Skip to content

session-title.sh

.claude/hooks/session-title.sh

PostToolUse

Session-title management, in two modes: write — PostToolUse on the Skill tool. Records "<emoji> <skill> <args>" for the last substantive skill run into .claude/.session-title. emit — called by session-start.sh. Prints the SessionStart JSON that sets sessionTitle, from that state file, else the current work branch. WHY a state file + SessionStart rather than a live rename: Claude Code only honours sessionTitle from SessionStart output — PostToolUse / Stop output cannot set it (verified against the hooks docs, 2026-06). So the title can't change the instant a skill runs; we capture the intent on use and apply it on the next startup/resume. Issue #443 tracks the platform ask to make it live. be-* manner-of-working modes (be-caveman, be-complete, …) are deliberately NOT titled: opening a session with caveman to save tokens must never make the session read as "about caveman".

Source

bash
#!/usr/bin/env bash
#
# Session-title management, in two modes:
#   write  — PostToolUse on the `Skill` tool. Records "<emoji> <skill> <args>"
#            for the last *substantive* skill run into .claude/.session-title.
#   emit   — called by session-start.sh. Prints the SessionStart JSON that sets
#            `sessionTitle`, from that state file, else the current work branch.
#
# WHY a state file + SessionStart rather than a live rename: Claude Code only
# honours `sessionTitle` from SessionStart output — PostToolUse / Stop output
# cannot set it (verified against the hooks docs, 2026-06). So the title can't
# change the instant a skill runs; we capture the intent on use and apply it on
# the next startup/resume. Issue #443 tracks the platform ask to make it live.
#
# be-* manner-of-working modes (be-caveman, be-complete, …) are deliberately
# NOT titled: opening a session with `caveman` to save tokens must never make
# the session read as "about caveman".

set -euo pipefail

cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null || exit 0

state=".claude/.session-title"

case "${1:-write}" in
  write)
    input="$(cat)"
    skill="$(jq -r '.tool_input.skill // empty' <<<"$input")"
    [ -n "$skill" ] || exit 0

    # Only mapped, substantive skills earn a title. Everything unlisted — every
    # be-* mode included — falls through and leaves the existing title untouched.
    case "$skill" in
      build-*)    emoji="🧑‍💻" ;;
      brainstorm) emoji="🧠" ;;
      *)          exit 0 ;;
    esac

    args="$(jq -r '.tool_input.args // empty' <<<"$input" | tr '\n\r' '  ' | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//' | cut -c1-50)"
    label="$skill"
    [ -n "$args" ] && label="$skill $args"

    mkdir -p .claude
    printf '%s %s\n' "$emoji" "$label" >"$state"
    ;;

  emit)
    title=""
    if [ -s "$state" ]; then
      title="$(head -n1 "$state")"
    else
      branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
      # The web env's auto-generated claude/* branch doesn't reflect the work,
      # so it's no better than the auto-summary — skip it and stay untitled.
      case "$branch" in
        "" | HEAD | main | claude/*) : ;;
        *) title="$branch" ;;
      esac
    fi

    [ -n "$title" ] || exit 0
    jq -n --arg t "$title" \
      '{hookSpecificOutput: {hookEventName: "SessionStart", sessionTitle: $t}}'
    ;;
esac

exit 0