aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-11 13:23:13 -0500
committerCraig Jennings <c@cjennings.net>2026-06-11 13:23:13 -0500
commitbdc9a5d6e1320032770f54c747c210e4f465c399 (patch)
tree0ccf24de982248ea5b709afc230526544d59299d
parent3df14fc985ddad041c290c732b5b5b8eae41f68e (diff)
downloadrulesets-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.json9
-rwxr-xr-xhooks/session-title.sh41
-rw-r--r--hooks/settings-snippet.json6
-rw-r--r--scripts/tests/session-title-hook.bats70
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" ]
+}