@ictechgy/context-guard 0.4.9 → 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.
- package/CHANGELOG.md +16 -0
- package/README.ko.md +41 -24
- package/README.md +66 -26
- package/docs/benchmark-fixtures/token-savings-12task-baseline.prompt.example.md +7 -0
- package/docs/benchmark-fixtures/token-savings-12task-contextguard.prompt.example.md +7 -0
- package/docs/benchmark-fixtures/token-savings-12task.tasks.example.json +182 -0
- package/docs/benchmark-fixtures/token-savings-12task.variants.example.json +10 -0
- package/docs/distribution.md +10 -7
- package/docs/experimental-benchmark-fixtures.md +8 -1
- package/package.json +3 -6
- package/packaging/homebrew/context-guard.rb.template +1 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +9 -6
- package/plugins/context-guard/README.md +21 -13
- package/plugins/context-guard/bin/context-guard +113 -26
- package/plugins/context-guard/bin/context-guard-artifact +542 -46
- package/plugins/context-guard/bin/context-guard-cache-score +380 -0
- package/plugins/context-guard/bin/context-guard-compress +146 -1
- package/plugins/context-guard/bin/context-guard-cost +783 -4
- package/plugins/context-guard/bin/context-guard-experiments +99 -18
- package/plugins/context-guard/bin/context-guard-failed-nudge +3 -0
- package/plugins/context-guard/bin/context-guard-filter +163 -7
- package/plugins/context-guard/bin/context-guard-guard-read +3 -0
- package/plugins/context-guard/bin/context-guard-pack +602 -43
- package/plugins/context-guard/bin/context-guard-rewrite-bash +3 -0
- package/plugins/context-guard/bin/context-guard-setup +165 -31
- package/plugins/context-guard/bin/context-guard-statusline +490 -283
- package/plugins/context-guard/bin/context-guard-statusline-merged +5 -0
- package/plugins/context-guard/bin/context-guard-tool-prune +241 -1
- package/plugins/context-guard/lib/context_guard_commands.py +206 -0
- package/plugins/context-guard/skills/setup/SKILL.md +1 -0
- package/context-guard-kit/README.md +0 -91
- package/context-guard-kit/benchmark_runner.py +0 -2401
- package/context-guard-kit/claude_transcript_cost_audit.py +0 -2346
- package/context-guard-kit/context_compress.py +0 -695
- package/context-guard-kit/context_escrow.py +0 -935
- package/context-guard-kit/context_filter.py +0 -637
- package/context-guard-kit/context_guard_cli.py +0 -325
- package/context-guard-kit/context_guard_diet.py +0 -1711
- package/context-guard-kit/context_pack.py +0 -2713
- package/context-guard-kit/cost_guard.py +0 -2349
- package/context-guard-kit/experimental_registry.py +0 -4348
- package/context-guard-kit/failed_attempt_nudge.py +0 -567
- package/context-guard-kit/guard_large_read.py +0 -690
- package/context-guard-kit/hook_secret_patterns.py +0 -43
- package/context-guard-kit/read_symbol.py +0 -483
- package/context-guard-kit/rewrite_bash_for_token_budget.py +0 -501
- package/context-guard-kit/sanitize_output.py +0 -725
- package/context-guard-kit/settings.example.json +0 -67
- package/context-guard-kit/setup_wizard.py +0 -2515
- package/context-guard-kit/statusline.sh +0 -362
- package/context-guard-kit/statusline_merged.sh +0 -157
- package/context-guard-kit/tool_schema_pruner.py +0 -837
- 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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
if
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
362
|
-
echo "[$model] ${dir}${branch} | ctx ${context_label} | cost ${cost}${metrics_label}"
|
|
569
|
+
exec python3 -c "$CONTEXT_GUARD_STATUSLINE_PY" "$@"
|