Feature: XACA-0341 — Session-state GC (lazy sweep + SessionEnd hook)#1
Conversation
Adds two-pronged garbage collection for the per-session state files
written under ~/.claude/state/time-inject/{SESSION_ID}.json. Without
GC, every Claude Code session leaves an orphan file and long-lived
users accumulate hundreds.
Strategy:
- SessionEnd hook (hooks/session-end.sh) — fires when a Claude Code
session ends cleanly. Reads session_id from stdin, sanitizes via
the same [A-Za-z0-9._-] whitelist used by inject-time-context.sh,
and removes only that one file. Silent on failure. No kill-switch
check (cleanup runs even when CLAUDE_TIME_INJECT=0).
- Lazy sweep (inject-time-context.sh) — backstop for sessions that
ended dirty (hard kill, crash, OS reboot before SessionEnd fires).
Rate-limited via the .gc-sweep marker file (one sweep per 24h).
Prunes any session state file with mtime older than 7 days. Uses
python3 mtime read instead of stat (avoids BSD/GNU divergence) and
find -print0 | xargs (avoids the flag-order footgun in find).
- Liveness signal — hook now touches the active state file on every
prompt regardless of whether an injection fires, so a long-running
quiet session is not mistakenly pruned.
Installer + uninstaller updated to register and de-register both
hooks with per-event idempotency. settings.example.json shows both
entries.
Test coverage: 4 new GC scenarios (fresh skip, stale prune, 24h
rate-limit honored, marker survives) + 3 new SessionEnd scenarios
(targeted delete only, malicious session_id sanitized, silent on
absent file). 9/9 pass on macOS + Ubuntu.
Version bump 0.1.0 → 0.2.0 (minor — backward-compatible feature).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
QA Test Report — claude-context-tick PR #1 (XACA-0341)
Tester: Lura Thok (Academy Testing Division)
Date: 2026-05-06
Branch: feature/xaca-0341-session-gc
Syntax Check
All shell files passed bash -n static syntax validation.
Exit code: 0
Test Suite
bash tests/run-tests.sh — 9/9 passed, 0 failed
[PASS] test_date_rollover
[PASS] test_first_run
[PASS] test_gc_sweep
[PASS] test_kill_switch
[PASS] test_malicious_session_id
[PASS] test_qh_tick
[PASS] test_session_end
[PASS] test_state_tracking
[PASS] test_tz_shift
Manual Scenario Results
All scenarios executed in isolated sandbox HOMEs with exported HOME variable to ensure clean state. Repo hooks invoked directly via bash hooks/inject-time-context.sh and bash hooks/session-end.sh.
Scenario A — Rapid injection rate-limiting (30 invocations, same session)
PASS. Exactly 1 injection fired on the first call; all 29 subsequent calls within the same QH bucket produced empty stdout. State file and .gc-sweep marker correctly created.
Scenario B — Stale marker (25h), 8-day-old session file triggers sweep
PASS. Sweep ran (marker age 90000s > 86400s threshold). The 8-day-old old-session-stale.json was removed. Marker mtime updated to current time. Fresh session file was created normally.
Scenario C — Fresh marker (mtime=now), 8-day-old session file survives (rate-limit honored)
PASS. Sweep did NOT run (marker age <10s). The 8-day-old session file survived untouched. Fresh session state written normally. Rate-limit correctly prevents sweep within 24h window.
Scenario D — session-end.sh deletes only the targeted session file
PASS. Three session files pre-seeded (session-alpha.json, session-beta.json, session-gamma.json) plus .gc-sweep marker. session-end.sh invoked with session_id: "session-beta". Result: session-beta.json removed; session-alpha.json, session-gamma.json, and .gc-sweep all survived.
Scenario E — Malicious session_id ../../../etc/passwd blocked by sanitization
PASS. The whitelist regex [^A-Za-z0-9._-] strips / and collapses the traversal to ......etcpasswd. /etc/passwd was not modified (size identical before and after). The sanitized target path (......etcpasswd.json) inside STATE_DIR did not exist and was not created. Silent exit.
Scenario F — install.sh idempotency + uninstall.sh cleanup
PASS. First install: created settings.json with both UserPromptSubmit and SessionEnd hook entries. Second install: correctly printed "Already installed — UserPromptSubmit hook entry already present." and "Already installed — SessionEnd hook entry already present." for both events. uninstall.sh --yes: removed both hook entries from settings.json; both hook scripts removed; settings verified via jq shows no remaining UserPromptSubmit or SessionEnd keys.
Scenario G — Read-only STATE_DIR mid-run: silent exit, no crash
PASS. After chmod a-w on STATE_DIR, a new-session invocation (which would need to write a new state file) produced empty stdout and empty stderr. No crash, no non-zero exit. The hook degraded gracefully as designed.
Scenario H — Malformed JSON on stdin: fallback to "unknown" session_id
PASS. Both hooks tested. inject-time-context.sh with non-JSON stdin: fell back to session_id="unknown", fired a first-run injection (correct — unknown is a valid session ID string), produced no stderr. session-end.sh with malformed JSON: fell back to "unknown", silently deleted unknown.json (which existed), no stderr. Both hooks are silent on bad input.
Summary
- Syntax check: 0 errors
- Test suite: 9/9 PASS
- All 8 manual scenarios: PASS
- No critical bugs found
- Security sanitization verified (path traversal blocked)
- Rate-limit, GC, idempotency, and cleanup all behave as specified
- No stderr leakage on any error path tested
There was a problem hiding this comment.
Code Review — PR #1 — XACA-0341 session GC
Decision: APPROVE (3 non-blocking subitems filed on XACA-0341)
Security
Path traversal: both inject-time-context.sh and session-end.sh sanitize session_id via the same re.sub(r'[^A-Za-z0-9._-]', '', sid) whitelist before building any filesystem path. The sanitization is verbatim-copied (not reimplemented), which is the right call. SE-2 test confirms a ../../../etc/passwd input is defused to ......etcpasswd and the resulting target stays inside STATE_DIR. No eval, no unquoted command substitution against tainted data. No secrets in any committed file. Clean.
Architecture
_gc_run_sweep is cleanly isolated: it reads only STATE_DIR (a module-level constant), calls _gc_sweep_due (its own helper), and touches only .json files in that dir. No unintended global writes. The touch-after-state-write ordering is correct: liveness signal on line 172 runs AFTER both FIRST_RUN detection (line 105) and the atomic state write (lines 135–150), so FIRST_RUN semantics are preserved. The || exit 0 escape at line 135 (mktemp failure) correctly prevents the liveness touch from running — the script exits before reaching line 172, so STATE_FILE is never pre-created. Architecture is sound.
Code Quality
- All variable expansions in path-building contexts are properly quoted.
python3calls inside_gc_sweep_dueare wrapped with2>/dev/null. The_gc_run_sweepcaller wraps the entire function call with2>/dev/null || true. Thexargspipeline ends with|| true. Silent-failure pattern is consistently applied.return $?after thepython3call in_gc_sweep_dueis explicit and correct withset -euo pipefail. The function returns the python3 exit code (0=due, 1=not due).- No
mapfile, nodeclare -A, no[[ -v ]]— Bash 3.2 compatible throughout. find -print0 | xargs -0is the correct pattern for filenames with spaces/special characters. The! -name ".gc-sweep"exclusion is belt-and-suspenders (the marker has no.jsonextension so*.jsonalready excludes it, but explicit exclusion is good defensive coding).session-end.shdeliberately omitsset -euo pipefail— this is intentional and correctly documented in the header ("pure cleanup, must never exit non-zero"). Clean.
Performance
Sweep is O(n) over state files but only runs inside the 24h rate-limit window. On every other prompt it's a single stat-equivalent via python3 getmtime. The liveness touch on each prompt is a single syscall. No performance concerns.
Testing — 9/9 pass
All tests confirmed passing locally. GC-1 through GC-4 and SE-1 through SE-3 cover the main spec. Three non-blocking gaps filed as [Review] subitems on XACA-0341 (see below).
Documentation
README "Garbage Collection" section accurately describes the two-pronged strategy, liveness signal, and config note. CHANGELOG 0.2.0 entry is complete. settings.example.json matches the installed format. Hand-merge example in README matches the example file.
Two stale references in the existing Troubleshooting section (not introduced by this PR, but now more visible) are filed as review subitems:
hooks.userPromptSubmit(camelCase) should behooks.UserPromptSubmit(PascalCase) — the correct key since 0.1.0.pathfield reference is leftover from the pre-0.1.0 schema; the installed format usescommandinside ahooksarray.
Backward Compatibility
Install idempotency is handled correctly: already_installed() and already_installed_session_end() are independent checks. A 0.1.0 user re-running install gets: UserPromptSubmit skipped (already present) + SessionEnd added. Correct behavior.
Non-Blocking Subitems Filed on XACA-0341
[Review]README Troubleshooting uses stale camelCase keyhooks.userPromptSubmit— should behooks.UserPromptSubmit[Review]README Troubleshooting line 147 references apathfield — the current schema usescommandinside ahooksarray[Review]Consider adding SE-4 test for session-end when STATE_DIR is completely absent (vs SE-3 which uses an empty dir). Manual verify confirms exit 0 + silent; minor spec gap only.
No blocking issues. The implementation is clean, well-tested, and maintains the silent-failure contract throughout.
- README Troubleshooting: fix stale schema references - hooks.userPromptSubmit → hooks.UserPromptSubmit (PascalCase) - top-level 'path' field → 'command' inside the hooks array Both refs date back to a pre-0.1.0 schema and were missed in the initial publish — caught by the PR #1 reviewer. - tests/test_session_end.sh: add SE-4 scenario. Stronger than SE-3: STATE_DIR (and ~/.claude/state) is fully absent, not just empty. Confirms session-end.sh exits 0 silently when there is no state directory at all. Resolves XACA-0341-009, XACA-0341-010, XACA-0341-011 (review subitems filed against the parent kanban item by the reviewer subagent). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
RE-QA APPROVED — SHA c8040d7 validated.
Diff verification: README.md corrects two stale schema references (hooks.userPromptSubmit → hooks.UserPromptSubmit; top-level path field → command inside hooks array). tests/test_session_end.sh adds SE-4 scenario (STATE_DIR completely absent). Both changes match the claimed scope exactly.
Test suite: 9/9 passed (test_date_rollover, test_first_run, test_gc_sweep, test_kill_switch, test_malicious_session_id, test_qh_tick, test_session_end, test_state_tracking, test_tz_shift). SE-4 executed and passed within test_session_end.
SE-4 manual sandbox verification: Ran session-end.sh with HOME set to a fresh temp dir containing no .claude directory. Exit code: 0. Post-run ls confirms .claude was never created.
Regression smoke tests: inject-time-context.sh correctly injects context and creates state file for fresh session_id (PASS). session-end.sh deletes only its own session file from a three-file STATE_DIR, leaving siblings untouched (PASS).
No regressions detected. PR is ready to merge.
There was a problem hiding this comment.
Re-review of SHA c8040d7 (follow-up commit addressing PR #1 review feedback).
Prior [Review] subitems — all properly addressed:
-
hooks.userPromptSubmitcasing: README now correctly useshooks.UserPromptSubmit(PascalCase array). Verified matchessettings.example.jsonandscripts/install.shlines 95/117/155-163. -
Top-level
pathfield: README now correctly describescommandfield inside the innerhooksarray entry. Matches actual settings schema shape produced by the installer. -
SE-4 absent-STATE_DIR test: Added. Removes both STATE_DIR and the entire
${HOME}/.claude/statetree before exercising the hook — correctly models the "never created" scenario. Asserts exit 0 and empty stdout/stderr. Does NOT assert dir re-creation (correct — mkdir is UserPromptSubmit's job, not SessionEnd's). Style is consistent with SE-1/SE-2/SE-3 (numbered variable suffixes, comment header block, assert ordering). Full suite runs 9/9 green.
No new [Review] subitems filed. The delta is clean.
APPROVE — no blocking issues in the follow-up commit.
Summary
Adds two-pronged garbage collection to
claude-context-tickso per-session state files no longer accumulate forever in~/.claude/state/time-inject/.hooks/session-end.sh— new SessionEnd hook. Readssession_idfrom stdin, sanitizes via the same[A-Za-z0-9._-]whitelist already used byinject-time-context.sh, and removes that one file. Silent on failure. No kill-switch check.inject-time-context.sh— backstop for sessions that ended dirty (hard kill, crash, OS reboot before SessionEnd fires). Rate-limited via the.gc-sweepmarker file (one sweep per 24h). Prunes any session state file with mtime older than 7 days.touches the active state file on every prompt regardless of whether an injection fires, so a long-running quiet session is not mistakenly pruned.Changes Made
hooks/inject-time-context.sh_gc_sweep_due()+_gc_run_sweep()+ livenesstouchhooks/session-end.shscripts/install.shUserPromptSubmitandSessionEndwith per-event idempotencyscripts/uninstall.shsettings.example.jsontests/lib.shrun_session_end()helpertests/test_gc_sweep.shtests/test_session_end.shREADME.mdCHANGELOG.mdImpact
Risk: LOW
rmon a single sanitized pathtest_malicious_session_id.shpatternTesting
Breaking Changes
None. Users with an existing 0.1.0 install can re-run
scripts/install.shto add the SessionEnd registration; the existing UserPromptSubmit entry will be detected and skipped.Checklist
🤖 Generated with Claude Code