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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.0] - 2026-05-06

### Added

- Lazy garbage collection of session state files in `inject-time-context.sh`:
- Rate-limited via `${STATE_DIR}/.gc-sweep` marker (one sweep per 24h max).
- Prunes session state files with mtime older than 7 days.
- Liveness signal: `touch` of the active state file on every prompt prevents quiet long-running sessions from being expired.
- Fully silent — failures never propagate to Claude Code stderr.
- New `hooks/session-end.sh` SessionEnd hook for prompt, precise per-session cleanup. Deletes only the ending session's state file.
- `scripts/install.sh` now registers both `UserPromptSubmit` and `SessionEnd` hooks. Per-event idempotency.
- `scripts/uninstall.sh` removes both hook entries and both installed scripts.
- `settings.example.json` shows both hook registrations.
- Test coverage: `tests/test_gc_sweep.sh` (4 scenarios), `tests/test_session_end.sh` (3 scenarios). 9/9 pass on macOS + Ubuntu.

### Changed

- `tests/lib.sh` — added `run_session_end` helper.

## [0.1.0] - 2026-05-06

### Added
Expand Down
50 changes: 42 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The hook injects a single-line context message `<context-tick>YYYY-MM-DD · HH:M
- **Quarter-hour tick** — minute boundary hits 00, 15, 30, or 45
- **Timezone shift** — DST transition or laptop traveling

State is tracked per-session in `~/.claude/state/time-inject/` with atomic writes (`mktemp` + `mv`) preventing partial state corruption on abnormal exits.
State is tracked per-session in `~/.claude/state/time-inject/` with atomic writes (`mktemp` + `mv`) preventing partial state corruption on abnormal exits. The hook also performs lazy garbage collection of stale state files: files idle for ≥7 days are pruned on a 24-hour sweep cadence, and a complementary `SessionEnd` hook deletes a session's file immediately when the session ends cleanly.

## Install

Expand All @@ -47,10 +47,28 @@ If you prefer to merge manually, copy the `hooks` entry from `settings.example.j
```json
{
"hooks": {
"userPromptSubmit": {
"path": "/path/to/claude-context-tick/hooks/inject-time-context.sh",
"enabled": true
}
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "${HOME}/.claude/hooks/inject-time-context.sh"
}
]
}
],
"SessionEnd": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "${HOME}/.claude/hooks/session-end.sh"
}
]
}
]
}
}
```
Expand All @@ -67,6 +85,8 @@ Environment variable to control behavior:

Example: `CLAUDE_TIME_INJECT=0 claude` launches a session with no time injections.

Note: Garbage collection behavior (7-day retention, 24-hour sweep cadence) is not currently configurable. Future versions may expose these via environment variables.

## How It Decides Whether to Inject (State-Tracking Explainer)

On each prompt submission, the hook:
Expand Down Expand Up @@ -95,22 +115,36 @@ The state file format is simple JSON:

The `reason` field documents why the injection fired (useful for debugging).

## Garbage Collection

Without automated cleanup, every Claude Code session leaves behind a state file. Over weeks or months, a long-lived developer can accumulate hundreds of idle files, wasting disk space and cluttering the state directory.

The hook implements a two-pronged GC strategy:

**SessionEnd hook** — When a Claude Code session ends cleanly, the `SessionEnd` hook fires and deletes only that session's state file. This is precise, prompt, and race-free: no concurrent prompts, no sweep logic, just `rm -f ~/.claude/state/time-inject/{SESSION_ID}.json`.

**Lazy sweep** — Sessions that end abnormally (hard kill, OS crash, reboot before hook fires) leave orphan files. The `inject-time-context.sh` hook runs a backstop: once per 24 hours, it prunes any state file with mtime older than 7 days. This is rate-limited via a marker file (`${STATE_DIR}/.gc-sweep`) to avoid scanning the directory on every single prompt.

**Liveness signal** — Every prompt submission, even when no injection fires, `touch`-updates the state file's mtime. This ensures a long-running but quiet session (stuck on a single task for 8 hours within a quarter-hour window) is not mistakenly swept away.

**Configuration** — The retention threshold (7 days) and sweep interval (24 hours) are fixed at compile time. There are no environment variable overrides; this design choice prioritizes zero-config simplicity. If you need immediate cleanup, simply `rm -rf ~/.claude/state/time-inject/` at any time. The hook will recreate the directory on its next run.

## Uninstall

```bash
bash scripts/uninstall.sh
```

This removes the hook entry from `~/.claude/settings.json`. The state directory `~/.claude/state/time-inject/` is left in place (can be manually removed if desired).
This removes both the `UserPromptSubmit` and `SessionEnd` hook entries from `~/.claude/settings.json`, and deletes the installed hook scripts. The state directory `~/.claude/state/time-inject/` is left in place (can be manually removed if desired).

## Troubleshooting

### "I don't see any `<context-tick>` lines in my conversation"

The first prompt of any session should always fire an injection. Check `~/.claude/state/time-inject/` — you should see one `.json` file per session ID. If it's empty:

- Verify `~/.claude/settings.json` has the hook entry under `hooks.userPromptSubmit`.
- Confirm the `path` points to your `inject-time-context.sh` script (not a stale path).
- Verify `~/.claude/settings.json` has an entry under `hooks.UserPromptSubmit` (PascalCase, an array).
- Confirm the `command` field inside that entry's `hooks` array points to your `inject-time-context.sh` script (not a stale path).
- Try the one-line install again to refresh the config.

### "Hook fires but injection is missing from the conversation"
Expand Down
46 changes: 46 additions & 0 deletions hooks/inject-time-context.sh
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,46 @@ STATE_FILE="${STATE_DIR}/${SESSION_ID}.json"
mkdir -p "$STATE_DIR" 2>/dev/null || exit 0
[[ -w "$STATE_DIR" ]] || exit 0

# --- Lazy GC sweep (runs at most once per 24h) ---
# Returns 0 if a sweep is due (marker absent or >24h old), 1 otherwise.
_gc_sweep_due() {
local marker="$1"
if [ ! -f "$marker" ]; then
return 0
fi
python3 -c "
import os, sys, time
try:
age = time.time() - os.path.getmtime(sys.argv[1])
sys.exit(0 if age >= 86400 else 1)
except Exception:
sys.exit(1)
" "$marker" 2>/dev/null
return $?
}

# Enumerate and remove session state files whose mtime is >7 days old.
# Belt-and-suspenders: the glob *.json already excludes .gc-sweep (no .json
# extension), but the find pattern makes the exclusion explicit.
# Uses find -mtime +7 (floor semantics: files older than 7*24h blocks).
# Never uses find -delete (flag-order footgun per dev-team policy).
# All operations silent; function always returns 0.
_gc_run_sweep() {
local marker="${STATE_DIR}/.gc-sweep"
_gc_sweep_due "$marker" 2>/dev/null || return 0
# Prune stale .json session files; explicitly exclude the marker and .tmp.* files
find "$STATE_DIR" -maxdepth 1 -name "*.json" \
! -name ".gc-sweep" \
! -name ".tmp.*" \
-mtime +7 \
-print0 2>/dev/null \
| xargs -0 rm -f 2>/dev/null || true
# Update marker regardless of whether any files were pruned
touch "$marker" 2>/dev/null || true
return 0
}
_gc_run_sweep 2>/dev/null || true

# --- Current values ---
NOW_DATE=$(date +%Y-%m-%d)
NOW_TIME=$(date +%H:%M)
Expand Down Expand Up @@ -125,4 +165,10 @@ print(json.dumps(payload))
" "$INJECT"
fi

# Liveness signal: keep state file mtime fresh so the GC sweep does not expire
# an active session. Runs even when INJECT is empty (no state change this prompt).
# MUST be after FIRST_RUN detection and after the state-write block — placing it
# before FIRST_RUN would pre-create STATE_FILE and break first-run injection.
touch "$STATE_FILE" 2>/dev/null || true

exit 0
31 changes: 31 additions & 0 deletions hooks/session-end.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# session-end.sh — claude-context-tick SessionEnd hook
# https://github.com/DoubleNode/claude-context-tick
# SPDX-License-Identifier: MIT
#
# Removes this session's state file when SessionEnd fires.
# Complementary to the lazy sweep in inject-time-context.sh: the sweep prunes
# files from sessions that ended without firing this hook (e.g. hard kills,
# crashes); this hook provides prompt, precise cleanup for normal exits.
#
# No kill-switch check — cleanup runs even when CLAUDE_TIME_INJECT=0.
# No set -euo pipefail — pure cleanup, must never exit non-zero.

# --- Read session_id from stdin JSON ---
# Verbatim copy of inject-time-context.sh lines 25-33.
STDIN_JSON=$(cat)
SESSION_ID=$(printf '%s' "$STDIN_JSON" | python3 -c \
"import sys,json,re
try:
sid = json.load(sys.stdin).get('session_id','unknown')
except Exception:
sid = 'unknown'
sid = re.sub(r'[^A-Za-z0-9._-]', '', sid) or 'unknown'
print(sid)" 2>/dev/null || echo "unknown")

STATE_DIR="${HOME}/.claude/state/time-inject"
TARGET="${STATE_DIR}/${SESSION_ID}.json"

rm -f "$TARGET" 2>/dev/null || true

exit 0
91 changes: 76 additions & 15 deletions scripts/install.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
#!/usr/bin/env bash
# install.sh — claude-context-tick installer
#
# Registers inject-time-context.sh as a UserPromptSubmit hook in
# ~/.claude/settings.json without clobbering any existing hooks.
# Registers inject-time-context.sh as a UserPromptSubmit hook and
# session-end.sh as a SessionEnd hook in ~/.claude/settings.json
# without clobbering any existing hooks.
#
# Usage:
# bash scripts/install.sh
Expand All @@ -18,13 +19,17 @@ set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
REPO_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )"
HOOK_SRC="$REPO_ROOT/hooks/inject-time-context.sh"
SESSION_END_SRC="$REPO_ROOT/hooks/session-end.sh"

INSTALL_DIR="${CLAUDE_CONTEXT_TICK_DIR:-$HOME/.claude/hooks}"
SETTINGS_FILE="$HOME/.claude/settings.json"

HOOK_NAME="inject-time-context.sh"
HOOK_DEST="$INSTALL_DIR/$HOOK_NAME"

SESSION_END_NAME="session-end.sh"
SESSION_END_DEST="$INSTALL_DIR/$SESSION_END_NAME"

# ---------------------------------------------------------------------------
# Pre-flight checks
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -54,6 +59,12 @@ if [ ! -f "$HOOK_SRC" ]; then
exit 1
fi

if [ ! -f "$SESSION_END_SRC" ]; then
echo "ERROR: Hook source not found: $SESSION_END_SRC" >&2
echo "Run install.sh from inside the claude-context-tick repository." >&2
exit 1
fi

# ---------------------------------------------------------------------------
# Install hook script
# ---------------------------------------------------------------------------
Expand All @@ -64,15 +75,21 @@ chmod +x "$HOOK_DEST"

echo "Installed hook: $HOOK_DEST"

cp "$SESSION_END_SRC" "$SESSION_END_DEST"
chmod +x "$SESSION_END_DEST"

echo "Installed hook: $SESSION_END_DEST"

# ---------------------------------------------------------------------------
# Merge into ~/.claude/settings.json
# ---------------------------------------------------------------------------

# The entry we want to add (as a jq-compatible object literal).
# We pass the command path via a shell variable to avoid jq injection.
# The command paths to register.
# We pass these via shell variables to avoid jq injection.
NEW_ENTRY_COMMAND="$HOOK_DEST"
SESSION_END_COMMAND="$SESSION_END_DEST"

# Idempotency filter: does an entry for this exact command already exist?
# Idempotency filter: does a UserPromptSubmit entry for this exact command already exist?
already_installed() {
jq -e --arg cmd "$NEW_ENTRY_COMMAND" '
.hooks.UserPromptSubmit? // []
Expand All @@ -81,12 +98,21 @@ already_installed() {
' "$SETTINGS_FILE" >/dev/null 2>&1
}

# Idempotency filter: does a SessionEnd entry for this exact command already exist?
already_installed_session_end() {
jq -e --arg cmd "$SESSION_END_COMMAND" '
.hooks.SessionEnd? // []
| map(.hooks // [] | map(.command) | index($cmd))
| any(. != null)
' "$SETTINGS_FILE" >/dev/null 2>&1
}

if [ ! -f "$SETTINGS_FILE" ]; then
# Create minimal settings.json from scratch
# Create minimal settings.json from scratch with both hook entries
echo "Settings file not found — creating: $SETTINGS_FILE"
mkdir -p "$(dirname "$SETTINGS_FILE")"

jq -n --arg cmd "$NEW_ENTRY_COMMAND" '{
jq -n --arg cmd "$NEW_ENTRY_COMMAND" --arg cmd_se "$SESSION_END_COMMAND" '{
"hooks": {
"UserPromptSubmit": [
{
Expand All @@ -95,6 +121,14 @@ if [ ! -f "$SETTINGS_FILE" ]; then
{ "type": "command", "command": $cmd }
]
}
],
"SessionEnd": [
{
"matcher": "",
"hooks": [
{ "type": "command", "command": $cmd_se }
]
}
]
}
}' > "$SETTINGS_FILE"
Expand All @@ -114,10 +148,9 @@ else
exit 1
fi

# Idempotency: skip if the exact command is already registered
# --- UserPromptSubmit merge (independent of SessionEnd check) ---
if already_installed; then
echo "Already installed — hook entry for '$HOOK_DEST' already present in settings."
echo "Nothing changed."
echo "Already installed — UserPromptSubmit hook entry already present."
else
# Merge: append new entry to UserPromptSubmit array (create array/key if absent)
MERGED="$(jq --arg cmd "$NEW_ENTRY_COMMAND" '
Expand All @@ -138,7 +171,33 @@ else
echo "$MERGED" > "$TMPFILE"
mv "$TMPFILE" "$SETTINGS_FILE"

echo "Merged hook entry into: $SETTINGS_FILE"
echo "Merged UserPromptSubmit hook entry into: $SETTINGS_FILE"
fi

# --- SessionEnd merge (independent of UserPromptSubmit check) ---
if already_installed_session_end; then
echo "Already installed — SessionEnd hook entry already present."
else
# Merge: append new entry to SessionEnd array (create array/key if absent)
MERGED_SE="$(jq --arg cmd "$SESSION_END_COMMAND" '
.hooks //= {}
| .hooks.SessionEnd //= []
| .hooks.SessionEnd += [
{
"matcher": "",
"hooks": [
{ "type": "command", "command": $cmd }
]
}
]
' "$SETTINGS_FILE")"

# Write atomically via temp file
TMPFILE_SE="$(mktemp "$SETTINGS_FILE.tmp.XXXXXX")"
echo "$MERGED_SE" > "$TMPFILE_SE"
mv "$TMPFILE_SE" "$SETTINGS_FILE"

echo "Merged SessionEnd hook entry into: $SETTINGS_FILE"
fi
fi

Expand All @@ -149,10 +208,12 @@ echo ""
echo "========================================"
echo " claude-context-tick installed"
echo "========================================"
echo " Hook path : $HOOK_DEST"
echo " Settings : $SETTINGS_FILE"
echo " Kill-switch : CLAUDE_TIME_INJECT=0 (set in env to disable injection)"
echo " Verify with : ls ~/.claude/state/time-inject/ after your next prompt"
echo " Hook path : $HOOK_DEST"
echo " Session-end hook : $SESSION_END_DEST"
echo " Settings : $SETTINGS_FILE"
echo " Kill-switch : CLAUDE_TIME_INJECT=0 (set in env to disable injection)"
echo " GC retention : 7 days (sweep every 24h)"
echo " Verify with : ls ~/.claude/state/time-inject/ after your next prompt"
echo "========================================"
echo ""

Expand Down
Loading
Loading