cigar-break 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cigar_break-0.1.0/.gitignore +12 -0
- cigar_break-0.1.0/CHANGELOG.md +14 -0
- cigar_break-0.1.0/LICENSE +21 -0
- cigar_break-0.1.0/PKG-INFO +163 -0
- cigar_break-0.1.0/README.md +153 -0
- cigar_break-0.1.0/pyproject.toml +42 -0
- cigar_break-0.1.0/src/cigar/__init__.py +3 -0
- cigar_break-0.1.0/src/cigar/auto.py +70 -0
- cigar_break-0.1.0/src/cigar/cli.py +128 -0
- cigar_break-0.1.0/src/cigar/config.py +120 -0
- cigar_break-0.1.0/src/cigar/defaults.py +100 -0
- cigar_break-0.1.0/src/cigar/handoff.py +176 -0
- cigar_break-0.1.0/src/cigar/hook.py +276 -0
- cigar_break-0.1.0/src/cigar/knee.py +129 -0
- cigar_break-0.1.0/src/cigar/logs.py +42 -0
- cigar_break-0.1.0/src/cigar/mcp_server.py +297 -0
- cigar_break-0.1.0/src/cigar/migrations/0001_init.sql +70 -0
- cigar_break-0.1.0/src/cigar/migrations/0002_context_window.sql +3 -0
- cigar_break-0.1.0/src/cigar/report.py +384 -0
- cigar_break-0.1.0/src/cigar/score.py +187 -0
- cigar_break-0.1.0/src/cigar/share.py +290 -0
- cigar_break-0.1.0/src/cigar/signals.py +179 -0
- cigar_break-0.1.0/src/cigar/status.py +75 -0
- cigar_break-0.1.0/src/cigar/store.py +359 -0
- cigar_break-0.1.0/src/cigar/track.py +293 -0
- cigar_break-0.1.0/src/cigar/transcript.py +84 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 · 2026-10-26
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- Detection: failures, repeats, re-reads, undos and failed checks over the last 20 tool calls, plus context pressure, combined into a fatigue score with evidence-backed nudges (at most three per session, 25 calls apart, never below 40k tokens).
|
|
8
|
+
- Handoffs: `take_break`, `resume` and `status` MCP tools; handoffs saved in `<project>/.cigar/handoffs/` and restored after `/clear` or `/compact`.
|
|
9
|
+
- Modes: `suggest` (default), `observe`, and opt-in `auto` (terminal), which clears the context only after a handoff is saved for the session.
|
|
10
|
+
- Learning: per-model knees from your own sessions, per context window (`200k` or `1m`, detected or set with `context_window`); learned knees replace the defaults at medium confidence or above.
|
|
11
|
+
- Report: `cigar report` and `/cigar:report`, as text and a self-contained HTML page.
|
|
12
|
+
- Sharing: opt-in `cigar share` and `/cigar:share`, an allowlisted aggregate payload opened as a prefilled GitHub issue; `scripts/ingest_curves.py` and `data/curves/`.
|
|
13
|
+
- Any agent: `record_event` MCP tool, `cigar.track` helper, `cigar-mcp` on PyPI as `cigar-break`.
|
|
14
|
+
- Fail-open hooks; standard library only; Python 3.11+ found even when `python3` is older.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mohamed Youssef
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cigar-break
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Detects when a coding agent's session is degrading, gets a structured handoff, and restores it after a context reset.
|
|
5
|
+
Author: Mohamed Youssef
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# Cigar 🚬
|
|
12
|
+
|
|
13
|
+
**Your coding agent gets worse as its context fills up. Cigar notices, has it write a handoff, and restarts it fresh without losing the thread.**
|
|
14
|
+
|
|
15
|
+
Two hours into a long Claude Code session the agent starts failing tool calls, re-reading files it already read and undoing its own edits. By the time you notice, you've lost twenty minutes; if you reset too early, you lose your place. Cigar watches for those signs, which the session already produces, and offers the agent a *cigar break*: write a structured handoff, reset the context, pick up from the handoff in a fresh one.
|
|
16
|
+
|
|
17
|
+
In auto mode (opt-in, terminal), the whole break happens by itself. An illustrative session; the 🚬 lines are Cigar's own messages:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
🚬 Cigar: this session shows fatigue (152k tokens, 9 signals in the last 20 calls).
|
|
21
|
+
Claude has been offered a break; in auto mode the context resets once it saves a handoff.
|
|
22
|
+
⏺ cigar - take_break (MCP) … Handoff saved to .cigar/handoffs/2026-10-24T0130-ab12.md
|
|
23
|
+
🚬 Handoff ready. Cigar auto mode clears the context now; if it doesn't, type /clear.
|
|
24
|
+
🚬 Cigar auto mode cleared the context and restored the handoff from .cigar/handoffs/2026-10-24T0130-ab12.md.
|
|
25
|
+
> what's next?
|
|
26
|
+
⏺ We're adding retry logic to the upload client. Next: make test_retry_backoff pass …
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
In Claude Code:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
/plugin marketplace add MYoussef885/cigar-break
|
|
35
|
+
/plugin install cigar@cigar-break
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Or from a shell: `claude plugin marketplace add MYoussef885/cigar-break && claude plugin install cigar@cigar-break`.
|
|
39
|
+
|
|
40
|
+
**Requirements:** Python 3.11 or newer somewhere on the machine, and nothing else (no packages to install). Claude Code runs Cigar with whatever `python3` is on your PATH; if that is older (macOS ships 3.9), Cigar's launcher finds `python3.11` to `python3.14` on your PATH, in `~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin` or uv's Python folder, and uses it. If there is none, Cigar shows one warning at session start, at most once a day, and otherwise stays out of the way. `uv python install 3.12` is the quickest fix.
|
|
41
|
+
|
|
42
|
+
## How it works
|
|
43
|
+
|
|
44
|
+
1. **Detect.** After every tool call, Cigar reads the context size from the tail of the session transcript and looks at the last 20 calls for five signals: failed calls, repeated identical calls, files re-read with no edit in between, edits that undo an earlier edit, and failed tests, builds or lint runs. It combines them with *context pressure* (context size ÷ the model's knee) into a fatigue score. It never nudges below 40k tokens, and never on fewer than 10 calls of evidence (unless the session is far past the knee).
|
|
45
|
+
2. **Nudge (`suggest`, the default).** When the score crosses the threshold, the agent gets a short factual note with the evidence, and you see one line (and a terminal notification). At most three nudges per session, 25 calls apart.
|
|
46
|
+
3. **Hand off.** The agent calls Cigar's `take_break` tool and writes a handoff: goal, status, what's done, decisions, files, next steps, open questions and gotchas. It's saved in your project's `.cigar/handoffs/`, which git ignores by itself (Cigar never touches your `.gitignore`).
|
|
47
|
+
4. **Reset and restore.** Type `/clear` (or `/compact`). Cigar injects the handoff into the fresh session, so the agent's first reply already knows the goal and the next step.
|
|
48
|
+
5. **Learn.** Every model starts with a default *knee*, the context size where sessions usually start to slip (for example 100k for Sonnet and 120k for Opus on a 200k window). As you work, Cigar learns where **your** sessions slip, per model, and uses that once it is confident. `/cigar:report` shows the curve.
|
|
49
|
+
|
|
50
|
+
Modes: `suggest` (default) nudges and waits for you; `observe` only records; `auto` resets by itself (below). Switch with `/cigar:mode`.
|
|
51
|
+
|
|
52
|
+
### Auto mode (opt-in, terminal)
|
|
53
|
+
|
|
54
|
+
`/cigar:mode auto` turns it on; it's off by default. In auto mode, once the agent has **saved a handoff for the current session**, Cigar clears the context itself at the end of that turn, restores the handoff, and tells you in one line which handoff it restored. If the agent ignores the nudge, auto mode behaves exactly like `suggest`: it nudges, waits, and never clears. It also never clears after an interrupted turn or for a subagent.
|
|
55
|
+
|
|
56
|
+
Auto mode uses a Claude Code plugin mod and is **verified in the terminal CLI only**. It is not claimed for the VS Code panel; use `suggest` there.
|
|
57
|
+
|
|
58
|
+
## Commands
|
|
59
|
+
|
|
60
|
+
| Command | What it does |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `/cigar:status` | Fatigue score, context size, knee, recent signals and mode |
|
|
63
|
+
| `/cigar:break` | Have the agent write a handoff now; then type `/clear` |
|
|
64
|
+
| `/cigar:mode [suggest\|observe\|auto]` | Show or change the mode |
|
|
65
|
+
| `/cigar:report` | Your signal rate by context size, per model, with the learned knee; also writes an HTML page |
|
|
66
|
+
| `/cigar:share` | Show your anonymous curve; `/cigar:share confirm` opens it as a prefilled GitHub issue |
|
|
67
|
+
|
|
68
|
+
The same is available outside Claude Code as `cigar status`, `cigar report [--html|--open]`, `cigar share [--dry-run]` and `cigar mode` (`python3 <plugin>/bin/cigar …`, or `cigar` after `pip install cigar-break`).
|
|
69
|
+
|
|
70
|
+
## Privacy
|
|
71
|
+
|
|
72
|
+
- **Everything stays on your machine:** `~/.cigar/` (store, config, logs, reports) and `<project>/.cigar/` (handoffs).
|
|
73
|
+
- Cigar stores **hashes** of tool arguments, never the arguments. It keeps file paths only to spot re-reads and undos, locally.
|
|
74
|
+
- It never stores prompts, assistant text or file contents. Handoffs are the exception: the agent writes them on purpose, into your project folder.
|
|
75
|
+
- **No telemetry, no network calls.** The only thing that ever leaves the machine is a curve you choose to share, below, and even then Cigar only opens a prefilled GitHub issue in your browser; nothing is sent until you press Submit there.
|
|
76
|
+
|
|
77
|
+
## Sharing your curve (opt-in)
|
|
78
|
+
|
|
79
|
+
Where agents get tired is an open question, and every developer's sessions are a data point. `cigar share` (or `/cigar:share`) shows you the **exact** payload first: one model ID, its context window label (`200k` or `1m`), counts and signal weight per 10k-token bucket, the knee and its confidence, the number of sessions, and the Cigar version. No paths, project names, code, prompts or user identifiers; a strict allowlist check runs before anything is shown. If you confirm, it opens a prefilled issue on this repo. Accepted curves are published in [`data/curves/`](data/curves/README.md) for anyone to use. `cigar share --dry-run` just prints the payload.
|
|
80
|
+
|
|
81
|
+
Shared curves are published under [CC0-1.0](data/curves/LICENSE) (public domain), not the code's MIT license.
|
|
82
|
+
|
|
83
|
+
## Other agents
|
|
84
|
+
|
|
85
|
+
**Standalone MCP server.** `pip install cigar-break` installs `cigar-mcp`, a stdio MCP server with `take_break`, `resume`, `status` and `record_event`. Agents without Claude Code's hooks call `record_event` after each tool call to get detection, and `take_break`/`resume` for handoffs. Tested with the official MCP Python SDK client (`scripts/check_mcp_client.py`); other clients have not been tested yet.
|
|
86
|
+
|
|
87
|
+
**Custom Python agent loops** can skip MCP and use `cigar.track` directly:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from cigar.track import Tracker
|
|
91
|
+
|
|
92
|
+
tracker = Tracker(session_id="run-42", model="gpt-5") # one per agent session
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def run_tool(name, args):
|
|
96
|
+
try:
|
|
97
|
+
result = TOOLS[name](**args)
|
|
98
|
+
ok, code = True, 0
|
|
99
|
+
except ToolFailed as exc:
|
|
100
|
+
result, ok, code = str(exc), False, exc.exit_code
|
|
101
|
+
check = tracker.record(
|
|
102
|
+
name,
|
|
103
|
+
ok,
|
|
104
|
+
context_tokens=current_context_tokens(),
|
|
105
|
+
kind={"read_file": "read", "edit_file": "edit", "shell": "shell"}.get(name, "other"),
|
|
106
|
+
path=args.get("path"),
|
|
107
|
+
args=args, # hashed, never stored
|
|
108
|
+
exit_code=code,
|
|
109
|
+
)
|
|
110
|
+
if check.break_suggested:
|
|
111
|
+
result += "\n\n" + check.message # factual note: context size, knee, evidence
|
|
112
|
+
return result
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Verified setups
|
|
116
|
+
|
|
117
|
+
Only these have been tested:
|
|
118
|
+
|
|
119
|
+
| Setup | What was verified |
|
|
120
|
+
|---|---|
|
|
121
|
+
| Claude Code CLI 2.1.288, terminal, macOS 26, Python 3.11 via uv (system `python3` 3.9) | Detection, nudges, handoff, restore after `/clear` and `/compact`, `suggest` and `auto` modes (live sessions, Session 1) |
|
|
122
|
+
| Claude Code 2.1.309, terminal, macOS | Auto-mode `/clear` from the plugin mod; plugin install from a marketplace in two commands |
|
|
123
|
+
| `cigar-mcp` from a wheel, official MCP Python SDK 2.3.0 client, macOS and Ubuntu (CI) | All four MCP tools, outside the plugin |
|
|
124
|
+
| Unit, replay, fail-open and MCP tests on Ubuntu, macOS and Windows (CI) | Tests only, no live Claude Code session on Linux or Windows |
|
|
125
|
+
|
|
126
|
+
Not verified yet: the VS Code extension's chat panel, a live session on Linux or Windows, and other MCP clients.
|
|
127
|
+
|
|
128
|
+
## Prior art
|
|
129
|
+
|
|
130
|
+
Similar tools exist. foldcrumbs, novyx-hygiene and claude-mem save memory around compaction; Cairn and a "context rot detection" MCP server report health zones from token usage. Most of them use a fixed threshold. Cigar's difference is a per-model knee learned from *behavioral* signals in your own sessions, an evidence-backed nudge that leads into a structured handoff and restore, and an open dataset of shared curves. No code was taken from those projects.
|
|
131
|
+
|
|
132
|
+
## Configuration
|
|
133
|
+
|
|
134
|
+
`~/.cigar/config.toml`, overridden by environment variables:
|
|
135
|
+
|
|
136
|
+
| Key | Env | Default |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `mode` | `CIGAR_MODE` | `suggest` |
|
|
139
|
+
| `min_tokens` | `CIGAR_MIN_TOKENS` | `40000` |
|
|
140
|
+
| `knee_tokens` (one knee for every model) | `CIGAR_KNEE_TOKENS` | learned or default per model |
|
|
141
|
+
| `context_window` (`200k` or `1m`) | `CIGAR_CONTEXT_WINDOW` | detected per session (past 200k means `1m`) |
|
|
142
|
+
| `cooldown_events`, `max_nudges` | | `25`, `3` |
|
|
143
|
+
| | `CIGAR_HOME` | `~/.cigar` |
|
|
144
|
+
|
|
145
|
+
To see Cigar trigger quickly in a scratch project: `CIGAR_KNEE_TOKENS=20000 CIGAR_MIN_TOKENS=5000 claude`.
|
|
146
|
+
|
|
147
|
+
## Development
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
uv sync --group dev
|
|
151
|
+
uv run ruff check . && uv run ruff format --check .
|
|
152
|
+
uv run pytest
|
|
153
|
+
uv run pytest tests/test_perf.py -s # prints the hook p95
|
|
154
|
+
claude --plugin-dir . # load the plugin from this checkout
|
|
155
|
+
uv run python scripts/simulate.py --home /tmp/cigar-sim # fake data with a known knee
|
|
156
|
+
CIGAR_HOME=/tmp/cigar-sim uv run cigar report --open
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Design notes live in [`SRS.md`](SRS.md), [`architecture.md`](architecture.md) and [`decisions.md`](decisions.md). Changes: [`CHANGELOG.md`](CHANGELOG.md).
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
Code: MIT. Shared curves in `data/curves/`: CC0-1.0.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Cigar 🚬
|
|
2
|
+
|
|
3
|
+
**Your coding agent gets worse as its context fills up. Cigar notices, has it write a handoff, and restarts it fresh without losing the thread.**
|
|
4
|
+
|
|
5
|
+
Two hours into a long Claude Code session the agent starts failing tool calls, re-reading files it already read and undoing its own edits. By the time you notice, you've lost twenty minutes; if you reset too early, you lose your place. Cigar watches for those signs, which the session already produces, and offers the agent a *cigar break*: write a structured handoff, reset the context, pick up from the handoff in a fresh one.
|
|
6
|
+
|
|
7
|
+
In auto mode (opt-in, terminal), the whole break happens by itself. An illustrative session; the 🚬 lines are Cigar's own messages:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
🚬 Cigar: this session shows fatigue (152k tokens, 9 signals in the last 20 calls).
|
|
11
|
+
Claude has been offered a break; in auto mode the context resets once it saves a handoff.
|
|
12
|
+
⏺ cigar - take_break (MCP) … Handoff saved to .cigar/handoffs/2026-10-24T0130-ab12.md
|
|
13
|
+
🚬 Handoff ready. Cigar auto mode clears the context now; if it doesn't, type /clear.
|
|
14
|
+
🚬 Cigar auto mode cleared the context and restored the handoff from .cigar/handoffs/2026-10-24T0130-ab12.md.
|
|
15
|
+
> what's next?
|
|
16
|
+
⏺ We're adding retry logic to the upload client. Next: make test_retry_backoff pass …
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
In Claude Code:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
/plugin marketplace add MYoussef885/cigar-break
|
|
25
|
+
/plugin install cigar@cigar-break
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or from a shell: `claude plugin marketplace add MYoussef885/cigar-break && claude plugin install cigar@cigar-break`.
|
|
29
|
+
|
|
30
|
+
**Requirements:** Python 3.11 or newer somewhere on the machine, and nothing else (no packages to install). Claude Code runs Cigar with whatever `python3` is on your PATH; if that is older (macOS ships 3.9), Cigar's launcher finds `python3.11` to `python3.14` on your PATH, in `~/.local/bin`, `/opt/homebrew/bin`, `/usr/local/bin` or uv's Python folder, and uses it. If there is none, Cigar shows one warning at session start, at most once a day, and otherwise stays out of the way. `uv python install 3.12` is the quickest fix.
|
|
31
|
+
|
|
32
|
+
## How it works
|
|
33
|
+
|
|
34
|
+
1. **Detect.** After every tool call, Cigar reads the context size from the tail of the session transcript and looks at the last 20 calls for five signals: failed calls, repeated identical calls, files re-read with no edit in between, edits that undo an earlier edit, and failed tests, builds or lint runs. It combines them with *context pressure* (context size ÷ the model's knee) into a fatigue score. It never nudges below 40k tokens, and never on fewer than 10 calls of evidence (unless the session is far past the knee).
|
|
35
|
+
2. **Nudge (`suggest`, the default).** When the score crosses the threshold, the agent gets a short factual note with the evidence, and you see one line (and a terminal notification). At most three nudges per session, 25 calls apart.
|
|
36
|
+
3. **Hand off.** The agent calls Cigar's `take_break` tool and writes a handoff: goal, status, what's done, decisions, files, next steps, open questions and gotchas. It's saved in your project's `.cigar/handoffs/`, which git ignores by itself (Cigar never touches your `.gitignore`).
|
|
37
|
+
4. **Reset and restore.** Type `/clear` (or `/compact`). Cigar injects the handoff into the fresh session, so the agent's first reply already knows the goal and the next step.
|
|
38
|
+
5. **Learn.** Every model starts with a default *knee*, the context size where sessions usually start to slip (for example 100k for Sonnet and 120k for Opus on a 200k window). As you work, Cigar learns where **your** sessions slip, per model, and uses that once it is confident. `/cigar:report` shows the curve.
|
|
39
|
+
|
|
40
|
+
Modes: `suggest` (default) nudges and waits for you; `observe` only records; `auto` resets by itself (below). Switch with `/cigar:mode`.
|
|
41
|
+
|
|
42
|
+
### Auto mode (opt-in, terminal)
|
|
43
|
+
|
|
44
|
+
`/cigar:mode auto` turns it on; it's off by default. In auto mode, once the agent has **saved a handoff for the current session**, Cigar clears the context itself at the end of that turn, restores the handoff, and tells you in one line which handoff it restored. If the agent ignores the nudge, auto mode behaves exactly like `suggest`: it nudges, waits, and never clears. It also never clears after an interrupted turn or for a subagent.
|
|
45
|
+
|
|
46
|
+
Auto mode uses a Claude Code plugin mod and is **verified in the terminal CLI only**. It is not claimed for the VS Code panel; use `suggest` there.
|
|
47
|
+
|
|
48
|
+
## Commands
|
|
49
|
+
|
|
50
|
+
| Command | What it does |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `/cigar:status` | Fatigue score, context size, knee, recent signals and mode |
|
|
53
|
+
| `/cigar:break` | Have the agent write a handoff now; then type `/clear` |
|
|
54
|
+
| `/cigar:mode [suggest\|observe\|auto]` | Show or change the mode |
|
|
55
|
+
| `/cigar:report` | Your signal rate by context size, per model, with the learned knee; also writes an HTML page |
|
|
56
|
+
| `/cigar:share` | Show your anonymous curve; `/cigar:share confirm` opens it as a prefilled GitHub issue |
|
|
57
|
+
|
|
58
|
+
The same is available outside Claude Code as `cigar status`, `cigar report [--html|--open]`, `cigar share [--dry-run]` and `cigar mode` (`python3 <plugin>/bin/cigar …`, or `cigar` after `pip install cigar-break`).
|
|
59
|
+
|
|
60
|
+
## Privacy
|
|
61
|
+
|
|
62
|
+
- **Everything stays on your machine:** `~/.cigar/` (store, config, logs, reports) and `<project>/.cigar/` (handoffs).
|
|
63
|
+
- Cigar stores **hashes** of tool arguments, never the arguments. It keeps file paths only to spot re-reads and undos, locally.
|
|
64
|
+
- It never stores prompts, assistant text or file contents. Handoffs are the exception: the agent writes them on purpose, into your project folder.
|
|
65
|
+
- **No telemetry, no network calls.** The only thing that ever leaves the machine is a curve you choose to share, below, and even then Cigar only opens a prefilled GitHub issue in your browser; nothing is sent until you press Submit there.
|
|
66
|
+
|
|
67
|
+
## Sharing your curve (opt-in)
|
|
68
|
+
|
|
69
|
+
Where agents get tired is an open question, and every developer's sessions are a data point. `cigar share` (or `/cigar:share`) shows you the **exact** payload first: one model ID, its context window label (`200k` or `1m`), counts and signal weight per 10k-token bucket, the knee and its confidence, the number of sessions, and the Cigar version. No paths, project names, code, prompts or user identifiers; a strict allowlist check runs before anything is shown. If you confirm, it opens a prefilled issue on this repo. Accepted curves are published in [`data/curves/`](data/curves/README.md) for anyone to use. `cigar share --dry-run` just prints the payload.
|
|
70
|
+
|
|
71
|
+
Shared curves are published under [CC0-1.0](data/curves/LICENSE) (public domain), not the code's MIT license.
|
|
72
|
+
|
|
73
|
+
## Other agents
|
|
74
|
+
|
|
75
|
+
**Standalone MCP server.** `pip install cigar-break` installs `cigar-mcp`, a stdio MCP server with `take_break`, `resume`, `status` and `record_event`. Agents without Claude Code's hooks call `record_event` after each tool call to get detection, and `take_break`/`resume` for handoffs. Tested with the official MCP Python SDK client (`scripts/check_mcp_client.py`); other clients have not been tested yet.
|
|
76
|
+
|
|
77
|
+
**Custom Python agent loops** can skip MCP and use `cigar.track` directly:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from cigar.track import Tracker
|
|
81
|
+
|
|
82
|
+
tracker = Tracker(session_id="run-42", model="gpt-5") # one per agent session
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def run_tool(name, args):
|
|
86
|
+
try:
|
|
87
|
+
result = TOOLS[name](**args)
|
|
88
|
+
ok, code = True, 0
|
|
89
|
+
except ToolFailed as exc:
|
|
90
|
+
result, ok, code = str(exc), False, exc.exit_code
|
|
91
|
+
check = tracker.record(
|
|
92
|
+
name,
|
|
93
|
+
ok,
|
|
94
|
+
context_tokens=current_context_tokens(),
|
|
95
|
+
kind={"read_file": "read", "edit_file": "edit", "shell": "shell"}.get(name, "other"),
|
|
96
|
+
path=args.get("path"),
|
|
97
|
+
args=args, # hashed, never stored
|
|
98
|
+
exit_code=code,
|
|
99
|
+
)
|
|
100
|
+
if check.break_suggested:
|
|
101
|
+
result += "\n\n" + check.message # factual note: context size, knee, evidence
|
|
102
|
+
return result
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Verified setups
|
|
106
|
+
|
|
107
|
+
Only these have been tested:
|
|
108
|
+
|
|
109
|
+
| Setup | What was verified |
|
|
110
|
+
|---|---|
|
|
111
|
+
| Claude Code CLI 2.1.288, terminal, macOS 26, Python 3.11 via uv (system `python3` 3.9) | Detection, nudges, handoff, restore after `/clear` and `/compact`, `suggest` and `auto` modes (live sessions, Session 1) |
|
|
112
|
+
| Claude Code 2.1.309, terminal, macOS | Auto-mode `/clear` from the plugin mod; plugin install from a marketplace in two commands |
|
|
113
|
+
| `cigar-mcp` from a wheel, official MCP Python SDK 2.3.0 client, macOS and Ubuntu (CI) | All four MCP tools, outside the plugin |
|
|
114
|
+
| Unit, replay, fail-open and MCP tests on Ubuntu, macOS and Windows (CI) | Tests only, no live Claude Code session on Linux or Windows |
|
|
115
|
+
|
|
116
|
+
Not verified yet: the VS Code extension's chat panel, a live session on Linux or Windows, and other MCP clients.
|
|
117
|
+
|
|
118
|
+
## Prior art
|
|
119
|
+
|
|
120
|
+
Similar tools exist. foldcrumbs, novyx-hygiene and claude-mem save memory around compaction; Cairn and a "context rot detection" MCP server report health zones from token usage. Most of them use a fixed threshold. Cigar's difference is a per-model knee learned from *behavioral* signals in your own sessions, an evidence-backed nudge that leads into a structured handoff and restore, and an open dataset of shared curves. No code was taken from those projects.
|
|
121
|
+
|
|
122
|
+
## Configuration
|
|
123
|
+
|
|
124
|
+
`~/.cigar/config.toml`, overridden by environment variables:
|
|
125
|
+
|
|
126
|
+
| Key | Env | Default |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| `mode` | `CIGAR_MODE` | `suggest` |
|
|
129
|
+
| `min_tokens` | `CIGAR_MIN_TOKENS` | `40000` |
|
|
130
|
+
| `knee_tokens` (one knee for every model) | `CIGAR_KNEE_TOKENS` | learned or default per model |
|
|
131
|
+
| `context_window` (`200k` or `1m`) | `CIGAR_CONTEXT_WINDOW` | detected per session (past 200k means `1m`) |
|
|
132
|
+
| `cooldown_events`, `max_nudges` | | `25`, `3` |
|
|
133
|
+
| | `CIGAR_HOME` | `~/.cigar` |
|
|
134
|
+
|
|
135
|
+
To see Cigar trigger quickly in a scratch project: `CIGAR_KNEE_TOKENS=20000 CIGAR_MIN_TOKENS=5000 claude`.
|
|
136
|
+
|
|
137
|
+
## Development
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
uv sync --group dev
|
|
141
|
+
uv run ruff check . && uv run ruff format --check .
|
|
142
|
+
uv run pytest
|
|
143
|
+
uv run pytest tests/test_perf.py -s # prints the hook p95
|
|
144
|
+
claude --plugin-dir . # load the plugin from this checkout
|
|
145
|
+
uv run python scripts/simulate.py --home /tmp/cigar-sim # fake data with a known knee
|
|
146
|
+
CIGAR_HOME=/tmp/cigar-sim uv run cigar report --open
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Design notes live in [`SRS.md`](SRS.md), [`architecture.md`](architecture.md) and [`decisions.md`](decisions.md). Changes: [`CHANGELOG.md`](CHANGELOG.md).
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
Code: MIT. Shared curves in `data/curves/`: CC0-1.0.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "cigar-break"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Detects when a coding agent's session is degrading, gets a structured handoff, and restores it after a context reset."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
authors = [{ name = "Mohamed Youssef" }]
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
cigar = "cigar.cli:main"
|
|
14
|
+
cigar-mcp = "cigar.mcp_server:main"
|
|
15
|
+
|
|
16
|
+
[dependency-groups]
|
|
17
|
+
dev = ["pytest>=8", "ruff>=0.6", "mcp>=1.9"]
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling"]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.wheel]
|
|
24
|
+
packages = ["src/cigar"]
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.sdist]
|
|
27
|
+
include = ["/src/cigar", "/README.md", "/LICENSE", "/CHANGELOG.md", "/pyproject.toml"]
|
|
28
|
+
|
|
29
|
+
[tool.ruff]
|
|
30
|
+
line-length = 100
|
|
31
|
+
target-version = "py311"
|
|
32
|
+
extend-exclude = ["tests/fixtures"]
|
|
33
|
+
|
|
34
|
+
[tool.ruff.lint]
|
|
35
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]
|
|
36
|
+
|
|
37
|
+
[tool.ruff.lint.per-file-ignores]
|
|
38
|
+
"bin/*" = ["UP"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
42
|
+
markers = ["perf: performance measurement (prints the hook p95)"]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Auto mode markers (D-007, D-021, D-026).
|
|
2
|
+
|
|
3
|
+
In `auto` mode, once a handoff is saved for the session, the hook writes
|
|
4
|
+
<CIGAR_HOME>/auto/<session id>.json with state "ready" and the handoff's path.
|
|
5
|
+
The mod in mods/auto.ts reads it at the end of the turn, marks it "fired" and
|
|
6
|
+
runs /clear. With no marker, or one without a handoff path, it never clears.
|
|
7
|
+
Only these markers cross between the two.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
MAX_AGE_SECONDS = 24 * 3600
|
|
17
|
+
FIRED_MAX_AGE_SECONDS = 600 # a clear that fired longer ago than this is not "just now"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def marker_path(home: Path, session_id: str) -> Path:
|
|
21
|
+
safe = "".join(c for c in session_id if c.isalnum() or c in "-_")
|
|
22
|
+
return home / "auto" / f"{safe}.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def mark_ready(home: Path, session_id: str, handoff: str) -> Path:
|
|
26
|
+
path = marker_path(home, session_id)
|
|
27
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
tmp = path.with_suffix(".tmp")
|
|
29
|
+
marker = {"state": "ready", "session": session_id, "handoff": handoff, "ts": time.time()}
|
|
30
|
+
tmp.write_text(json.dumps(marker), encoding="utf-8")
|
|
31
|
+
tmp.replace(path)
|
|
32
|
+
return path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def take_fired(home: Path, max_age: float = FIRED_MAX_AGE_SECONDS) -> str | None:
|
|
36
|
+
"""The handoff path of a clear the mod just fired, removing its marker."""
|
|
37
|
+
folder = home / "auto"
|
|
38
|
+
if not folder.is_dir():
|
|
39
|
+
return None
|
|
40
|
+
found: tuple[float, str] | None = None
|
|
41
|
+
cutoff = time.time() - max_age
|
|
42
|
+
for path in folder.glob("*.json"):
|
|
43
|
+
try:
|
|
44
|
+
marker = json.loads(path.read_text(encoding="utf-8"))
|
|
45
|
+
if marker.get("state") != "fired" or path.stat().st_mtime < cutoff:
|
|
46
|
+
continue
|
|
47
|
+
handoff = marker.get("handoff")
|
|
48
|
+
if isinstance(handoff, str) and handoff:
|
|
49
|
+
stamp = path.stat().st_mtime
|
|
50
|
+
if found is None or stamp > found[0]:
|
|
51
|
+
found = (stamp, handoff)
|
|
52
|
+
path.unlink()
|
|
53
|
+
except (OSError, ValueError, AttributeError):
|
|
54
|
+
continue
|
|
55
|
+
return found[1] if found else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def prune(home: Path, max_age: float = MAX_AGE_SECONDS) -> None:
|
|
59
|
+
"""Remove fired markers and any older than max_age."""
|
|
60
|
+
folder = home / "auto"
|
|
61
|
+
if not folder.is_dir():
|
|
62
|
+
return
|
|
63
|
+
cutoff = time.time() - max_age
|
|
64
|
+
for path in folder.glob("*.json"):
|
|
65
|
+
try:
|
|
66
|
+
fired = json.loads(path.read_text(encoding="utf-8")).get("state") == "fired"
|
|
67
|
+
if fired or path.stat().st_mtime < cutoff:
|
|
68
|
+
path.unlink()
|
|
69
|
+
except (OSError, ValueError, AttributeError):
|
|
70
|
+
path.unlink(missing_ok=True)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""`cigar` command line: status, report, share and mode (FR-C6)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from cigar import __version__, config, status
|
|
12
|
+
from cigar.store import Store, project_hash
|
|
13
|
+
|
|
14
|
+
MODE_HELP = {
|
|
15
|
+
"suggest": "Cigar nudges the agent and you when the session shows fatigue (default).",
|
|
16
|
+
"observe": "Cigar records signals only, with no nudges.",
|
|
17
|
+
"auto": (
|
|
18
|
+
"After a handoff is saved, Cigar clears the context itself at the end of the turn and "
|
|
19
|
+
"restores the handoff. Needs plugin mods (Claude Code 2.1.287 or newer); tested in the "
|
|
20
|
+
"terminal CLI only."
|
|
21
|
+
),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
26
|
+
cfg = config.load()
|
|
27
|
+
project_dir = args.project or os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
|
|
28
|
+
project = project_hash(str(Path(project_dir)))
|
|
29
|
+
with Store.open(cfg.db_path) as store:
|
|
30
|
+
info = status.collect(store, cfg, args.session, project)
|
|
31
|
+
print(json.dumps(info, indent=2) if args.json else status.render(info))
|
|
32
|
+
return 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cmd_mode(args: argparse.Namespace) -> int:
|
|
36
|
+
cfg = config.load()
|
|
37
|
+
mode = (args.mode or "").strip().lower()
|
|
38
|
+
if not mode:
|
|
39
|
+
print(f"Mode: {cfg.mode}. {MODE_HELP[cfg.mode]}")
|
|
40
|
+
return 0
|
|
41
|
+
if mode not in config.MODES:
|
|
42
|
+
print(f"Unknown mode {mode!r}. Choose one of: {', '.join(config.MODES)}.")
|
|
43
|
+
return 2
|
|
44
|
+
args.mode = mode
|
|
45
|
+
path = config.write_value(cfg.home, "mode", args.mode)
|
|
46
|
+
note = ""
|
|
47
|
+
if os.environ.get("CIGAR_MODE") and os.environ["CIGAR_MODE"] != args.mode:
|
|
48
|
+
note = f" Note: CIGAR_MODE={os.environ['CIGAR_MODE']} is set and overrides it."
|
|
49
|
+
print(f"Mode set to {args.mode} in {path}. {MODE_HELP[args.mode]}{note}")
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cmd_report(args: argparse.Namespace) -> int:
|
|
54
|
+
from cigar import report, track
|
|
55
|
+
|
|
56
|
+
cfg = config.load()
|
|
57
|
+
with Store.open(cfg.db_path) as store:
|
|
58
|
+
fits = track.refresh_knees(store, force=True) or []
|
|
59
|
+
compactions = store.compaction_tokens()
|
|
60
|
+
print(report.render_text(fits, compactions))
|
|
61
|
+
if args.html or args.open:
|
|
62
|
+
path = report.write_html(cfg.home, report.render_html(fits, compactions))
|
|
63
|
+
print(f"\nHTML report: {path}")
|
|
64
|
+
if args.open:
|
|
65
|
+
import webbrowser
|
|
66
|
+
|
|
67
|
+
webbrowser.open(path.as_uri())
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def cmd_share(args: argparse.Namespace) -> int:
|
|
72
|
+
from cigar import share, track
|
|
73
|
+
|
|
74
|
+
cfg = config.load()
|
|
75
|
+
with Store.open(cfg.db_path) as store:
|
|
76
|
+
fits = track.refresh_knees(store, force=True) or []
|
|
77
|
+
harnesses = track.harnesses(store)
|
|
78
|
+
return share.run(
|
|
79
|
+
cfg,
|
|
80
|
+
fits,
|
|
81
|
+
harnesses,
|
|
82
|
+
model=args.model,
|
|
83
|
+
window=args.window,
|
|
84
|
+
dry_run=args.dry_run,
|
|
85
|
+
yes=args.yes,
|
|
86
|
+
skill=args.skill,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
91
|
+
parser = argparse.ArgumentParser(
|
|
92
|
+
prog="cigar", description="Cigar: take a break before the context rots."
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument("--version", action="version", version=f"cigar {__version__}")
|
|
95
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
96
|
+
|
|
97
|
+
p_status = sub.add_parser("status", help="score, context size, knee, recent signals and mode")
|
|
98
|
+
p_status.add_argument("--session", help="session ID (default: the latest in this project)")
|
|
99
|
+
p_status.add_argument("--project", help="project directory (default: current directory)")
|
|
100
|
+
p_status.add_argument("--json", action="store_true", help="print JSON")
|
|
101
|
+
p_status.set_defaults(func=cmd_status)
|
|
102
|
+
|
|
103
|
+
p_report = sub.add_parser("report", help="your signal rate by context size, per model")
|
|
104
|
+
p_report.add_argument("--html", action="store_true", help="also write the HTML report")
|
|
105
|
+
p_report.add_argument("--open", action="store_true", help="write the HTML and open it")
|
|
106
|
+
p_report.set_defaults(func=cmd_report)
|
|
107
|
+
|
|
108
|
+
p_share = sub.add_parser("share", help="share your anonymous curve (opt-in, shows it first)")
|
|
109
|
+
p_share.add_argument("--dry-run", action="store_true", help="print the payload only")
|
|
110
|
+
p_share.add_argument("--yes", action="store_true", help="skip the confirmation question")
|
|
111
|
+
p_share.add_argument("--model", help="which model's curve (default: the most data)")
|
|
112
|
+
p_share.add_argument("--window", choices=["200k", "1m"], help="which context window")
|
|
113
|
+
p_share.add_argument("--skill", help=argparse.SUPPRESS) # /cigar:share passes its argument
|
|
114
|
+
p_share.set_defaults(func=cmd_share)
|
|
115
|
+
|
|
116
|
+
p_mode = sub.add_parser("mode", help="show or set the mode")
|
|
117
|
+
p_mode.add_argument("mode", nargs="?", help="suggest, observe or auto")
|
|
118
|
+
p_mode.set_defaults(func=cmd_mode)
|
|
119
|
+
return parser
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main(argv: list[str] | None = None) -> int:
|
|
123
|
+
args = build_parser().parse_args(argv)
|
|
124
|
+
return int(args.func(args))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
sys.exit(main())
|