P
PostToolUse
HookFires on
PostToolUseImmediately after a tool returns, with its result available.
Where this sits in a session
- Session start→
- Prompt submitted→
- Prompt expansion→
- Before tool use→
- After tool use→
- Notification→
- Before compaction→
- Subagent finished→
- Turn finished→
- Session end
Can block — This hook is able to deny the action or send Claude back to work.
What it runs
Executed automatically when the event fires — no prompt, no confirmation.
command
bash "${CLAUDE_PLUGIN_ROOT}/scripts/post-tool-hook.sh"Install it yourself
Paste into .claude/settings.json to run this hook without installing the whole plugin. Adjust the paths to point at your own copy of the scripts.
settings.json
{
"hooks": {
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/scripts/post-tool-hook.sh\""
}
]
}
]
}
}Source
Read this before installing — it runs on your machine with your permissions.
#!/bin/bash
# ============================================================================
# post-tool-hook.sh — PostToolUse hook for the Remember plugin
# ============================================================================
#
# DESCRIPTION
# Fires after every Claude Code tool call. Counts new JSONL lines since the
# last save, and when the delta exceeds a threshold (default 50 lines),
# launches save-session.sh in the background. Also outputs a team memory
# nudge as additionalContext to remind the agent to log team-worthy knowledge.
#
# Which session it is recording for comes from the `session_id` on stdin,
# NOT from the newest transcript in the session dir (#212) — with two
# sessions live in one project those are different answers, and the wrong one
# writes a durable marker vouching for a session that did no work. The
# transcript that is measured is resolved from that same id, because the
# line count is compared against a position keyed by it. See the block above
# CURRENT_LINES.
#
# USAGE
# Called automatically by Claude Code's PostToolUse hook system.
# Not intended for manual invocation.
#
# STDIN
# The PostToolUse JSON payload. Consumed here, and re-exported as
# REMEMBER_HOOK_STDIN for hooks.d/after_post_tool scripts. Read is bounded
# (never from a tty, `read -t 1`): this fires on every tool call, so a
# blocking read is a hung agent. Anything unusable falls back to the
# pre-#212 mtime behaviour rather than failing.
#
# ENVIRONMENT
# CLAUDE_PLUGIN_ROOT Plugin install directory (set by Claude Code)
# CLAUDE_PROJECT_DIR Project root (default: .)
#
# DEPENDENCIES
# python3 (for JSON parsing of last-save.json)
# jq (for reading config.json threshold)
# save-session.sh (launched in background when threshold met)
#
# EXIT CODES
# 0 Always (hook must not block the agent)
#
# ============================================================================
# --- Resolve paths ---
# Opt into resolve-paths.sh's soft-failure mode — see the comment in
# session-start-hook.sh. This hook must never block the agent, so a resolution
# failure (e.g. a nested/headless session with no CLAUDE_PROJECT_DIR) is a
# silent no-op, not a crash.
REMEMBER_PATHS_SOFT_FAIL=1 source "$(dirname "$0")/resolve-paths.sh" || exit 0
source "$(dirname "$0")/detect-tools.sh"
source "$(dirname "$0")/bootstrap-dirs.sh"
PLUGIN_ROOT="$PIPELINE_DIR"
PROJECT="$PROJECT_DIR"
source "$PLUGIN_ROOT/scripts/log.sh" 2>/dev/null
log "hook" "post-tool: PROJECT_DIR=$PROJECT_DIR PIPELINE_DIR=$PIPELINE_DIR PYTHON=$PYTHON REMEMBER_DIR=$REMEMBER_DIR"
# "PostToolUse ran at all" (#200) — written here, before every early exit
# below, because the question the doctor asks is whether this hook is WIRED,
# and the answers to that and to "did it find a transcript" are different.
# Putting it after the transcript check made it unwritable in exactly the
# slug-mismatch case (#144) it most needed to distinguish, so the doctor told
# those users the hook had never fired and to restart Claude Code — advice
# that does nothing for a wrong slug.
: > "$REMEMBER_DIR/tmp/post-tool-ran" 2>/dev/null || true
# --- Which session is this invocation FOR? (#212) ---
# PostToolUse supplies the answer on stdin. Read it here, once, before anything
# else wants it, and re-export the raw payload: this hook consumes stdin, so a
# hooks.d/after_post_tool script that wanted it would otherwise find EOF.
#
# Reading stdin is only safe if it cannot wait forever — this runs on EVERY
# tool call, so a blocking read is a hung agent, not a slow one. Two guards:
# a tty stdin (hand invocation from a shell) is never read at all, and the read
# itself is bounded by `-t 1` so a pipe held open with nothing in it costs a
# second and then gives up. bash 3.2 has no sub-second -t, hence 1.
HOOK_STDIN=""
if [ ! -t 0 ]; then
_line=""
while IFS= read -r -t 1 _line || [ -n "$_line" ]; do
HOOK_STDIN="$HOOK_STDIN$_line"
_line=""
done
fi
export REMEMBER_HOOK_STDIN="$HOOK_STDIN"
# Extract "session_id" without forking a JSON parser on every tool call.
# Deliberately narrow: the key must be followed by nothing but whitespace and a
# colon before the value's opening quote, so a `session_id` appearing inside
# tool_input (a Grep pattern, a file being read) is not mistaken for the field.
# It is a heuristic and it is treated as one — the caller validates the result
# as a path component AND requires it to name a transcript that exists here
# before anything is done with it.
_stdin_session_id() {
local raw="$1" rest prefix value
case "$raw" in *'"session_id"'*) ;; *) return 1 ;; esac
rest=${raw#*\"session_id\"}
prefix=${rest%%\"*}
case "$prefix" in *[!:[:space:]]*) return 1 ;; esac
value=${rest#*\"}
value=${value%%\"*}
[ -n "$value" ] || return 1
printf '%s' "$value"
}
STDIN_SESSION_ID=$(_stdin_session_id "$HOOK_STDIN" 2>/dev/null) || STDIN_SESSION_ID=""
# stdin is not more trustworthy than a basename. The id becomes both a path
# component under capture-alive.d/ and a transcript filename, so it faces the
# same guard the basename-derived id has always faced, at the point of entry.
case "$STDIN_SESSION_ID" in
''|.|..|*[!A-Za-z0-9._-]*) STDIN_SESSION_ID="" ;;
esac
SAVE_SCRIPT="$PLUGIN_ROOT/scripts/save-session.sh"
LAST_SAVE_FILE="$REMEMBER_DIR/tmp/last-save.json"
PID_FILE="$REMEMBER_DIR/tmp/save-session.pid"
SESSION_DIR="$(claude_projects_dir)/$(session_dir_slug "$PROJECT")"
[ -f "$SAVE_SCRIPT" ] || exit 0
# --- Count JSONL lines in the current session ---
# A slug that does not match the directory Claude Code actually created leaves
# nothing to read here, and the whole pipeline no-ops for the life of the
# session. That used to be a bare `exit 0`, so the only symptom was memory
# quietly never appearing (issue #144).
#
# Say so instead — but on a timer, not once ever. This runs on every tool call,
# so a line per call would bury the logs; a plain once-only sentinel is worse
# though, because the cause here is environmental (a mis-slugged path stays
# mis-slugged), so the warning would fire once and every later session would be
# silent again — the very failure being fixed. An hourly repeat keeps a
# persistent cause visible without flooding.
NOTICE_TTL=3600
LATEST_JSONL=$(ls -t "$SESSION_DIR"/*.jsonl 2>/dev/null | head -1)
if [ -z "$LATEST_JSONL" ]; then
NOTICE_MARKER="$REMEMBER_DIR/tmp/no-transcript-notice"
NOTICE_LAST=0
if [ -f "$NOTICE_MARKER" ]; then
NOTICE_LAST=$(cat "$NOTICE_MARKER" 2>/dev/null || echo 0)
case "$NOTICE_LAST" in ''|*[!0-9]*) NOTICE_LAST=0 ;; esac
fi
if [ $(( $(_remember_date +%s) - NOTICE_LAST )) -ge "$NOTICE_TTL" ]; then
mkdir -p "$REMEMBER_DIR/tmp" 2>/dev/null
_remember_date +%s > "$NOTICE_MARKER" 2>/dev/null
if [ -d "$SESSION_DIR" ]; then
log "hook" "no .jsonl transcript in $SESSION_DIR — nothing to save yet"
else
log "hook" "WARNING: no session dir for this project: $SESSION_DIR (slug of $PROJECT). Memory cannot save until it matches a directory under $(claude_projects_dir)/"
fi
fi
exit 0
fi
rm -f "$REMEMBER_DIR/tmp/no-transcript-notice" 2>/dev/null
# --- One transcript, one session, one answer (#212) ---
# Everything below is about ONE session: the marker vouches for it, the saved
# position is keyed by it, and save-session.sh is handed it by id. The line
# count is compared against that position, so it has to count THAT session's
# transcript — "the newest file in the directory" was only ever a stand-in for
# it, and with two sessions live in one project the stand-in names the wrong
# one. Resolve the transcript from the session id and both uses agree by
# construction.
#
# The mtime pick above is kept as the fallback, unchanged, and is still what
# decides whether there is anything here to save at all. If stdin did not
# answer — an older CLI, a different invocation path, a test harness — or if it
# named a session with no transcript in this project, the hook records exactly
# what it recorded before this change. Misattributing a tool call is a bug;
# capturing nothing is an outage, and this repo has twice traded the first for
# the second (#204, #129).
TRANSCRIPT="$LATEST_JSONL"
if [ -n "$STDIN_SESSION_ID" ] && [ -f "$SESSION_DIR/$STDIN_SESSION_ID.jsonl" ]; then
TRANSCRIPT="$SESSION_DIR/$STDIN_SESSION_ID.jsonl"
else
[ -n "$STDIN_SESSION_ID" ] && log "hook" "post-tool: stdin session $STDIN_SESSION_ID has no transcript in $SESSION_DIR — falling back to newest"
fi
CURRENT_LINES=$(wc -l < "$TRANSCRIPT" | tr -d ' ')
SESSION_ID=$(basename "$TRANSCRIPT" .jsonl)
# Which session PostToolUse serviced, for the capture-gap check in
# session-start-hook.sh (#200). Distinct from the post-tool-ran marker above:
# that one answers "is this hook wired", this one answers "for which session",
# and it can only be written once a transcript has actually been found.
#
# Before any throttle or early return BELOW this line: the question is "did it
# run for this session", not "did it save".
#
# The SESSION ID, not a timestamp. `-nt` compares mtimes at one-second
# granularity under bash 3.2, so a sign of life landing in the same second as
# the session-start stamp read as "not newer" and reported a healthy session as
# broken. Identity has no clock to lose to.
# Written via rename, the same lesson last-save.json learned in #140: a plain
# redirect truncates first, so a process killed between truncate and write
# leaves an EMPTY marker — which matches no session id, and the next
# SessionStart reports a healthy session as a gap.
#
# ONE MARKER PER SESSION, not one slot shared by all of them (#206). A single
# slot answers "which session most recently made a tool call" — a different
# question from "was session X captured", and the moment anything writes after
# X the answer for X is gone. Not hypothetical: after a `/clear` the CURRENT
# session's own tool calls overwrite it while SessionStart still has the
# previous session to judge, so every healthy session read as broken, once per
# /clear, forever.
#
# The single file is still written. doctor.sh reads it, and it is the only
# evidence an install upgrading from the old format has for the session that
# straddles the upgrade.
if mkdir -p "$REMEMBER_DIR/tmp/capture-alive.d" 2>/dev/null; then
# The id becomes a path component, so it is checked rather than trusted:
# it comes from a filename in the transcript dir, and `..` or a slash
# would escape the store. Real session ids are UUIDs.
case "$SESSION_ID" in
''|.|..|*[!A-Za-z0-9._-]*) : ;;
*) : > "$REMEMBER_DIR/tmp/capture-alive.d/$SESSION_ID" 2>/dev/null || true ;;
esac
fi
if printf '%s' "$SESSION_ID" > "$REMEMBER_DIR/tmp/capture-alive.$$" 2>/dev/null; then
mv -f "$REMEMBER_DIR/tmp/capture-alive.$$" "$REMEMBER_DIR/tmp/capture-alive" 2>/dev/null \
|| rm -f "$REMEMBER_DIR/tmp/capture-alive.$$" 2>/dev/null || true
fi
# --- Get last saved position (from last-save.json) ---
# Positions are keyed by session (issue #140), so ask for THIS session rather
# than whether it happens to own the one slot — two live sessions used to
# overwrite each other and re-summarize whole spans as duplicates.
#
# Delegated to pipeline.shell rather than parsed here. This hook used to carry
# its own json reader, and it drifted: every other reader learned that a bool
# is not a position and an integral float is, while this one kept a bare
# isinstance check and reported 0 for positions the rest of the pipeline
# resumed from, defeating the delta throttle for the life of a session.
LAST_LINE=0
if [ -f "$LAST_SAVE_FILE" ]; then
LAST_LINE=$(cd "$PIPELINE_DIR" && $PYTHON -m pipeline.shell read-position "$LAST_SAVE_FILE" "$SESSION_ID" 2>/dev/null)
case "$LAST_LINE" in ''|*[!0-9]*) LAST_LINE=0 ;; esac
fi
DELTA=$((CURRENT_LINES - LAST_LINE))
SAVE_TRIGGERED=""
# --- Don't fork a save that save-session.sh will only discard on cooldown ---
# The delta throttle above keys on save *position* (last-save.json), which is
# only written after a successful save. Until the first save lands LAST_LINE is
# 0, so DELTA is the whole transcript and always clears the threshold — and in
# an agentic session (many tool calls, few human turns) the min-human gate keeps
# that first save from ever landing, so the hook would fork a save-session.sh on
# every tool call for the whole session (issue #125). save-session.sh already
# rate-limits on the last-save-ts cooldown marker; consult it here so the fork
# is skipped entirely instead of spawned only to be discarded milliseconds later.
IN_COOLDOWN=false
COOLDOWN_MARKER="$REMEMBER_DIR/tmp/last-save-ts"
if [ -f "$COOLDOWN_MARKER" ]; then
LAST_TS=$(cat "$COOLDOWN_MARKER" 2>/dev/null || echo 0)
case "$LAST_TS" in ''|*[!0-9]*) LAST_TS=0 ;; esac
SAVE_COOLDOWN=$(config ".cooldowns.save_seconds" 120)
case "$SAVE_COOLDOWN" in ''|*[!0-9]*) SAVE_COOLDOWN=120 ;; esac
[ $(( $(_remember_date +%s) - LAST_TS )) -lt "$SAVE_COOLDOWN" ] && IN_COOLDOWN=true
fi
# --- Fire save if delta exceeds threshold and no save already running ---
DELTA_THRESHOLD=$(config ".thresholds.delta_lines_trigger" 50)
if [ "$DELTA" -gt "$DELTA_THRESHOLD" ] && [ "$IN_COOLDOWN" = false ]; then
ALREADY_RUNNING=false
if [ -f "$PID_FILE" ]; then
OLD_PID=$(cat "$PID_FILE" 2>/dev/null)
if kill -0 "$OLD_PID" 2>/dev/null; then
ALREADY_RUNNING=true
fi
fi
if [ "$ALREADY_RUNNING" = false ]; then
mkdir -p "$REMEMBER_DIR/logs/autonomous"
nohup "$SAVE_SCRIPT" "$SESSION_ID" > "$REMEMBER_DIR/logs/autonomous/save-$(_remember_date +%H%M%S).log" 2>&1 &
echo $! > "$PID_FILE"
SAVE_TRIGGERED="true"
fi
fi
# --- Dispatch: after_post_tool ---
export REMEMBER_SAVE_TRIGGERED="$SAVE_TRIGGERED"
dispatch "after_post_tool"
Shipped by 1 plugin
Installing any of these installs this hook.
Reviews
Log in to leave a review.
No reviews yet — be the first.
Explore related
Other things in this space — across every part of the ecosystem, not just hooks.