diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-11 13:23:13 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-11 13:23:13 -0500 |
| commit | bdc9a5d6e1320032770f54c747c210e4f465c399 (patch) | |
| tree | 0ccf24de982248ea5b709afc230526544d59299d | |
| parent | 3df14fc985ddad041c290c732b5b5b8eae41f68e (diff) | |
| download | rulesets-bdc9a5d6e1320032770f54c747c210e4f465c399.tar.gz rulesets-bdc9a5d6e1320032770f54c747c210e4f465c399.zip | |
feat(hooks): title sessions "host project" for the remote session list
Remote sessions showed up on claude.ai/code and mobile under auto-generated names, so picking the right one meant guessing. Claude Code 2.1.152+ lets a SessionStart hook set the title via hookSpecificOutput.sessionTitle.
hooks/session-title.sh emits "<uname -n> <project>" (ratio rulesets, velox work) on startup and resume. Project is the git-toplevel basename so a session started in a subdirectory still names the project, with the cwd basename as fallback. The hook stays silent when a title already exists, so a /rename or an earlier run isn't clobbered on resume. The harness ignores titles on clear and compact, so the settings matcher restricts to startup|resume.
Wired in settings.json and the install-hooks snippet. As a default hook it reaches every machine through make install on the next session start.
| -rw-r--r-- | .claude/settings.json | 9 | ||||
| -rwxr-xr-x | hooks/session-title.sh | 41 | ||||
| -rw-r--r-- | hooks/settings-snippet.json | 6 | ||||
| -rw-r--r-- | scripts/tests/session-title-hook.bats | 70 |
4 files changed, 126 insertions, 0 deletions
diff --git a/.claude/settings.json b/.claude/settings.json index 3b8b237..b2e5af7 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -36,6 +36,15 @@ ], "SessionStart": [ { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "~/.claude/hooks/session-title.sh" + } + ] + }, + { "matcher": "clear", "hooks": [ { diff --git a/hooks/session-title.sh b/hooks/session-title.sh new file mode 100755 index 0000000..8a12f61 --- /dev/null +++ b/hooks/session-title.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# SessionStart(startup|resume) hook: title the session "<host> <project>" +# (e.g. "ratio rulesets", "velox work") so remote sessions are identifiable +# in the claude.ai/code and mobile session lists instead of auto-generated +# names. +# +# Project = basename of the git toplevel when cwd is inside a repo (a session +# started in a subdirectory still names the project), else basename of cwd. +# +# Only fires when the session has no title yet: a /rename, an earlier run, or +# any other titling must not be clobbered on resume. The harness ignores +# sessionTitle on clear/compact sources, so the settings matcher restricts to +# startup|resume. +# +# Wire in settings.json: +# +# "SessionStart": [ +# { "matcher": "startup|resume", +# "hooks": [ { "type": "command", +# "command": "~/.claude/hooks/session-title.sh" } ] } +# ] + +input=$(cat) + +existing=$(echo "$input" | jq -r '.session_title // empty') +[ -n "$existing" ] && exit 0 + +cwd=$(echo "$input" | jq -r '.cwd // empty') +[ -n "$cwd" ] || cwd=$PWD + +project=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null) +if [ -n "$project" ]; then + project=$(basename "$project") +else + project=$(basename "$cwd") +fi + +# uname -n, not hostname: Arch doesn't ship hostname by default (inetutils). +host=$(uname -n) + +python3 -c 'import json,sys; print(json.dumps({"hookSpecificOutput":{"hookEventName":"SessionStart","sessionTitle":sys.argv[1]}}))' "$host $project" diff --git a/hooks/settings-snippet.json b/hooks/settings-snippet.json index 1be5f00..a5f9d9c 100644 --- a/hooks/settings-snippet.json +++ b/hooks/settings-snippet.json @@ -2,6 +2,12 @@ "hooks": { "SessionStart": [ { + "matcher": "startup|resume", + "hooks": [ + { "type": "command", "command": "~/.claude/hooks/session-title.sh" } + ] + }, + { "matcher": "clear", "hooks": [ { "type": "command", "command": "~/.claude/hooks/session-clear-resume.sh" } diff --git a/scripts/tests/session-title-hook.bats b/scripts/tests/session-title-hook.bats new file mode 100644 index 0000000..60b633d --- /dev/null +++ b/scripts/tests/session-title-hook.bats @@ -0,0 +1,70 @@ +#!/usr/bin/env bats +# hooks/session-title.sh — SessionStart hook that titles the session +# "<host> <project>" (uname -n + git-toplevel basename, cwd basename outside +# a repo) so remote sessions are identifiable on web/mobile. It only sets a +# title when the session doesn't have one yet: a /rename or an earlier run +# must not be clobbered on resume. + +setup() { + REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../.." && pwd)" + SCRIPT="$REPO_ROOT/hooks/session-title.sh" + TMPDIR_T="$(mktemp -d)" + HOST="$(uname -n)" +} + +teardown() { + rm -rf "$TMPDIR_T" +} + +run_hook() { + # $1 = cwd, $2 = optional existing session_title + if [ -n "${2:-}" ]; then + printf '{"cwd":"%s","source":"startup","session_title":"%s"}' "$1" "$2" | sh "$SCRIPT" + else + printf '{"cwd":"%s","source":"startup"}' "$1" | sh "$SCRIPT" + fi +} + +@test "titles host + repo basename inside a git repo" { + git -C "$TMPDIR_T" init -q -b main + mkdir -p "$TMPDIR_T/sub/dir" + run run_hook "$TMPDIR_T" + [ "$status" -eq 0 ] + title=$(echo "$output" | jq -r '.hookSpecificOutput.sessionTitle') + [ "$title" = "$HOST $(basename "$TMPDIR_T")" ] +} + +@test "uses the repo toplevel basename from a subdirectory" { + git -C "$TMPDIR_T" init -q -b main + mkdir -p "$TMPDIR_T/sub/dir" + run run_hook "$TMPDIR_T/sub/dir" + [ "$status" -eq 0 ] + title=$(echo "$output" | jq -r '.hookSpecificOutput.sessionTitle') + [ "$title" = "$HOST $(basename "$TMPDIR_T")" ] +} + +@test "falls back to cwd basename outside a git repo" { + mkdir -p "$TMPDIR_T/chime" + run run_hook "$TMPDIR_T/chime" + [ "$status" -eq 0 ] + title=$(echo "$output" | jq -r '.hookSpecificOutput.sessionTitle') + [ "$title" = "$HOST chime" ] +} + +@test "emits valid SessionStart hookSpecificOutput" { + run run_hook "$TMPDIR_T" + [ "$status" -eq 0 ] + event=$(echo "$output" | jq -r '.hookSpecificOutput.hookEventName') + [ "$event" = "SessionStart" ] +} + +@test "stays silent when a session title already exists" { + run run_hook "$TMPDIR_T" "my custom name" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "emits no stderr noise" { + err="$(printf '{"cwd":"%s","source":"startup"}' "$TMPDIR_T" | sh "$SCRIPT" 2>&1 >/dev/null)" + [ -z "$err" ] +} |
