A Claude Code UserPromptSubmit hook that injects wall-clock time into conversation context at meaningful boundaries.
~22 sec demo: first-run injection → silent rerun (state matches) → fast-forward state → quarter-hour-tick injection. Reproduce locally with bash scripts/demo.sh.
Claude has no built-in concept of wall-clock time mid-conversation. The model's "today's date" comes from the system prompt at session start, but after that, subjective time inside the conversation drifts. Agents lose track of date rollovers, scheduled wake-ups, quarter-hourly pacing, and timezone shifts. This hook nudges Claude with a tiny ground-truth timestamp only when the answer to "what time is it actually" has materially changed — minimizing token cost while keeping the agent grounded.
The hook injects a single-line context message <context-tick>YYYY-MM-DD · HH:MM TZ</context-tick> into the conversation only on state transitions that matter:
- First run of a session — establishes baseline time
- Date rollover — midnight boundary crossed
- 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. 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.
curl -fsSL https://raw.githubusercontent.com/DoubleNode/claude-context-tick/main/scripts/install.sh | bashSecurity note: Always review scripts before piping to bash. The script simply merges the hook entry into ~/.claude/settings.json.
git clone https://github.com/DoubleNode/claude-context-tick.git
cd claude-context-tick
bash scripts/install.shIf you prefer to merge manually, copy the hooks entry from settings.example.json into your ~/.claude/settings.json:
{
"hooks": {
"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"
}
]
}
]
}
}Ensure the hooks key exists; if not, create it at the top level.
Environment variable to control behavior:
| Variable | Default | Effect |
|---|---|---|
CLAUDE_TIME_INJECT |
1 |
Set to 0 to disable the hook entirely (all injections suppressed) |
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.
On each prompt submission, the hook:
- Checks the kill-switch (
CLAUDE_TIME_INJECT=0). If enabled, exits silently with no injection. - Reads the current session_id from stdin (Claude Code UserPromptSubmit metadata). Sanitizes it via positive whitelist
[A-Za-z0-9._-]so untrusted input cannot escape the state directory. - Looks up the session's prior state in
~/.claude/state/time-inject/{SESSION_ID}.json. If the file doesn't exist, this is the first run. - Evaluates four conditions in order:
- First run? Inject full timestamp.
- Timezone changed (DST or
readlink /etc/localtimediffers)? Inject. - Date changed (midnight crossed)? Inject.
- Quarter-hour boundary (minute in [00, 15, 30, 45])? Inject.
- If any condition fires, write new state atomically (via
mktemp, thenmv) and emit the injection JSON to stdout per the UserPromptSubmit protocol.
The state file format is simple JSON:
{
"date": "2026-05-06",
"qh": "2026-05-06T14:30",
"iana": "America/Los_Angeles",
"tz": "PDT",
"reason": "date-rollover"
}The reason field documents why the injection fired (useful for debugging).
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.
bash scripts/uninstall.shThis 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).
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.jsonhas an entry underhooks.UserPromptSubmit(PascalCase, an array). - Confirm the
commandfield inside that entry'shooksarray points to yourinject-time-context.shscript (not a stale path). - Try the one-line install again to refresh the config.
The hook ran but the state directory could not be written. This can happen if:
~/.claude/state/or~/.claude/state/time-inject/is read-only.- Disk is full.
The hook silently exits on write failures (by design — it is best-effort and should not spam the Claude Code UI with errors). Resolve the write issue, then start a new Claude Code session.
Not a warning. D3 sanitization silently strips any character outside [A-Za-z0-9._-] from the session_id before using it as a filename. This is intentional and safe.
The hook:
- Reads only from stdin (session metadata from Claude Code).
- Writes only to
~/.claude/state/time-inject/(local state files, JSON format). - Never touches the network or makes external API calls.
- Sanitizes session_id via positive whitelist before using it as a filename, preventing directory traversal attacks (e.g.,
../etc/passwdinjection). - Respects the kill-switch (
CLAUDE_TIME_INJECT=0) before any I/O.
State files contain only timestamps and timezone metadata — no sensitive information.
Bash: 4.0+. macOS ships with bash 3.2 by default; this script uses [[ ... ]] and string comparisons compatible with 3.2. If you install bash 5+ via Homebrew, the shebang #!/usr/bin/env bash will pick it up automatically.
Python: 3.6+ required (already a Claude Code dependency). Used for robust JSON parsing and session_id sanitization.
Platforms: macOS (primary), Linux (Ubuntu, Debian tested; other distributions may vary). The hook tolerates non-symlink /etc/localtime on systems where readlink fails.
MIT. See LICENSE for details.
Issues and pull requests welcome at https://github.com/DoubleNode/claude-context-tick/issues.
Tests live in tests/; contributions should include test cases. CI runs on macOS and Ubuntu via GitHub Actions.
