Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "prep-compact",
"description": "When your Claude Code session's real token count crosses a configurable threshold (default 450K), an informational reminder names the warm handoff file. A Stop hook continuously maintains an on-disk handoff JSON (cumulative file paths, in-progress todos, active subagent launches, recent user-message quotes). The /prep-compact skill reads this warm handoff and adds an analytical layer (decisions, constraints, blockers, verb-anchored next-step) to emit a tailored /compact <instructions> block.",
"version": "3.0.2",
"version": "3.0.3",
"author": {
"name": "Koen van der Heide",
"url": "https://github.com/koenvdheide"
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ All notable changes to prep-compact will be documented in this file.

The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [3.0.3] - 2026-06-26

### Changed

- The Stop hook now runs asynchronously, so the handoff write no longer blocks the end of a turn.
- The UserPromptSubmit hook merges its stdin parse and transcript tail-scan into a single Python process, removing one interpreter cold-start per message (the dominant per-message cost on Windows).

### Fixed

- The UserPromptSubmit hook converts the transcript path with `cygpath` first on Git Bash, matching the pre-merge behaviour. A direct existence check could otherwise resolve a POSIX path such as `/tmp/x` to a drive-relative `C:\tmp\x` and scan the wrong file.
- With the Stop hook now asynchronous, it compares the on-disk handoff's `transcript_mtime_at_write` before replacing the handoff and skips the write when the existing file is newer, reducing the chance that a slow run overwrites a fresher handoff from a later turn. A narrow residual race remains (the check and the replace are not atomic, so a concurrent newer run can still interleave between them; closing it fully would need a per-session lock), but it is rare and self-corrects on the next successful Stop.

## [3.0.2] - 2026-06-05

### Fixed
Expand Down
120 changes: 70 additions & 50 deletions hooks/check-context-size.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@
# falls back to a shorter copy that just names the skill. Always exits 0
# (fail-open).
#
# A single Python process does both the stdin-JSON parse (session_id,
# transcript_path) and the transcript tail-scan, printing two lines:
# line 1: safe session id (empty -> caller no-op)
# line 2: summed token count (absent -> below-threshold/no-usable-usage no-op)
# Collapsing what were two separate python invocations into one removes a
# per-message interpreter cold-start (the dominant cost on Windows).
#
# Main-chain filter: role == 'assistant', isSidechain != true,
# isApiErrorMessage != true. input_tokens required; cache fields default to 0.
# No byte path, no baseline, no RESET.
Expand Down Expand Up @@ -44,81 +51,85 @@ else
exit 0
fi

# Extract session_id and transcript_path from stdin JSON.
EXTRACTED=$(printf '%s' "$STDIN_JSON" | "$PY" -c "
import sys, json, hashlib, re
# Single Python process: parse stdin JSON for session_id + transcript_path,
# derive the safe session id, resolve the transcript to a path the interpreter
# can open, tail-scan the last 256 KB for the newest main-chain assistant
# .message.usage, and print safe_sid (line 1) and the summed token count
# (line 2, omitted when there is no usable usage). Defensive at every layer.
RESULT=$(printf '%s' "$STDIN_JSON" | "$PY" -c "
import sys, json, hashlib, re, os, subprocess

TAIL_BYTES = 262144 # 256 KB

try:
d = json.load(sys.stdin)
sid = d.get('session_id', '') or ''
tp = d.get('transcript_path', '') or ''
except Exception:
sid = ''
tp = ''
print('')
sys.exit(0)

# session_id safety: regex-valid or SHA-1 hex fallback.
if sid and re.fullmatch(r'[A-Za-z0-9_-]{1,64}', sid):
safe = sid
elif sid:
safe = hashlib.sha1(sid.encode('utf-8')).hexdigest()
else:
safe = ''
print(safe)
print(tp)
" 2>/dev/null || printf '\n\n')
SAFE_SID=$(printf '%s' "$EXTRACTED" | sed -n '1p')
TRANSCRIPT_PATH=$(printf '%s' "$EXTRACTED" | sed -n '2p')

if [[ -z "$SAFE_SID" ]]; then
exit 0
fi

FLAG="$CACHE_DIR/compact-warned-$SAFE_SID"

if [[ -z "$TRANSCRIPT_PATH" || ! -r "$TRANSCRIPT_PATH" ]]; then
exit 0
fi

# Convert transcript_path to a format the native Python can open. Git Bash
# on Windows maps /tmp/ and /c/ to NTFS paths that are invisible to a
# Windows-native python.exe; cygpath -w bridges the gap. On Linux/macOS
# cygpath isn't present and the path is already native, so we fall through.
if command -v cygpath >/dev/null 2>&1; then
TRANSCRIPT_NATIVE=$(cygpath -w "$TRANSCRIPT_PATH" 2>/dev/null || printf '%s' "$TRANSCRIPT_PATH")
else
TRANSCRIPT_NATIVE="$TRANSCRIPT_PATH"
fi
print('')
sys.exit(0)

# Tail-scan the transcript for the newest main-chain assistant .message.usage.
# Prints the summed token count or nothing. Defensive at every layer.
TOKENS=$(printf '%s' "$TRANSCRIPT_NATIVE" | "$PY" -c "
import sys, json, os
print(safe) # line 1 — always emitted once sid is usable

# Resolve transcript to a path the interpreter can open. Native paths
# (Windows/Linux/macOS) open directly; Git Bash maps /tmp/ and /c/ to NTFS
# paths invisible to a Windows-native python.exe, so fall back to cygpath -w.
# cygpath is only spawned when the direct open fails, so the common
# native-path case stays a single process with no extra spawn.
def resolve(path):
if not path:
return None
# Match the pre-merge behaviour: on Git Bash (cygpath present) convert the
# path FIRST. Native python resolves a POSIX path like /tmp/x to a
# drive-relative C:\tmp\x that can collide with an unrelated file, so a
# direct os.path.exists() pre-check could scan the wrong file. cygpath -w is
# one lightweight spawn (far cheaper than the second python it replaced).
# When cygpath is absent (Linux/macOS) or errors, fall back to the raw path;
# a wrong/missing path then fails the open() below and the hook stays silent.
try:
proc = subprocess.run(['cygpath', '-w', path],
capture_output=True, text=True, timeout=5)
if proc.returncode == 0:
c = proc.stdout.strip()
if c:
return c
except Exception:
pass
return path

TAIL_BYTES = 262144 # 256 KB
rp = resolve(tp)
if not rp:
sys.exit(0) # empty transcript_path -> no token line

path = sys.stdin.read().strip()
try:
size = os.path.getsize(path)
except OSError:
sys.exit(0)
try:
with open(path, 'rb') as f:
size = os.path.getsize(rp)
with open(rp, 'rb') as f:
f.seek(max(0, size - TAIL_BYTES))
tail = f.read().decode('utf-8', errors='replace')
except OSError:
sys.exit(0)

for line in reversed(tail.splitlines()):
try:
d = json.loads(line)
e = json.loads(line)
except Exception:
continue
if not isinstance(d, dict):
if not isinstance(e, dict):
continue
if d.get('isSidechain') is True:
if e.get('isSidechain') is True:
continue
if d.get('isApiErrorMessage') is True:
if e.get('isApiErrorMessage') is True:
continue
msg = d.get('message')
msg = e.get('message')
if not isinstance(msg, dict):
continue
if msg.get('role') != 'assistant':
Expand All @@ -133,13 +144,22 @@ for line in reversed(tail.splitlines()):
cr = u.get('cache_read_input_tokens') or 0
if not isinstance(cc, int) or not isinstance(cr, int):
continue
print(it + cc + cr)
sys.exit(0)
print(it + cc + cr) # line 2 — summed token count
break
" 2>/dev/null)

SAFE_SID=$(printf '%s' "$RESULT" | sed -n '1p')
TOKENS=$(printf '%s' "$RESULT" | sed -n '2p')

if [[ -z "$SAFE_SID" ]]; then
exit 0
fi

FLAG="$CACHE_DIR/compact-warned-$SAFE_SID"

if [[ -z "$TOKENS" || ! "$TOKENS" =~ ^[0-9]+$ ]]; then
# No usable usage in tail — silent no-op (pre-first-turn, parse errors,
# schema drift, oversized-straddle, etc.).
# schema drift, oversized-straddle, missing transcript, etc.).
exit 0
fi

Expand Down
2 changes: 1 addition & 1 deletion hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"Stop": [
{
"hooks": [
{ "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/update-handoff.sh\"" }
{ "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/update-handoff.sh\"", "async": true }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't mark stale handoffs current from async Stop

The official Claude Code hook docs state that async hooks start in the background and Claude continues without waiting, so with this flag a user can submit the next prompt before update-handoff.sh has replaced handoff-<sid>.json. check-context-size.sh only checks that the handoff file exists before telling the model it “is current”, so in that window /prep-compact can read the previous turn's files/todos/requests while the reminder claims it is fresh. Either keep this hook synchronous or add a freshness/in-progress check before advertising the handoff as current.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Defer the warning until the async handoff is ready

With the Stop hook now backgrounded, a user who submits the next prompt immediately after crossing the token threshold can hit UserPromptSubmit before this async write has created or refreshed handoff-<sid>.json. In that case check-context-size.sh emits the no-handoff reminder and creates compact-warned-<sid>, so the later successful handoff write will not trigger a corrected handoff-aware reminder during the same crossing. Previously the synchronous Stop hook made the handoff available before the next prompt hook ran.

Useful? React with 👍 / 👎.

]
}
]
Expand Down
16 changes: 16 additions & 0 deletions hooks/update-handoff.sh
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,22 @@ if os.environ.get('PREP_COMPACT_TEST_REPLACE_FAIL'):
except OSError: pass
sys.exit(0)

# Async-write ordering guard: with the Stop hook now async, a slower run for
# the same session can resume after a newer run already replaced the handoff.
# os.replace prevents partial reads but not last-writer-wins reordering, so skip
# the replace when the on-disk handoff's transcript_mtime_at_write is newer than
# this run's (a stale run must not clobber a fresher handoff). Fail-open.
try:
if os.path.exists(handoff_path):
with open(handoff_path, 'r', encoding='utf-8') as f:
_existing = json.load(f)
_emt = _existing.get('transcript_mtime_at_write')
Comment on lines +460 to +463

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make freshness check atomic with replace

In an async overlap, this read-before-replace guard can still let an older Stop run clobber a newer handoff: run A can pass this check while the file is absent or older, then run B writes a fresher handoff, and finally run A resumes at os.replace and overwrites B. That interleaving leaves the stale handoff on disk even though the mtime guard was intended to prevent stale writers, so the check needs to be coupled with the replace via locking or another atomic ordering mechanism.

Useful? React with 👍 / 👎.

if isinstance(_emt, (int, float)) and _emt > transcript_mtime:
os.unlink(tmp_path)
sys.exit(0)
Comment on lines +464 to +466

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Merge stale-run context before skipping

When two async Stop hooks overlap and the later run has already written a handoff for a newer transcript, this branch deletes the older run's temp file and exits. If the rapid subsequent turn pushed a user request or file reference out of the 1 MB transcript tail, the newer run could not capture that data from the prior handoff yet, and this skip drops the only run that did capture it; /prep-compact can then lose recent intent or touched files. Consider merging the stale run's additive fields into the existing newer handoff instead of dropping the temp wholesale.

Useful? React with 👍 / 👎.

except Exception:
pass

# Replace with one retry on PermissionError (Windows: target held open).
for attempt in (1, 2):
try:
Expand Down
26 changes: 23 additions & 3 deletions test/run-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ fi
# After T6: EXPECTED_PASS=87, SKIPPED=39 (T6 tests not Stop-dep)
# After T7: EXPECTED_PASS=88, SKIPPED=39 (T7 test not Stop-dep)
# PR-comment fix: +3 Stop-dep (T-32cap +1 short-still-captured, T-32prior +2)
EXPECTED_PASS=130
EXPECTED_PASS=131

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update skipped Stop-hook count with new assertion

When test/fixtures/stop-real.json is absent—the local-dev path this harness explicitly supports—the new T-38g assertion is skipped with the rest of the Stop-hook block, but SKIPPED remains 59 while EXPECTED_PASS was bumped to 131. That path now fails the false-green guard with PASS=71 + SKIPPED=59, so local developers without the captured fixture can no longer run the non-Stop tests; bump the skipped count with the expected count.

Useful? React with 👍 / 👎.

SKIPPED=0

run_hook() {
Expand Down Expand Up @@ -667,6 +667,22 @@ PRIOR_INTACT=$("$PY" -c "import json; d=json.load(open('$HANDOFF')); print('yes'
assert_eq "T-38p: simulated replace fail -> prior preserved (sentinel intact)" "yes" "$PRIOR_INTACT"
assert_true "T-38p: stderr warning printed on simulated fail" '[[ "$ERR" == *"replace failed twice, prior preserved"* ]]'

# --- T-38g: async ordering guard — a newer on-disk handoff is NOT clobbered by an older run.
# Pre-write a handoff with a far-future transcript_mtime_at_write; the run (whose
# fixture transcript has a normal mtime) must SKIP the replace, leaving the
# sentinel written_at untouched. Distinguishes the guard from cumulative_files
# merge (which would preserve prior entries even on a write).
cleanup
"$PY" -c "
import json
prior = {'version':'3.0','session_id':'s38g','cwd':'/sample/cwd','transcript_path':'/x','transcript_mtime_at_write':9999999999.0,'written_at':'2099-01-01T00:00:00Z','cumulative_files':[],'recent_files':[],'in_progress_status':'unknown','in_progress':[],'recent_task_launches':[],'recent_user_requests':[]}
with open('$(to_native "$CACHE/handoff-s38g.json")','w') as f: json.dump(prior, f)
"
run_stop_hook '{"session_id":"s38g","transcript_path":"'"$FIX/transcript-handoff-multi-turn.jsonl"'","cwd":"/sample/cwd","permission_mode":"default","hook_event_name":"Stop"}' >/dev/null
HANDOFF=$(to_native "$CACHE/handoff-s38g.json")
WRITTEN_AT=$("$PY" -c "import json; d=json.load(open('$HANDOFF')); print(d.get('written_at',''))")
assert_eq "T-38g: newer on-disk handoff preserved (guard skips stale replace)" "2099-01-01T00:00:00Z" "$WRITTEN_AT"

# T-52: file noise filter — .git/temp/plugin-data excluded, real path kept
cleanup
FIX_ENV="$FIX" "$PY" -c "
Expand Down Expand Up @@ -754,7 +770,7 @@ assert_eq "T-56: legit =-char path kept" "yes" "$("$PY" -c "import json;d=json
assert_eq "T-56: shell-junk line rejected" "no" "$("$PY" -c "import json;d=json.load(open('$HANDOFF'));print('yes' if any(('SCRIPT_DIR' in p) or ('SKILL=' in p) or ('prep-compact/SKILL.md' in p) for p in d['recent_files']) else 'no')")"

else
SKIPPED=59
SKIPPED=60
fi # STOP_FIXTURE_OK

# ===================================================================
Expand Down Expand Up @@ -828,7 +844,11 @@ assert_true "T-46: valid lower-root candidate chosen" '[[ "$RPATH" == *"zzz-inli
# T-46b semantic-malformed (cwd is a list, not a string) is skipped, not fatal
reset_pdata
mkdir -p "$PDATA/aaa-inline"
"$PY" -c "import json;json.dump({'cwd':['oops']},open(r'$PDATA/aaa-inline/handoff-sidA.json','w'))"
# to_native: the path is interpolated INTO the python -c code, so MSYS does not
# translate it (unlike an argv path); without this, Windows python resolves the
# /tmp/... MSYS path to a non-existent C:\tmp\... and the malformed fixture is
# never written, making this test pass for the wrong reason.
"$PY" -c "import json;json.dump({'cwd':['oops']},open(r'$(to_native "$PDATA/aaa-inline/handoff-sidA.json")','w'))"
write_handoff "zzz-inline" "sidA" "C:/proj/one"
run_resolve_f "sidA" "C:/proj/one"
assert_eq "T-46b: non-string cwd skipped, scan continues -> HIT" "HIT" "$RSTATUS"
Expand Down
Loading