context-handoff 0.1.1__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """Keep Claude Code sessions cheap: meter cost, hand off, resume after /clear."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,58 @@
1
+ """context-handoff entry point.
2
+
3
+ context-handoff meter UserPromptSubmit hook (reads hook JSON on stdin)
4
+ context-handoff resume SessionStart hook (reads hook JSON on stdin)
5
+ context-handoff report [days]
6
+ context-handoff check <transcript.jsonl> print what the meter sees
7
+
8
+ Hooks never fail the session: any error ends with no output and exit 0.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+
16
+ from . import config, meter, report, resume
17
+
18
+
19
+ def _hook(fn) -> int:
20
+ try:
21
+ event = json.load(sys.stdin)
22
+ out = fn(event, config.load())
23
+ except Exception as exc: # a hook must never break the session
24
+ print(f"context-handoff: {exc}", file=sys.stderr)
25
+ return 0
26
+ if out:
27
+ print(json.dumps(out))
28
+ return 0
29
+
30
+
31
+ def main(argv: list[str] | None = None) -> int:
32
+ argv = sys.argv[1:] if argv is None else argv
33
+ cmd = argv[0] if argv else ""
34
+ if cmd == "meter":
35
+ return _hook(meter.run)
36
+ if cmd == "resume":
37
+ return _hook(resume.run)
38
+ if cmd == "report":
39
+ days = int(argv[1]) if len(argv) > 1 else 7
40
+ print(report.render(config.load(), days))
41
+ return 0
42
+ if cmd == "check" and len(argv) > 1:
43
+ cfg = config.load()
44
+ r = meter.measure(argv[1], cfg)
45
+ if r is None:
46
+ print("no Claude replies in that transcript")
47
+ return 1
48
+ block, warn = meter.reasons(r, cfg)
49
+ print(r.summary())
50
+ print("block:", "; ".join(block) or "no")
51
+ print("warn: ", "; ".join(warn) or "no")
52
+ return 0
53
+ print(__doc__.strip())
54
+ return 2
55
+
56
+
57
+ if __name__ == "__main__":
58
+ sys.exit(main())
@@ -0,0 +1,90 @@
1
+ """Settings: defaults, then ~/.claude/context-handoff.json, then environment.
2
+
3
+ Config file example (all keys optional):
4
+
5
+ {
6
+ "warn_k": 150, "block_k": 400,
7
+ "warn_turn_usd": 0.50, "block_turn_usd": 3.00,
8
+ "warn_session_usd": 20, "auto_handoff": true,
9
+ "prices": {"claude-opus-5-5": [4.0, 20.0, 0.20]}
10
+ }
11
+
12
+ Environment variables use the same names, upper case, with a
13
+ CONTEXT_HANDOFF_ prefix: CONTEXT_HANDOFF_WARN_TURN_USD=1.0 and so on.
14
+ A limit set to 0 is turned off.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ from dataclasses import dataclass, field, fields
22
+ from pathlib import Path
23
+
24
+ from .prices import DEFAULT_PRICES
25
+
26
+
27
+ def _home() -> Path:
28
+ return Path(os.environ.get("HOME") or Path.home())
29
+
30
+
31
+ @dataclass
32
+ class Config:
33
+ warn_k: int = 150 # context size (thousands of tokens) that triggers a handoff
34
+ block_k: int = 400 # context size that blocks prompts until a handoff
35
+ warn_turn_usd: float = 0.50 # cost of the last turn that triggers a handoff
36
+ block_turn_usd: float = 3.00 # cost of the last turn that blocks prompts
37
+ warn_session_usd: float = 20.0 # whole-session cost that triggers a handoff
38
+ auto_handoff: bool = True # ask Claude to write the note itself
39
+ max_age_min: int = 180 # notes older than this are not resumed
40
+ handoff_dir: str = ""
41
+ state_dir: str = ""
42
+ prices: dict = field(default_factory=lambda: dict(DEFAULT_PRICES))
43
+
44
+ @property
45
+ def notes(self) -> Path:
46
+ return Path(self.handoff_dir) if self.handoff_dir else _home() / ".claude" / "handoffs"
47
+
48
+ @property
49
+ def state(self) -> Path:
50
+ return Path(self.state_dir) if self.state_dir else _home() / ".claude" / "context-handoff"
51
+
52
+
53
+ def _coerce(kind, value):
54
+ if kind is bool or kind == "bool":
55
+ return str(value).strip().lower() in ("1", "true", "yes", "on")
56
+ if kind is int or kind == "int":
57
+ return int(float(value))
58
+ if kind is float or kind == "float":
59
+ return float(value)
60
+ return str(value)
61
+
62
+
63
+ def load(path: str | Path | None = None) -> Config:
64
+ cfg = Config()
65
+ path = Path(path) if path else Path(
66
+ os.environ.get("CONTEXT_HANDOFF_CONFIG") or _home() / ".claude" / "context-handoff.json"
67
+ )
68
+ data: dict = {}
69
+ if path.is_file():
70
+ try:
71
+ data = json.loads(path.read_text())
72
+ except (OSError, ValueError):
73
+ data = {}
74
+ for f in fields(Config):
75
+ if f.name == "prices":
76
+ continue
77
+ env = os.environ.get("CONTEXT_HANDOFF_" + f.name.upper())
78
+ raw = env if env not in (None, "") else data.get(f.name)
79
+ if raw is not None:
80
+ try:
81
+ setattr(cfg, f.name, _coerce(f.type, raw))
82
+ except (TypeError, ValueError):
83
+ pass
84
+ for prefix, row in (data.get("prices") or {}).items():
85
+ try:
86
+ i, o, r = (float(x) for x in row)
87
+ cfg.prices[prefix] = (i, o, r)
88
+ except (TypeError, ValueError):
89
+ pass
90
+ return cfg
@@ -0,0 +1,177 @@
1
+ """UserPromptSubmit hook: decide whether this session has become too expensive.
2
+
3
+ Every turn re-sends the whole conversation. While the prompt cache is warm the
4
+ re-read is cheap; after a pause the whole context is written again at the
5
+ cache-write price, and that is where long sessions get expensive. The meter
6
+ looks at three numbers from the transcript:
7
+
8
+ * context: tokens the last reply read (what the next turn re-sends)
9
+ * last turn: what the last reply cost
10
+ * session: what all replies in this session cost so far
11
+
12
+ and also projects what one cold turn (cache expired) would cost now.
13
+
14
+ Over a warn limit it asks Claude, once per session, to write a handoff note
15
+ when the current task is done. Over a block limit it blocks the prompt until
16
+ the owner asks for the handoff (prompt starts with "handoff") or overrides it
17
+ ("#override").
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import datetime as dt
23
+ import json
24
+ import re
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+
28
+ from . import transcript
29
+ from .config import Config
30
+ from .prices import lookup
31
+
32
+ BYPASS = re.compile(r"^\s*/?([a-z0-9-]+:)?handoff\b|#override", re.IGNORECASE)
33
+ # The plugin's /handoff command defines the note's format; the meter reuses it.
34
+ COMMAND_FILE = Path(__file__).resolve().parents[2] / "commands" / "handoff.md"
35
+
36
+
37
+ @dataclass
38
+ class Reading:
39
+ model: str
40
+ context: int
41
+ last_turn_usd: float
42
+ session_usd: float
43
+ cold_turn_usd: float
44
+ turns: int
45
+
46
+ def summary(self) -> str:
47
+ return (
48
+ f"{self.context // 1000}K context, last turn ${self.last_turn_usd:.2f}, "
49
+ f"session ${self.session_usd:.2f}, a cold turn would cost ${self.cold_turn_usd:.2f} "
50
+ f"({self.model} prices)"
51
+ )
52
+
53
+
54
+ def measure(path: str, cfg: Config) -> Reading | None:
55
+ session = transcript.read(path)
56
+ last = session.last
57
+ if last is None:
58
+ return None
59
+ price = lookup(last.model, cfg.prices)
60
+ return Reading(
61
+ model=price.model,
62
+ context=last.context,
63
+ last_turn_usd=last.cost(price),
64
+ session_usd=session.cost(cfg.prices),
65
+ cold_turn_usd=last.context * price.write_1h / 1e6,
66
+ turns=len(session.turns),
67
+ )
68
+
69
+
70
+ def _over(value: float, limit: float) -> bool:
71
+ return limit > 0 and value >= limit
72
+
73
+
74
+ def reasons(r: Reading, cfg: Config) -> tuple[list[str], list[str]]:
75
+ """(block reasons, warn reasons)."""
76
+ block, warn = [], []
77
+ if _over(r.context, cfg.block_k * 1000):
78
+ block.append(f"context {r.context // 1000}K >= {cfg.block_k}K")
79
+ if _over(r.last_turn_usd, cfg.block_turn_usd):
80
+ block.append(f"last turn ${r.last_turn_usd:.2f} >= ${cfg.block_turn_usd:.2f}")
81
+ if _over(r.context, cfg.warn_k * 1000):
82
+ warn.append(f"context {r.context // 1000}K >= {cfg.warn_k}K")
83
+ if _over(r.last_turn_usd, cfg.warn_turn_usd):
84
+ warn.append(f"last turn ${r.last_turn_usd:.2f} >= ${cfg.warn_turn_usd:.2f}")
85
+ if _over(r.session_usd, cfg.warn_session_usd):
86
+ warn.append(f"session ${r.session_usd:.2f} >= ${cfg.warn_session_usd:.2f}")
87
+ return block, warn
88
+
89
+
90
+ def note_path(cfg: Config, cwd: str, now: dt.datetime | None = None) -> Path:
91
+ now = now or dt.datetime.now()
92
+ project = Path(cwd).name or "session"
93
+ return cfg.notes / f"{project}-{now:%Y%m%d-%H%M}.md"
94
+
95
+
96
+ def note_instructions() -> str:
97
+ """The handoff command's body: the one place the note's format is defined."""
98
+ try:
99
+ text = COMMAND_FILE.read_text()
100
+ except OSError:
101
+ return "Write the goal, state, decisions, open items and next steps, where things are, and gotchas."
102
+ if text.startswith("---"):
103
+ text = text.split("---", 2)[2]
104
+ return text.replace("$ARGUMENTS", "").strip()
105
+
106
+
107
+ def _log(cfg: Config, event: dict) -> None:
108
+ try:
109
+ cfg.state.mkdir(parents=True, exist_ok=True)
110
+ with open(cfg.state / "meter.jsonl", "a") as f:
111
+ f.write(json.dumps(event) + "\n")
112
+ except OSError:
113
+ pass
114
+
115
+
116
+ def run(event: dict, cfg: Config) -> dict | None:
117
+ """Hook output as a dict, or None for no output."""
118
+ path = event.get("transcript_path") or ""
119
+ session = event.get("session_id") or ""
120
+ cwd = event.get("cwd") or ""
121
+ prompt = event.get("prompt") or ""
122
+ if not path or not Path(path).is_file():
123
+ return None
124
+ r = measure(path, cfg)
125
+ if r is None:
126
+ return None
127
+
128
+ _log(cfg, {
129
+ "ts": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"),
130
+ "session": session, "cwd": cwd, "model": r.model, "ctx": r.context,
131
+ "turn_usd": round(r.last_turn_usd, 4), "session_usd": round(r.session_usd, 4),
132
+ })
133
+
134
+ block, warn = reasons(r, cfg)
135
+ if BYPASS.search(prompt):
136
+ if block or warn:
137
+ return {"systemMessage": f"[context-handoff] {r.summary()}; prompt let through."}
138
+ return None
139
+
140
+ if block:
141
+ return {
142
+ "decision": "block",
143
+ "reason": (
144
+ f"[context-handoff] {'; '.join(block)}. {r.summary()}. Every turn re-sends the whole "
145
+ "session. Type: handoff (Claude saves a note), then /clear and type go. "
146
+ "Add #override to send this prompt anyway."
147
+ ),
148
+ }
149
+ if not warn:
150
+ return None
151
+
152
+ marker = cfg.state / f"asked-{session}"
153
+ if cfg.auto_handoff and session and not marker.exists():
154
+ try:
155
+ cfg.state.mkdir(parents=True, exist_ok=True)
156
+ marker.touch()
157
+ except OSError:
158
+ pass
159
+ target = note_path(cfg, cwd)
160
+ context = (
161
+ f"[context-handoff] This session has become expensive: {'; '.join(warn)}. {r.summary()}. "
162
+ "Answer the owner's message first. Then, at the end of the current task (or now, if "
163
+ f"nothing is in progress), write a handoff note to {target} following these "
164
+ "instructions:\n\n" + note_instructions()
165
+ )
166
+ return {
167
+ "systemMessage": (
168
+ f"[context-handoff] {r.summary()}. Claude will write a handoff note after this "
169
+ "task; then /clear and type go."
170
+ ),
171
+ "hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": context},
172
+ }
173
+ return {
174
+ "systemMessage": (
175
+ f"[context-handoff] {r.summary()}. Finish this task, type handoff, then /clear and go."
176
+ )
177
+ }
@@ -0,0 +1,66 @@
1
+ """Default per-million-token prices (USD) for Claude models on the Anthropic API.
2
+
3
+ Source: Anthropic's published first-party prices as of November 2026. Prices
4
+ change; override them in the config file (see config.py) rather than trusting
5
+ these forever. Cache writes cost 1.25x input (5-minute TTL) or 2x input
6
+ (1-hour TTL); cache reads have their own rate per model.
7
+
8
+ Each entry: model-id prefix -> (input, output, cache_read).
9
+ The longest matching prefix wins, so "claude-opus-4-8" beats "claude-opus-4".
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+
16
+ DEFAULT_PRICES: dict[str, tuple[float, float, float]] = {
17
+ "claude-fable-5-2": (8.00, 40.00, 0.20),
18
+ "claude-mythos-5-2": (8.00, 40.00, 0.20),
19
+ "claude-fable-5-1": (10.00, 50.00, 0.25),
20
+ "claude-mythos-5-1": (10.00, 50.00, 0.25),
21
+ "claude-fable-5": (10.00, 50.00, 1.00),
22
+ "claude-mythos-5": (10.00, 50.00, 1.00),
23
+ "claude-opus-5-5": (4.00, 20.00, 0.20),
24
+ "claude-opus-5": (5.00, 25.00, 0.50),
25
+ "claude-opus-4": (5.00, 25.00, 0.50),
26
+ "claude-sonnet-5-5": (2.00, 10.00, 0.10),
27
+ "claude-sonnet-5": (2.00, 10.00, 0.20),
28
+ "claude-sonnet-4": (3.00, 15.00, 0.30),
29
+ "claude-haiku-5-5": (0.10, 0.50, 0.01),
30
+ "claude-haiku-4": (1.00, 5.00, 0.10),
31
+ }
32
+
33
+ # Used when the model id matches nothing above: priced like the default Opus.
34
+ FALLBACK = "claude-opus-5-5"
35
+
36
+ WRITE_5M = 1.25
37
+ WRITE_1H = 2.0
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Price:
42
+ model: str
43
+ input: float
44
+ output: float
45
+ cache_read: float
46
+
47
+ @property
48
+ def write_5m(self) -> float:
49
+ return self.input * WRITE_5M
50
+
51
+ @property
52
+ def write_1h(self) -> float:
53
+ return self.input * WRITE_1H
54
+
55
+
56
+ def lookup(model: str, table: dict[str, tuple[float, float, float]] | None = None) -> Price:
57
+ table = table or DEFAULT_PRICES
58
+ model = (model or "").lower()
59
+ best = ""
60
+ for prefix in table:
61
+ if model.startswith(prefix) and len(prefix) > len(best):
62
+ best = prefix
63
+ if not best:
64
+ best = FALLBACK if FALLBACK in table else next(iter(table))
65
+ i, o, r = table[best]
66
+ return Price(best, i, o, r)
@@ -0,0 +1,50 @@
1
+ """What Claude Code sessions cost, from local transcripts (API list prices)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from . import transcript
9
+ from .config import Config
10
+ from .prices import lookup
11
+
12
+
13
+ def rows(cfg: Config, days: int, root: Path | None = None):
14
+ root = root or Path.home() / ".claude" / "projects"
15
+ cutoff = time.time() - days * 86400
16
+ for f in root.glob("*/*.jsonl"):
17
+ try:
18
+ if f.stat().st_mtime < cutoff:
19
+ continue
20
+ except OSError:
21
+ continue
22
+ s = transcript.read(f)
23
+ if not s.turns:
24
+ continue
25
+ peak = max(t.context for t in s.turns)
26
+ cold = s.last.context * lookup(s.last.model, cfg.prices).write_1h / 1e6
27
+ yield {
28
+ "project": f.parent.name, "session": f.stem[:8], "turns": len(s.turns),
29
+ "usd": s.cost(cfg.prices), "peak_k": peak // 1000, "cold_usd": cold,
30
+ }
31
+
32
+
33
+ def render(cfg: Config, days: int = 7, root: Path | None = None, top: int = 15) -> str:
34
+ data = sorted(rows(cfg, days, root), key=lambda r: r["usd"], reverse=True)
35
+ if not data:
36
+ return f"no sessions in the last {days} days"
37
+ total = sum(r["usd"] for r in data)
38
+ over = sum(1 for r in data if r["peak_k"] >= cfg.warn_k)
39
+ out = [
40
+ f"last {days} days: {len(data)} sessions, ${total:.2f} at API list prices, "
41
+ f"{over} sessions peaked over {cfg.warn_k}K context",
42
+ "",
43
+ f"{'usd':>9} {'turns':>6} {'peak(K)':>8} {'cold turn':>10} project / session",
44
+ ]
45
+ for r in data[:top]:
46
+ out.append(
47
+ f"{r['usd']:>9.2f} {r['turns']:>6} {r['peak_k']:>8} {r['cold_usd']:>10.2f} "
48
+ f"{r['project']} / {r['session']}"
49
+ )
50
+ return "\n".join(out)
@@ -0,0 +1,63 @@
1
+ """SessionStart hook (after /clear): load the project's newest unused handoff note."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from .config import Config
10
+
11
+ MAX_BYTES = 12_000
12
+
13
+
14
+ def find_note(cfg: Config, cwd: str, now: float | None = None) -> Path | None:
15
+ notes = cfg.notes
16
+ if not cwd or not notes.is_dir():
17
+ return None
18
+ project = Path(cwd).name
19
+ pattern = re.compile(re.escape(project) + r"-\d{8}-\d{4}\.md$")
20
+ used = _used(cfg)
21
+ now = now or time.time()
22
+ candidates = []
23
+ for p in notes.iterdir():
24
+ if not pattern.fullmatch(p.name) or str(p) in used:
25
+ continue
26
+ try:
27
+ mtime = p.stat().st_mtime
28
+ except OSError:
29
+ continue
30
+ if cfg.max_age_min > 0 and now - mtime > cfg.max_age_min * 60:
31
+ continue
32
+ candidates.append((mtime, p))
33
+ return max(candidates)[1] if candidates else None
34
+
35
+
36
+ def _used(cfg: Config) -> set[str]:
37
+ try:
38
+ return set((cfg.notes / ".used").read_text().split("\n"))
39
+ except OSError:
40
+ return set()
41
+
42
+
43
+ def run(event: dict, cfg: Config) -> dict | None:
44
+ note = find_note(cfg, event.get("cwd") or "")
45
+ if note is None:
46
+ return None
47
+ try:
48
+ body = note.read_text(errors="replace")[:MAX_BYTES]
49
+ with open(cfg.notes / ".used", "a") as f:
50
+ f.write(str(note) + "\n")
51
+ except OSError:
52
+ return None
53
+ return {
54
+ "systemMessage": f"[context-handoff] loaded {note}; type go to continue",
55
+ "hookSpecificOutput": {
56
+ "hookEventName": "SessionStart",
57
+ "additionalContext": (
58
+ f"A handoff note from the previous session was loaded ({note}). On the owner's first "
59
+ 'message, continue from its "Open items and next steps" unless the owner asks for '
60
+ "something else.\n\n" + body
61
+ ),
62
+ },
63
+ }
@@ -0,0 +1,97 @@
1
+ """Read token usage from a Claude Code session transcript (JSON lines)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ from .prices import Price, lookup
10
+
11
+
12
+ @dataclass
13
+ class Turn:
14
+ """Usage of one model reply."""
15
+
16
+ model: str
17
+ input: int = 0
18
+ cache_write_5m: int = 0
19
+ cache_write_1h: int = 0
20
+ cache_read: int = 0
21
+ output: int = 0
22
+
23
+ @property
24
+ def context(self) -> int:
25
+ """Tokens the model read for this reply: what the next turn re-sends."""
26
+ return self.input + self.cache_write_5m + self.cache_write_1h + self.cache_read
27
+
28
+ def cost(self, price: Price) -> float:
29
+ return (
30
+ self.input * price.input
31
+ + self.cache_write_5m * price.write_5m
32
+ + self.cache_write_1h * price.write_1h
33
+ + self.cache_read * price.cache_read
34
+ + self.output * price.output
35
+ ) / 1e6
36
+
37
+
38
+ @dataclass
39
+ class Session:
40
+ turns: list[Turn] = field(default_factory=list)
41
+
42
+ @property
43
+ def last(self) -> Turn | None:
44
+ return self.turns[-1] if self.turns else None
45
+
46
+ def cost(self, table=None) -> float:
47
+ return sum(t.cost(lookup(t.model, table)) for t in self.turns)
48
+
49
+
50
+ def _turn(usage: dict, model: str) -> Turn:
51
+ creation = usage.get("cache_creation") or {}
52
+ w5 = creation.get("ephemeral_5m_input_tokens")
53
+ w1 = creation.get("ephemeral_1h_input_tokens")
54
+ total_write = usage.get("cache_creation_input_tokens") or 0
55
+ if w5 is None and w1 is None:
56
+ # No TTL breakdown: Claude Code writes with the 1-hour TTL, so price it as that.
57
+ w5, w1 = 0, total_write
58
+ return Turn(
59
+ model=model,
60
+ input=usage.get("input_tokens") or 0,
61
+ cache_write_5m=w5 or 0,
62
+ cache_write_1h=w1 or 0,
63
+ cache_read=usage.get("cache_read_input_tokens") or 0,
64
+ output=usage.get("output_tokens") or 0,
65
+ )
66
+
67
+
68
+ def read(path: str | Path) -> Session:
69
+ """Main-thread Claude replies, one per message id, in order.
70
+
71
+ A reply is written as several transcript lines repeating the same usage,
72
+ so only the last line seen for each message id counts.
73
+ """
74
+ replies: dict[str, Turn] = {}
75
+ try:
76
+ f = open(path, encoding="utf-8", errors="replace")
77
+ except OSError:
78
+ return Session()
79
+ with f:
80
+ for line in f:
81
+ if '"usage"' not in line or '"assistant"' not in line:
82
+ continue
83
+ try:
84
+ rec = json.loads(line)
85
+ except ValueError:
86
+ continue
87
+ if rec.get("type") != "assistant" or rec.get("isSidechain"):
88
+ continue
89
+ msg = rec.get("message") or {}
90
+ usage = msg.get("usage")
91
+ model = msg.get("model") or ""
92
+ if not usage or not model.startswith("claude"):
93
+ continue
94
+ key = msg.get("id") or f"line-{len(replies)}"
95
+ replies.pop(key, None) # keep insertion order at the latest position
96
+ replies[key] = _turn(usage, model)
97
+ return Session(list(replies.values()))
@@ -0,0 +1,182 @@
1
+ Metadata-Version: 2.4
2
+ Name: context-handoff
3
+ Version: 0.1.1
4
+ Summary: Keep Claude Code sessions cheap: meter cost, write a handoff note, resume after /clear
5
+ Author: Yauhen Bichel
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/YauhenBichel/context-handoff
8
+ Project-URL: Changelog, https://github.com/YauhenBichel/context-handoff/blob/main/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/YauhenBichel/context-handoff/issues
10
+ Keywords: claude,claude-code,llm,cost,context,handoff
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # context-handoff
23
+
24
+ [![ci](https://github.com/YauhenBichel/context-handoff/actions/workflows/ci.yml/badge.svg)](https://github.com/YauhenBichel/context-handoff/actions/workflows/ci.yml)
25
+ [![release](https://img.shields.io/github/v/release/YauhenBichel/context-handoff)](https://github.com/YauhenBichel/context-handoff/releases)
26
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
27
+
28
+ A Claude Code plugin that keeps long sessions from getting expensive: it shows
29
+ what each turn costs, writes a handoff note when the session grows, and resumes
30
+ from it after `/clear`.
31
+
32
+ ```
33
+ /plugin marketplace add YauhenBichel/context-handoff
34
+ /plugin install context-handoff@context-handoff
35
+ ```
36
+
37
+ Python 3.10+ as `python3`, nothing else. Details in [Install](#install).
38
+
39
+ Every Claude Code turn re-sends the whole conversation. While the prompt cache
40
+ is warm that re-read is cheap; after a pause the whole context is written to
41
+ the cache again at up to 2× the input price. A 400K-token session on Claude
42
+ Opus 5.5 pays about $3.20 for one such cold turn, before Claude writes a word.
43
+ Long sessions also get slower and lose focus.
44
+
45
+ context-handoff watches what each turn costs. When a session gets too big or
46
+ too expensive, Claude writes a short handoff note at the end of the current
47
+ task. You run `/clear`, type `go`, and a fresh, small session continues from
48
+ the note.
49
+
50
+ What you see above a prompt once a session is past the warn limit:
51
+
52
+ ```
53
+ [context-handoff] 182K context, last turn $0.61, session $14.20, a cold turn would cost $1.46
54
+ (claude-opus-5-5 prices). Claude will write a handoff note after this task; then /clear and type go.
55
+ ```
56
+
57
+ ## How it works
58
+
59
+ 1. **Meter** (`UserPromptSubmit` hook). Before each prompt it reads the
60
+ session transcript and computes, at API list prices:
61
+ - the context the next turn will re-send,
62
+ - what the last turn cost,
63
+ - what the whole session cost,
64
+ - what one cold turn would cost now.
65
+ 2. **Auto handoff**. Over a warn limit, it asks Claude (once per session) to
66
+ write a handoff note when the current task is done.
67
+ 3. **Block**. Over a block limit, it holds the prompt until you type `handoff`
68
+ or add `#override`.
69
+ 4. **Resume** (`SessionStart` hook, after `/clear`). It loads the project's
70
+ newest unused note into the new session, so you type `go` instead of
71
+ pasting a prompt. Each note is loaded once.
72
+
73
+ ## Install
74
+
75
+ In Claude Code:
76
+
77
+ ```
78
+ /plugin marketplace add YauhenBichel/context-handoff
79
+ /plugin install context-handoff@context-handoff
80
+ ```
81
+
82
+ Or from a terminal:
83
+
84
+ ```
85
+ claude plugin marketplace add YauhenBichel/context-handoff
86
+ claude plugin install context-handoff@context-handoff
87
+ ```
88
+
89
+ Restart Claude Code (or start a new session) to load the hooks. The meter
90
+ line appears from the second prompt of a session.
91
+
92
+ Requires Python 3.10 or newer on `PATH` as `python3`. No other dependencies.
93
+
94
+ ### Without Claude Code plugins
95
+
96
+ The same code is on PyPI. It gives the `context-handoff` command for reports
97
+ and checks, for example in a shell or on a machine where you don't use the
98
+ plugin:
99
+
100
+ ```
101
+ pipx install context-handoff # or: uv tool install context-handoff
102
+ context-handoff report 14
103
+ ```
104
+
105
+ The hooks still come from the plugin above; the package does not install them.
106
+
107
+ Update or remove:
108
+
109
+ ```
110
+ claude plugin marketplace update context-handoff
111
+ claude plugin update context-handoff@context-handoff
112
+ claude plugin uninstall context-handoff@context-handoff
113
+ ```
114
+
115
+ ## Use
116
+
117
+ - **Normal flow**: keep working. When the meter asks, Claude writes the note
118
+ after the current task and tells you. Run `/clear`, then type `go`.
119
+ - **By hand**: `/context-handoff:handoff`, then `/clear` and `go`.
120
+ - **Past a block**: start the prompt with `handoff`, or add `#override`.
121
+ - **Check a session**: `python3 bin/context-handoff check <transcript.jsonl>`.
122
+ - **Report** of the last N days (default 7):
123
+ `python3 bin/context-handoff report 14`.
124
+
125
+ ## Settings
126
+
127
+ Defaults, then `~/.claude/context-handoff.json`, then environment variables
128
+ (`CONTEXT_HANDOFF_` + the key in upper case). A limit of 0 turns it off.
129
+
130
+ | Key | Default | Meaning |
131
+ |---|---|---|
132
+ | `warn_k` | 150 | context size (thousands of tokens) that triggers a handoff |
133
+ | `block_k` | 400 | context size that blocks prompts |
134
+ | `warn_turn_usd` | 0.50 | cost of the last turn that triggers a handoff |
135
+ | `block_turn_usd` | 3.00 | cost of the last turn that blocks prompts |
136
+ | `warn_session_usd` | 20 | session total that triggers a handoff |
137
+ | `auto_handoff` | true | Claude writes the note itself; false = only a warning |
138
+ | `max_age_min` | 180 | notes older than this are not resumed |
139
+ | `handoff_dir` | `~/.claude/handoffs` | where notes go |
140
+ | `state_dir` | `~/.claude/context-handoff` | log (`meter.jsonl`) and per-session markers |
141
+ | `prices` | see [prices.py](src/context_handoff/prices.py) | `{"model-prefix": [input, output, cache_read]}` per million tokens |
142
+
143
+ Example `~/.claude/context-handoff.json`:
144
+
145
+ ```json
146
+ { "warn_turn_usd": 1.0, "warn_session_usd": 50, "prices": { "claude-opus-5-5": [4.0, 20.0, 0.20] } }
147
+ ```
148
+
149
+ ## Prices
150
+
151
+ The default table holds Anthropic's first-party API list prices as of November
152
+ 2026. Cache writes are priced at 1.25× input (5-minute TTL) or 2× input (1-hour
153
+ TTL). On a Claude subscription you do not pay per token, but the same numbers
154
+ show how fast a session uses your limits. Prices change: override them in the
155
+ config file.
156
+
157
+ ## Limits
158
+
159
+ - A plugin cannot run `/clear` for you. That one step stays manual.
160
+ - Notes are matched by the project folder's name, and only those newer than
161
+ `max_age_min` are loaded.
162
+ - The meter reads the transcript, so the first prompt of a session is not
163
+ metered.
164
+ - Costs are estimates from the transcript's usage fields, not your bill.
165
+
166
+ ## Privacy
167
+
168
+ Everything runs locally. The plugin reads your transcripts in `~/.claude` and
169
+ writes notes and a log there. Nothing is sent anywhere. Do not put secrets in
170
+ handoff notes.
171
+
172
+ ## Development
173
+
174
+ ```
175
+ python3 -m unittest discover -s tests
176
+ ```
177
+
178
+ See [CONTRIBUTING.md](CONTRIBUTING.md).
179
+
180
+ ## License
181
+
182
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,14 @@
1
+ context_handoff/__init__.py,sha256=yNmguB0FR0W_PRnGEwLiCq878C16JtzTwjNOsowM4ro,105
2
+ context_handoff/__main__.py,sha256=HAVx6Bdxknoy6a7MFGr-SRfRa8YtwJsF8B0KJjcahnI,1658
3
+ context_handoff/config.py,sha256=xtz6NkHgMSgXM_tQ6kZn8AIIRdn_n_oBR-mfz46xYNc,3007
4
+ context_handoff/meter.py,sha256=Z4jkPElt5QOR6yL1cXx0c-wRRNrRCzY-3jytfMB2YMM,6360
5
+ context_handoff/prices.py,sha256=dERyLRaYIoejXPWfUi9mB4qK6MOhMby6Rg0QgntXdzk,2122
6
+ context_handoff/report.py,sha256=HsPpYJrPaAcW9Fju5qWfLuVDZrYztZtecmyR1QHPWbc,1773
7
+ context_handoff/resume.py,sha256=iYNU28T0aa2uCUhUqciGSQ5HO9D06hQBpz23jzE9TX8,1920
8
+ context_handoff/transcript.py,sha256=n4xHVHjCSxwAPp0pwVmGMlRXqy1buv-Q9NB3w_AZ4B0,3091
9
+ context_handoff-0.1.1.dist-info/licenses/LICENSE,sha256=b5IGCXKe5CTz8uH4O5cBtOC0aVMSlOgoKEC6DOHyOy4,1070
10
+ context_handoff-0.1.1.dist-info/METADATA,sha256=BeJTLVhQxGFvTHuwokA-kYGMTMldDR0cp_KceXrikUU,6904
11
+ context_handoff-0.1.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
12
+ context_handoff-0.1.1.dist-info/entry_points.txt,sha256=wJyGDDsDpPcM_rNd5DtAz9mi2equgTTxyPTvFGCfdUU,66
13
+ context_handoff-0.1.1.dist-info/top_level.txt,sha256=a7XB-PbU5WJWkEViOFBiIJR4SyEcw6ohWF2sTEtVGzk,16
14
+ context_handoff-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ context-handoff = context_handoff.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yauhen Bichel
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 @@
1
+ context_handoff