@ictechgy/context-guard 0.4.8 → 0.4.10

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.ko.md +92 -37
  3. package/README.md +111 -37
  4. package/docs/benchmark-fixtures/token-savings-12task-baseline.prompt.example.md +7 -0
  5. package/docs/benchmark-fixtures/token-savings-12task-contextguard.prompt.example.md +7 -0
  6. package/docs/benchmark-fixtures/token-savings-12task.tasks.example.json +182 -0
  7. package/docs/benchmark-fixtures/token-savings-12task.variants.example.json +10 -0
  8. package/docs/distribution.md +10 -7
  9. package/docs/experimental-benchmark-fixtures.md +8 -1
  10. package/package.json +3 -6
  11. package/packaging/homebrew/context-guard.rb.template +1 -1
  12. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  13. package/plugins/context-guard/README.ko.md +9 -6
  14. package/plugins/context-guard/README.md +27 -12
  15. package/plugins/context-guard/bin/context-guard +113 -26
  16. package/plugins/context-guard/bin/context-guard-artifact +542 -46
  17. package/plugins/context-guard/bin/context-guard-cache-score +380 -0
  18. package/plugins/context-guard/bin/context-guard-compress +146 -1
  19. package/plugins/context-guard/bin/context-guard-cost +783 -4
  20. package/plugins/context-guard/bin/context-guard-experiments +2211 -121
  21. package/plugins/context-guard/bin/context-guard-failed-nudge +3 -0
  22. package/plugins/context-guard/bin/context-guard-filter +163 -7
  23. package/plugins/context-guard/bin/context-guard-guard-read +3 -0
  24. package/plugins/context-guard/bin/context-guard-pack +602 -43
  25. package/plugins/context-guard/bin/context-guard-rewrite-bash +3 -0
  26. package/plugins/context-guard/bin/context-guard-setup +165 -31
  27. package/plugins/context-guard/bin/context-guard-statusline +490 -283
  28. package/plugins/context-guard/bin/context-guard-statusline-merged +5 -0
  29. package/plugins/context-guard/bin/context-guard-tool-prune +241 -1
  30. package/plugins/context-guard/lib/context_guard_commands.py +206 -0
  31. package/plugins/context-guard/skills/setup/SKILL.md +1 -0
  32. package/context-guard-kit/README.md +0 -91
  33. package/context-guard-kit/benchmark_runner.py +0 -2401
  34. package/context-guard-kit/claude_transcript_cost_audit.py +0 -2346
  35. package/context-guard-kit/context_compress.py +0 -695
  36. package/context-guard-kit/context_escrow.py +0 -935
  37. package/context-guard-kit/context_filter.py +0 -637
  38. package/context-guard-kit/context_guard_cli.py +0 -325
  39. package/context-guard-kit/context_guard_diet.py +0 -1711
  40. package/context-guard-kit/context_pack.py +0 -2713
  41. package/context-guard-kit/cost_guard.py +0 -2349
  42. package/context-guard-kit/experimental_registry.py +0 -2339
  43. package/context-guard-kit/failed_attempt_nudge.py +0 -567
  44. package/context-guard-kit/guard_large_read.py +0 -690
  45. package/context-guard-kit/hook_secret_patterns.py +0 -43
  46. package/context-guard-kit/read_symbol.py +0 -483
  47. package/context-guard-kit/rewrite_bash_for_token_budget.py +0 -501
  48. package/context-guard-kit/sanitize_output.py +0 -725
  49. package/context-guard-kit/settings.example.json +0 -67
  50. package/context-guard-kit/setup_wizard.py +0 -2515
  51. package/context-guard-kit/statusline.sh +0 -362
  52. package/context-guard-kit/statusline_merged.sh +0 -157
  53. package/context-guard-kit/tool_schema_pruner.py +0 -837
  54. package/context-guard-kit/trim_command_output.py +0 -1449
@@ -6,223 +6,161 @@ if [[ -t 0 ]]; then
6
6
  exit 0
7
7
  fi
8
8
 
9
- statusline_input_tmp=''
10
-
11
- statusline_tmp_base() {
12
- local candidate="${TMPDIR:-/tmp}" resolved
13
- if [[ "$candidate" != "/" ]]; then
14
- candidate="${candidate%/}"
15
- fi
16
- if [[ -z "$candidate" || "$candidate" != /* || ! -d "$candidate" || ! -w "$candidate" ]]; then
17
- candidate="/tmp"
18
- fi
19
- if resolved=$(cd "$candidate" 2>/dev/null && pwd -P); then
20
- if [[ "$resolved" != "/" ]]; then
21
- resolved="${resolved%/}"
22
- fi
23
- printf '%s\n' "${resolved:-/}"
24
- else
25
- printf '/tmp\n'
26
- fi
27
- }
28
-
29
- statusline_input_max_bytes() {
30
- local raw="${CONTEXT_GUARD_STATUSLINE_INPUT_MAX_BYTES:-${CLAUDE_TOKEN_STATUSLINE_INPUT_MAX_BYTES:-65536}}" max=65536
31
- if [[ "$raw" =~ ^[0-9]+$ ]] && (( ${#raw} <= 7 )); then
32
- max=$((10#$raw))
33
- fi
34
- if (( max < 1 || max > 1048576 )); then
35
- max=65536
36
- fi
37
- printf '%s\n' "$max"
38
- }
39
-
40
- statusline_context_warn_threshold() {
41
- local raw="${CONTEXT_GUARD_STATUSLINE_CTX_WARN:-${CLAUDE_TOKEN_STATUSLINE_CTX_WARN:-80}}" threshold=80
42
- if [[ "$raw" =~ ^[0-9]{1,3}$ ]]; then
43
- threshold=$((10#$raw))
44
- if (( threshold < 1 )); then
45
- threshold=1
46
- elif (( threshold > 100 )); then
47
- threshold=100
48
- fi
49
- fi
50
- printf '%s\n' "$threshold"
51
- }
52
-
53
- read_bounded_statusline_input() {
54
- local max input_len tmp_base
55
- max=$(statusline_input_max_bytes)
56
- tmp_base=$(statusline_tmp_base)
57
- statusline_input_tmp=$(mktemp "$tmp_base/context-guard-statusline.XXXXXX") || {
58
- printf '[input-error] could not create statusline input buffer\n'
59
- exit 0
60
- }
61
- trap 'rm -f "${statusline_input_tmp:-}"' EXIT
62
- LC_ALL=C head -c "$((max + 1))" >"$statusline_input_tmp" 2>/dev/null || true
63
- input_len=$(LC_ALL=C wc -c <"$statusline_input_tmp" | tr -d '[:space:]')
64
- if (( input_len > max )); then
65
- printf '[input-too-large] Claude statusline JSON exceeds %s bytes\n' "$max"
66
- exit 0
67
- fi
68
- input=$(cat "$statusline_input_tmp" 2>/dev/null || true)
69
- rm -f "$statusline_input_tmp"
70
- statusline_input_tmp=''
71
- trap - EXIT
72
- }
73
-
74
- read_bounded_statusline_input
75
-
76
- if ! command -v jq >/dev/null 2>&1; then
77
- echo "[needs-jq] install jq for Claude token statusline"
9
+ if ! command -v python3 >/dev/null 2>&1; then
10
+ echo "[needs-python3] install python3 for Claude token statusline"
78
11
  exit 0
79
12
  fi
80
13
 
81
- jq_get() {
82
- jq -r "$1 // empty" <<<"$input" 2>/dev/null || true
83
- }
84
-
85
- strip_terminal_sequences() {
86
- if command -v perl >/dev/null 2>&1; then
87
- perl -pe 's/\e\][^\a\e]*(?:\a|\e\\)//g; s/\e[@-_][0-?]*[ -\/]*[@-~]//g'
88
- else
89
- cat
90
- fi
91
- }
92
-
93
- sanitize_status() {
94
- # Statusline values may come from untrusted workspace metadata; keep one-line printable text.
95
- local cleaned
96
- cleaned=$(printf '%s' "$1" \
97
- | strip_terminal_sequences \
98
- | LC_ALL=C tr '\r\n' ' ' \
99
- | LC_ALL=C tr -d '\000-\010\013\014\016-\037\177-\237' \
100
- | cut -c 1-160)
101
- if printf '%s' "$cleaned" | LC_ALL=C grep -Eiq '(gh[pousr]_|github_pat_|glpat-|xox[abprs]-|AKIA|ASIA|sk-|npm_|AIza|Bearer[[:space:]]|Basic[[:space:]])'; then
102
- printf '[redacted]'
103
- else
104
- printf '%s' "$cleaned"
105
- fi
106
- }
107
-
108
- git_head_branch() {
109
- # Keep the statusline cheap and non-blocking: do not invoke `git` here. Some
110
- # workspaces have slow network filesystems, hydrated-on-demand git objects, or
111
- # broken config; reading .git/HEAD is enough for a best-effort branch label.
112
- local current="$1"
113
- local dotgit gitdir_line gitdir head_file head_line branch
114
- [[ -n "$current" && -d "$current" ]] || return 1
115
- current=$(cd "$current" 2>/dev/null && pwd -P) || return 1
116
-
117
- while [[ -n "$current" ]]; do
118
- head_file=''
119
- dotgit="$current/.git"
120
- if [[ -d "$dotgit" && ! -L "$dotgit" ]]; then
121
- head_file="$dotgit/HEAD"
122
- elif [[ -f "$dotgit" && ! -L "$dotgit" ]]; then
123
- IFS= read -r gitdir_line <"$dotgit" 2>/dev/null || gitdir_line=''
124
- if [[ "$gitdir_line" == gitdir:\ * ]]; then
125
- gitdir="${gitdir_line#gitdir: }"
126
- [[ "$gitdir" == /* ]] || gitdir="$current/$gitdir"
127
- if gitdir=$(cd "$gitdir" 2>/dev/null && pwd -P) && [[ -f "$gitdir/HEAD" && ! -L "$gitdir/HEAD" ]]; then
128
- head_file="$gitdir/HEAD"
129
- fi
130
- fi
131
- fi
132
-
133
- if [[ -n "$head_file" && -f "$head_file" && ! -L "$head_file" ]]; then
134
- IFS= read -r head_line <"$head_file" 2>/dev/null || return 1
135
- if [[ "$head_line" == ref:\ refs/heads/* ]]; then
136
- branch="${head_line#ref: refs/heads/}"
137
- [[ -n "$branch" ]] && printf '%s\n' "$branch"
138
- return 0
139
- fi
140
- if [[ "$head_line" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
141
- printf '%s\n' "${head_line:0:12}"
142
- return 0
143
- fi
144
- return 1
145
- fi
146
-
147
- [[ "$current" == "/" ]] && break
148
- current="${current%/*}"
149
- [[ -n "$current" ]] || current="/"
150
- done
151
- return 1
152
- }
153
-
154
- model=$(jq_get '.model.display_name')
155
- model=${model:-$(jq_get '.model.id')}
156
- model=${model:-unknown}
157
- model=$(sanitize_status "$model")
158
-
159
- context_raw=$(jq_get '.context_window.used_percentage')
160
- context_is_numeric=0
161
- if [[ -n "$context_raw" ]]; then
162
- if context_pct=$(LC_NUMERIC=C printf '%.0f' "$context_raw" 2>/dev/null); then
163
- if [[ "$context_pct" =~ ^-?[0-9]+$ ]]; then
164
- context_is_numeric=1
165
- else
166
- context_pct=$(sanitize_status "$context_raw")
167
- fi
168
- else
169
- context_pct=$(sanitize_status "$context_raw")
170
- fi
171
- else
172
- context_pct="?"
173
- fi
174
- context_label="${context_pct}%"
175
- if (( context_is_numeric )); then
176
- context_warn_threshold=$(statusline_context_warn_threshold)
177
- if (( context_pct >= context_warn_threshold )); then
178
- context_label="${context_label} ⚠"
179
- fi
180
- fi
181
-
182
- cost=$(jq_get '.cost.total_cost_usd')
183
- if [[ -n "$cost" ]]; then
184
- cost=$(printf '$%.3f' "$cost" 2>/dev/null || sanitize_status "$cost")
185
- else
186
- cost='n/a'
187
- fi
188
-
189
- cwd=$(jq_get '.workspace.current_dir')
190
- dir=${cwd##*/}
191
- dir=${dir:-.}
192
- dir=$(sanitize_status "$dir")
193
-
194
- branch=''
195
- branch_dir=${cwd:-$PWD}
196
- b=$(git_head_branch "$branch_dir" 2>/dev/null || true)
197
- if [[ -n "$b" ]]; then
198
- b=$(sanitize_status "$b")
199
- branch=" | ${b}"
200
- fi
14
+ read -r -d '' CONTEXT_GUARD_STATUSLINE_PY <<'PYEOF' || true
15
+ from __future__ import annotations
201
16
 
202
- # Cache metrics from the transcript tail (best-effort, fast — reads only the last 1MB).
203
- # Stays empty when transcript is unavailable or python3 fails so the status line never breaks.
204
- # NOTE: keep the token-key list and usage-extraction shape in sync with claude_transcript_cost_audit.py
205
- # so the statusline metric matches the audit metric for the same transcript.
206
- metrics_label=''
207
- transcript_path=$(jq_get '.transcript_path')
208
- if [[ -n "$transcript_path" && -r "$transcript_path" ]] && command -v python3 >/dev/null 2>&1; then
209
- transcript_metrics=$(python3 - "$transcript_path" 2>/dev/null <<'PYEOF' || true
17
+ import hashlib
210
18
  import json
19
+ import math
211
20
  import os
21
+ import re
212
22
  import stat
213
23
  import sys
24
+ import time
25
+ from typing import Any
214
26
 
215
- path = sys.argv[1] if len(sys.argv) > 1 else ""
216
- if not path:
217
- sys.exit(0)
218
-
219
- # Bounded tail read so the statusline never stalls on huge transcripts.
220
27
  TAIL_BYTES = 1024 * 1024
221
28
  MAX_RECORDS = 300
29
+ CACHE_SCHEMA_VERSION = 1
30
+ DEFAULT_CACHE_TTL_SECONDS = 2.0
31
+ MAX_CACHE_TTL_SECONDS = 30.0
32
+ MAX_CACHE_BYTES = 4096
33
+ METRIC_RE = re.compile(r"^\d+(?:\.\d)?$")
34
+ SECRET_RE = re.compile(
35
+ r"(gh[pousr]_|github_pat_|glpat-|xox[abprs]-|AKIA|ASIA|sk-|npm_|AIza|Bearer\s|Basic\s)",
36
+ re.IGNORECASE,
37
+ )
38
+ OSC_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
39
+ CSI_RE = re.compile(r"\x1b[@-_][0-?]*[ -/]*[@-~]")
40
+ CONTROL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
41
+
42
+
43
+ def _bounded_int_env(primary: str, legacy: str, default: int, *, lower: int, upper: int) -> int:
44
+ raw = os.environ.get(primary, os.environ.get(legacy, str(default)))
45
+ value = default
46
+ if raw.isdigit() and len(raw) <= 7:
47
+ value = int(raw, 10)
48
+ if value < lower or value > upper:
49
+ return default
50
+ return value
51
+
52
+
53
+ def statusline_input_max_bytes() -> int:
54
+ return _bounded_int_env(
55
+ "CONTEXT_GUARD_STATUSLINE_INPUT_MAX_BYTES",
56
+ "CLAUDE_TOKEN_STATUSLINE_INPUT_MAX_BYTES",
57
+ 65536,
58
+ lower=1,
59
+ upper=1048576,
60
+ )
61
+
62
+
63
+ def statusline_context_warn_threshold() -> int:
64
+ raw = os.environ.get(
65
+ "CONTEXT_GUARD_STATUSLINE_CTX_WARN",
66
+ os.environ.get("CLAUDE_TOKEN_STATUSLINE_CTX_WARN", "80"),
67
+ )
68
+ threshold = 80
69
+ if re.fullmatch(r"\d{1,3}", raw or ""):
70
+ threshold = int(raw, 10)
71
+ if threshold < 1:
72
+ threshold = 1
73
+ elif threshold > 100:
74
+ threshold = 100
75
+ return threshold
76
+
77
+
78
+ def _json_tostring(value: Any) -> str:
79
+ if value is None:
80
+ return ""
81
+ if isinstance(value, bool):
82
+ return "true" if value else "false"
83
+ if isinstance(value, (dict, list)):
84
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
85
+ return str(value)
86
+
87
+
88
+ def _get_path(data: dict[str, Any], *keys: str) -> str:
89
+ cur: Any = data
90
+ for key in keys:
91
+ if not isinstance(cur, dict):
92
+ return ""
93
+ cur = cur.get(key)
94
+ return _json_tostring(cur)
95
+
96
+
97
+ def strip_terminal_sequences(value: str) -> str:
98
+ value = OSC_RE.sub("", value)
99
+ return CSI_RE.sub("", value)
100
+
222
101
 
102
+ def sanitize_status(value: str) -> str:
103
+ cleaned = strip_terminal_sequences(str(value))
104
+ cleaned = cleaned.replace("\r", " ").replace("\n", " ")
105
+ cleaned = CONTROL_RE.sub("", cleaned)[:160]
106
+ if SECRET_RE.search(cleaned):
107
+ return "[redacted]"
108
+ return cleaned
223
109
 
224
- def _int_or_zero(value):
225
- """transcript usage 토큰값을 정수로 강제. bool은 int 서브클래스이므로 별도 차단."""
110
+
111
+ def git_head_branch(current: str) -> str | None:
112
+ if not current or not os.path.isdir(current):
113
+ return None
114
+ try:
115
+ current = os.path.realpath(current)
116
+ except Exception:
117
+ return None
118
+
119
+ while current:
120
+ head_file = ""
121
+ dotgit = os.path.join(current, ".git")
122
+ if os.path.isdir(dotgit) and not os.path.islink(dotgit):
123
+ head_file = os.path.join(dotgit, "HEAD")
124
+ elif os.path.isfile(dotgit) and not os.path.islink(dotgit):
125
+ try:
126
+ with open(dotgit, "r", encoding="utf-8", errors="replace") as fh:
127
+ gitdir_line = fh.readline().rstrip("\n")
128
+ except OSError:
129
+ gitdir_line = ""
130
+ if gitdir_line.startswith("gitdir: "):
131
+ gitdir = gitdir_line[len("gitdir: ") :]
132
+ if not os.path.isabs(gitdir):
133
+ gitdir = os.path.join(current, gitdir)
134
+ try:
135
+ gitdir = os.path.realpath(gitdir)
136
+ except Exception:
137
+ gitdir = ""
138
+ candidate = os.path.join(gitdir, "HEAD") if gitdir else ""
139
+ if candidate and os.path.isfile(candidate) and not os.path.islink(candidate):
140
+ head_file = candidate
141
+
142
+ if head_file and os.path.isfile(head_file) and not os.path.islink(head_file):
143
+ try:
144
+ with open(head_file, "r", encoding="utf-8", errors="replace") as fh:
145
+ head_line = fh.readline().strip()
146
+ except OSError:
147
+ return None
148
+ if head_line.startswith("ref: refs/heads/"):
149
+ branch = head_line[len("ref: refs/heads/") :]
150
+ return branch or None
151
+ if re.fullmatch(r"[0-9a-fA-F]{7,40}", head_line or ""):
152
+ return head_line[:12]
153
+ return None
154
+
155
+ parent = os.path.dirname(current)
156
+ if parent == current:
157
+ break
158
+ current = parent or os.sep
159
+ return None
160
+
161
+
162
+ def _int_or_zero(value: Any) -> int:
163
+ """Coerce transcript usage token values. bool is an int subclass, so block it."""
226
164
  if isinstance(value, bool):
227
165
  return 0
228
166
  if isinstance(value, int):
@@ -230,18 +168,12 @@ def _int_or_zero(value):
230
168
  return 0
231
169
 
232
170
 
233
- def _extract_usage(record):
234
- """transcript record에서 알려진 usage 객체 1개만 꺼낸다.
235
-
236
- Claude Code transcript JSONL은 record 당 한 번의 LLM 호출 usage를 다음 중 한 자리에
237
- 넣는 것이 일반적이다 — top-level "usage", "message.usage", "response.usage".
238
- 재귀 walk 대신 알려진 경로만 보아야 동일 값이 여러 nested 사본으로 들어왔을 때
239
- 이중 합산되는 문제를 피할 수 있다.
240
- """
171
+ def _extract_usage(record: Any) -> dict[str, Any] | None:
172
+ """Extract one known transcript usage object without recursively double-counting copies."""
241
173
  if not isinstance(record, dict):
242
174
  return None
243
175
  for path_keys in (("usage",), ("message", "usage"), ("response", "usage")):
244
- cur = record
176
+ cur: Any = record
245
177
  for key in path_keys:
246
178
  if not isinstance(cur, dict):
247
179
  cur = None
@@ -252,9 +184,7 @@ def _extract_usage(record):
252
184
  return None
253
185
 
254
186
 
255
- input_tokens = cache_read = cache_creation = 0
256
-
257
- def _open_regular_transcript(path):
187
+ def _open_regular_transcript(path: str) -> tuple[int, os.stat_result] | None:
258
188
  flags = os.O_RDONLY
259
189
  if hasattr(os, "O_CLOEXEC"):
260
190
  flags |= os.O_CLOEXEC
@@ -262,26 +192,32 @@ def _open_regular_transcript(path):
262
192
  flags |= os.O_NOFOLLOW
263
193
  if hasattr(os, "O_NONBLOCK"):
264
194
  flags |= os.O_NONBLOCK
265
- st = os.lstat(path)
195
+ try:
196
+ st = os.lstat(path)
197
+ except OSError:
198
+ return None
266
199
  if not stat.S_ISREG(st.st_mode):
267
200
  return None
268
- fd = os.open(path, flags)
201
+ try:
202
+ fd = os.open(path, flags)
203
+ except OSError:
204
+ return None
269
205
  try:
270
206
  opened = os.fstat(fd)
271
207
  if not stat.S_ISREG(opened.st_mode):
272
208
  os.close(fd)
273
209
  return None
274
- return fd, opened.st_size
210
+ return fd, opened
275
211
  except Exception:
276
212
  os.close(fd)
277
213
  raise
278
214
 
279
215
 
280
- def _read_tail(fd, size):
216
+ def _read_tail(fd: int, size: int) -> tuple[bytes, int]:
281
217
  read_size = min(size, TAIL_BYTES)
282
218
  if size > read_size:
283
219
  os.lseek(fd, size - read_size, os.SEEK_SET)
284
- chunks = []
220
+ chunks: list[bytes] = []
285
221
  remaining = read_size
286
222
  while remaining > 0:
287
223
  chunk = os.read(fd, remaining)
@@ -292,71 +228,342 @@ def _read_tail(fd, size):
292
228
  return b"".join(chunks), read_size
293
229
 
294
230
 
295
- try:
296
- opened = _open_regular_transcript(path)
297
- if opened is None:
298
- sys.exit(0)
299
- fd, size = opened
231
+ def _cache_ttl_seconds() -> float:
232
+ raw = os.environ.get("CONTEXT_GUARD_STATUSLINE_CACHE_TTL_SECONDS", "")
233
+ if raw == "":
234
+ return DEFAULT_CACHE_TTL_SECONDS
235
+ try:
236
+ ttl = float(raw)
237
+ except (TypeError, ValueError, OverflowError):
238
+ return DEFAULT_CACHE_TTL_SECONDS
239
+ if ttl <= 0:
240
+ return 0.0
241
+ return min(ttl, MAX_CACHE_TTL_SECONDS)
242
+
243
+
244
+ def _path_contains(parent: str, child: str) -> bool:
245
+ try:
246
+ parent_real = os.path.realpath(parent)
247
+ child_real = os.path.realpath(child)
248
+ return os.path.commonpath([parent_real, child_real]) == parent_real
249
+ except Exception:
250
+ return False
251
+
252
+
253
+ def _private_cache_dir(workspace: str) -> str | None:
254
+ home = os.path.expanduser("~")
255
+ if not home or not os.path.isabs(home):
256
+ return None
257
+ root = os.path.join(home, ".cache", "context-guard", "statusline")
258
+ if workspace and os.path.isabs(workspace) and os.path.isdir(workspace) and _path_contains(workspace, root):
259
+ return None
260
+ try:
261
+ os.makedirs(root, mode=0o700, exist_ok=True)
262
+ st = os.lstat(root)
263
+ if not stat.S_ISDIR(st.st_mode) or stat.S_ISLNK(st.st_mode):
264
+ return None
265
+ if hasattr(os, "getuid") and st.st_uid != os.getuid():
266
+ return None
267
+ if stat.S_IMODE(st.st_mode) != 0o700:
268
+ os.chmod(root, 0o700)
269
+ st = os.lstat(root)
270
+ if stat.S_IMODE(st.st_mode) != 0o700:
271
+ return None
272
+ return root
273
+ except Exception:
274
+ return None
275
+
276
+
277
+ def _identity(path: str, st: os.stat_result) -> dict[str, int | str]:
278
+ absolute = os.path.abspath(path)
279
+ path_hash = hashlib.sha256(os.fsencode(absolute)).hexdigest()
280
+ return {
281
+ "path_hash": path_hash,
282
+ "size": int(st.st_size),
283
+ "mtime_ns": int(getattr(st, "st_mtime_ns", int(st.st_mtime * 1_000_000_000))),
284
+ "dev": int(getattr(st, "st_dev", 0)),
285
+ "ino": int(getattr(st, "st_ino", 0)),
286
+ }
287
+
288
+
289
+ def _cache_path(identity: dict[str, int | str], workspace_dir: str) -> str | None:
290
+ root = _private_cache_dir(workspace_dir)
291
+ if not root:
292
+ return None
293
+ return os.path.join(root, f"{identity['path_hash']}.json")
294
+
295
+
296
+ def _open_no_follow_read(path: str) -> tuple[int, int] | None:
297
+ flags = os.O_RDONLY
298
+ if hasattr(os, "O_CLOEXEC"):
299
+ flags |= os.O_CLOEXEC
300
+ if hasattr(os, "O_NOFOLLOW"):
301
+ flags |= os.O_NOFOLLOW
300
302
  try:
301
- chunk, read_size = _read_tail(fd, size)
302
- finally:
303
+ fd = os.open(path, flags)
304
+ except OSError:
305
+ return None
306
+ try:
307
+ st = os.fstat(fd)
308
+ if not stat.S_ISREG(st.st_mode) or st.st_size > MAX_CACHE_BYTES:
309
+ os.close(fd)
310
+ return None
311
+ return fd, int(st.st_size)
312
+ except Exception:
303
313
  os.close(fd)
304
- lines = chunk.splitlines()
305
- if size > read_size and lines:
306
- # First line in the tail window is likely partial; drop it.
307
- lines = lines[1:]
308
- for raw in lines[-MAX_RECORDS:]:
309
- if not raw.strip():
310
- continue
314
+ raise
315
+
316
+
317
+ def _validated_metric(value: Any, *, minimum: float, maximum: float) -> str | None:
318
+ if not isinstance(value, str) or not METRIC_RE.match(value):
319
+ return None
320
+ try:
321
+ number = float(value)
322
+ except (TypeError, ValueError, OverflowError):
323
+ return None
324
+ if not math.isfinite(number) or number < minimum or number > maximum:
325
+ return None
326
+ return value
327
+
328
+
329
+ def _metric_parts(cache_pct: Any, reuse_x: Any) -> str | None:
330
+ cache_pct = _validated_metric(cache_pct, minimum=0.0, maximum=100.0)
331
+ if cache_pct is None:
332
+ return None
333
+ if reuse_x is not None:
334
+ reuse_x = _validated_metric(reuse_x, minimum=0.0, maximum=1_000_000.0)
335
+ if reuse_x is None:
336
+ return None
337
+ parts = [f"cache_pct={cache_pct}"]
338
+ if reuse_x:
339
+ parts.append(f"reuse_x={reuse_x}")
340
+ return " ".join(parts)
341
+
342
+
343
+ def _read_cache(identity: dict[str, int | str], workspace_dir: str, ttl: float) -> str | None:
344
+ if ttl <= 0:
345
+ return None
346
+ path = _cache_path(identity, workspace_dir)
347
+ if not path:
348
+ return None
349
+ try:
350
+ opened = _open_no_follow_read(path)
351
+ if opened is None:
352
+ return None
353
+ fd, size = opened
311
354
  try:
312
- obj = json.loads(raw)
355
+ raw = os.read(fd, size + 1)
356
+ finally:
357
+ os.close(fd)
358
+ data = json.loads(raw.decode("utf-8", errors="strict"))
359
+ if not isinstance(data, dict):
360
+ return None
361
+ if data.get("schema_version") != CACHE_SCHEMA_VERSION:
362
+ return None
363
+ computed_at = float(data.get("computed_at", 0))
364
+ now = time.time()
365
+ if not math.isfinite(computed_at):
366
+ return None
367
+ if now - computed_at > ttl or computed_at - now > ttl:
368
+ return None
369
+ for key, value in identity.items():
370
+ if data.get(key) != value:
371
+ return None
372
+ return _metric_parts(data.get("cache_pct"), data.get("reuse_x"))
373
+ except Exception:
374
+ return None
375
+
376
+
377
+ def _write_cache(identity: dict[str, int | str], workspace_dir: str, cache_pct: str, reuse_x: str | None) -> None:
378
+ ttl = _cache_ttl_seconds()
379
+ if ttl <= 0:
380
+ return
381
+ path = _cache_path(identity, workspace_dir)
382
+ if not path:
383
+ return
384
+ payload = {
385
+ "schema_version": CACHE_SCHEMA_VERSION,
386
+ **identity,
387
+ "computed_at": time.time(),
388
+ "cache_pct": cache_pct,
389
+ "reuse_x": reuse_x,
390
+ }
391
+ raw = (json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
392
+ if len(raw) > MAX_CACHE_BYTES:
393
+ return
394
+ tmp_path = f"{path}.{os.getpid()}.tmp"
395
+ fd = -1
396
+ try:
397
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
398
+ if hasattr(os, "O_CLOEXEC"):
399
+ flags |= os.O_CLOEXEC
400
+ if hasattr(os, "O_NOFOLLOW"):
401
+ flags |= os.O_NOFOLLOW
402
+ fd = os.open(tmp_path, flags, 0o600)
403
+ os.write(fd, raw)
404
+ os.close(fd)
405
+ fd = -1
406
+ os.replace(tmp_path, path)
407
+ os.chmod(path, 0o600)
408
+ except Exception:
409
+ if fd >= 0:
410
+ try:
411
+ os.close(fd)
412
+ except Exception:
413
+ pass
414
+ try:
415
+ os.unlink(tmp_path)
313
416
  except Exception:
314
- continue
315
- usage = _extract_usage(obj)
316
- if not usage:
317
- continue
318
- input_tokens += _int_or_zero(usage.get("input_tokens"))
319
- cr = usage.get("cache_read_input_tokens")
320
- if cr is None:
321
- cr = usage.get("cacheRead")
322
- cache_read += _int_or_zero(cr)
323
- cc = usage.get("cache_creation_input_tokens")
324
- if cc is None:
325
- cc = usage.get("cacheCreation")
326
- cache_creation += _int_or_zero(cc)
327
- denom = input_tokens + cache_read + cache_creation
328
- # Skip the label entirely on empty / cache-cold sessions so the user does not see a
329
- # confusing "cache 0%" before the cache has had a chance to warm up.
330
- if denom <= 0 or cache_read <= 0:
331
- sys.exit(0)
332
- pct = max(0.0, min(100.0, cache_read / denom * 100))
333
- parts = [f"cache_pct={pct:.0f}"]
334
- if cache_creation > 0:
335
- parts.append(f"reuse_x={cache_read / cache_creation:.1f}")
336
- print(" ".join(parts))
337
- except Exception:
338
- sys.exit(0)
417
+ pass
418
+
419
+
420
+ def transcript_metrics(path: str, workspace_dir: str) -> str | None:
421
+ input_tokens = 0
422
+ cache_read = 0
423
+ cache_creation = 0
424
+ try:
425
+ opened = _open_regular_transcript(path)
426
+ if opened is None:
427
+ return None
428
+ fd, st = opened
429
+ size = int(st.st_size)
430
+ identity = _identity(path, st)
431
+ cached = _read_cache(identity, workspace_dir, _cache_ttl_seconds())
432
+ if cached:
433
+ os.close(fd)
434
+ return cached
435
+ try:
436
+ chunk, read_size = _read_tail(fd, size)
437
+ finally:
438
+ os.close(fd)
439
+ lines = chunk.splitlines()
440
+ if size > read_size and lines:
441
+ lines = lines[1:]
442
+ for raw in lines[-MAX_RECORDS:]:
443
+ if not raw.strip():
444
+ continue
445
+ try:
446
+ obj = json.loads(raw)
447
+ except Exception:
448
+ continue
449
+ usage = _extract_usage(obj)
450
+ if not usage:
451
+ continue
452
+ input_tokens += _int_or_zero(usage.get("input_tokens"))
453
+ cr = usage.get("cache_read_input_tokens")
454
+ if cr is None:
455
+ cr = usage.get("cacheRead")
456
+ cache_read += _int_or_zero(cr)
457
+ cc = usage.get("cache_creation_input_tokens")
458
+ if cc is None:
459
+ cc = usage.get("cacheCreation")
460
+ cache_creation += _int_or_zero(cc)
461
+ denom = input_tokens + cache_read + cache_creation
462
+ if denom <= 0 or cache_read <= 0:
463
+ return None
464
+ pct = max(0.0, min(100.0, cache_read / denom * 100))
465
+ cache_pct = f"{pct:.0f}"
466
+ reuse_x = f"{cache_read / cache_creation:.1f}" if cache_creation > 0 else None
467
+ _write_cache(identity, workspace_dir, cache_pct, reuse_x)
468
+ parts = [f"cache_pct={cache_pct}"]
469
+ if reuse_x:
470
+ parts.append(f"reuse_x={reuse_x}")
471
+ return " ".join(parts)
472
+ except Exception:
473
+ return None
474
+
475
+
476
+ def _load_payload(raw: bytes) -> dict[str, Any]:
477
+ try:
478
+ data = json.loads(raw.decode("utf-8", errors="strict"))
479
+ except Exception:
480
+ return {}
481
+ return data if isinstance(data, dict) else {}
482
+
483
+
484
+ def _rounded_context(raw: str) -> tuple[str, bool]:
485
+ if not raw:
486
+ return "?", False
487
+ try:
488
+ number = float(raw)
489
+ except (TypeError, ValueError, OverflowError):
490
+ return sanitize_status(raw), False
491
+ if not math.isfinite(number):
492
+ return sanitize_status(raw), False
493
+ rendered = f"{number:.0f}"
494
+ if re.fullmatch(r"-?\d+", rendered):
495
+ return rendered, True
496
+ return sanitize_status(raw), False
497
+
498
+
499
+ def render_statusline(payload: dict[str, Any]) -> str:
500
+ model_display = _get_path(payload, "model", "display_name")
501
+ model_id = _get_path(payload, "model", "id")
502
+ context_raw = _get_path(payload, "context_window", "used_percentage")
503
+ cost_raw = _get_path(payload, "cost", "total_cost_usd")
504
+ cwd = _get_path(payload, "workspace", "current_dir")
505
+ transcript_path = _get_path(payload, "transcript_path")
506
+
507
+ model = sanitize_status(model_display or model_id or "unknown")
508
+
509
+ context_pct, context_is_numeric = _rounded_context(context_raw)
510
+ context_label = f"{context_pct}%"
511
+ if context_is_numeric and int(context_pct) >= statusline_context_warn_threshold():
512
+ context_label = f"{context_label} ⚠"
513
+
514
+ if cost_raw:
515
+ try:
516
+ cost_number = float(cost_raw)
517
+ if not math.isfinite(cost_number):
518
+ raise ValueError("non-finite cost")
519
+ cost = f"${cost_number:.3f}"
520
+ except (TypeError, ValueError, OverflowError):
521
+ cost = sanitize_status(cost_raw)
522
+ else:
523
+ cost = "n/a"
524
+
525
+ dir_label = os.path.basename(cwd) if cwd else "."
526
+ dir_label = sanitize_status(dir_label or ".")
527
+
528
+ branch_label = ""
529
+ branch_dir = cwd or os.getcwd()
530
+ branch = git_head_branch(branch_dir)
531
+ if branch:
532
+ branch_label = f" | {sanitize_status(branch)}"
533
+
534
+ metrics_label = ""
535
+ if transcript_path and os.access(transcript_path, os.R_OK):
536
+ raw_metrics = transcript_metrics(transcript_path, cwd)
537
+ if raw_metrics:
538
+ cache_pct = ""
539
+ reuse_x = ""
540
+ for metric in raw_metrics.split():
541
+ if metric.startswith("cache_pct="):
542
+ cache_pct = metric[len("cache_pct=") :]
543
+ elif metric.startswith("reuse_x="):
544
+ reuse_x = metric[len("reuse_x=") :]
545
+ if cache_pct:
546
+ metrics_label = f" | cache {sanitize_status(cache_pct)}%"
547
+ if reuse_x:
548
+ metrics_label += f" | reuse {sanitize_status(reuse_x)}x"
549
+
550
+ return f"[{model}] {dir_label}{branch_label} | ctx {context_label} | cost {cost}{metrics_label}"
551
+
552
+
553
+ def main() -> int:
554
+ max_bytes = statusline_input_max_bytes()
555
+ raw = sys.stdin.buffer.read(max_bytes + 1)
556
+ if len(raw) > max_bytes:
557
+ print(f"[input-too-large] Claude statusline JSON exceeds {max_bytes} bytes")
558
+ return 0
559
+ print(render_statusline(_load_payload(raw)))
560
+ return 0
561
+
562
+
563
+ try:
564
+ raise SystemExit(main())
565
+ except BrokenPipeError:
566
+ raise SystemExit(0)
339
567
  PYEOF
340
- )
341
- if [[ -n "$transcript_metrics" ]]; then
342
- cache_pct=''
343
- reuse_x=''
344
- for metric in $transcript_metrics; do
345
- case "$metric" in
346
- cache_pct=*) cache_pct="${metric#cache_pct=}" ;;
347
- reuse_x=*) reuse_x="${metric#reuse_x=}" ;;
348
- esac
349
- done
350
- if [[ -n "$cache_pct" ]]; then
351
- cache_pct=$(sanitize_status "$cache_pct")
352
- metrics_label=" | cache ${cache_pct}%"
353
- if [[ -n "$reuse_x" ]]; then
354
- reuse_x=$(sanitize_status "$reuse_x")
355
- metrics_label+=" | reuse ${reuse_x}x"
356
- fi
357
- fi
358
- fi
359
- fi
360
568
 
361
- # Keep it one line and cheap: this script runs locally and should not do expensive git status.
362
- echo "[$model] ${dir}${branch} | ctx ${context_label} | cost ${cost}${metrics_label}"
569
+ exec python3 -c "$CONTEXT_GUARD_STATUSLINE_PY" "$@"