superpowers/hooks/session-start
Jesse Vincent 328685babd fix(hooks): start no child processes in the SessionStart hook (#2081)
On Windows, Git Bash emulates fork(), so every command substitution and
pipe costs tens of milliseconds. The SessionStart hook runs on every
startup, /clear and compaction, and on dev it forked six times: the
$(cd ... && pwd) in run-hook.cmd and twice in session-start,
$(escape_for_json ...), and printf | cat.

Resolve paths with parameter expansion, read the skill with the read
builtin, have escape_for_json assign a variable, and absorb EPIPE with
trap '' PIPE plus printf 2>/dev/null || true instead of piping through
cat. The cat pipe also turned an early-closed stdout into exit 141 under
pipefail; the new emit_json exits 0 there.

Measured on Windows 11 (VM, Git Bash 5.2), 21 interleaved runs of
run-hook.cmd session-start: median 116 ms before, 46 ms after, against a
25 ms floor for one bash start (the wrapper starts two). Claude Code
2.1.308 logged the hook at 181-421 ms before and 137-146 ms after. Output
is byte-identical to dev across all five output shapes, two working
directories, normal and empty PATH, direct and through the wrapper (40/40
on Linux), and its sha256 matches on Windows.

Tests: the cat-stub case, which asserted the pipe, is replaced by a
closed-stdout case, and a new case counts process creations with strace
on Linux (5 in session-start and 6 through the wrapper on dev, 0 after).

Co-authored-by: willow <willowite@users.noreply.github.com>
Claude-Session: https://claude.ai/code/session_01SHANcr4A71nzuU9et4Fnba
2026-10-22 21:16:09 +00:00

90 lines
4.3 KiB
Bash
Executable file

#!/usr/bin/env bash
# SessionStart hook for superpowers plugin
set -euo pipefail
# Determine plugin root directory.
# Splits $0 on its last / or \ (run-hook.cmd's cmd half passes a Windows
# path), with no separator meaning the current directory. This avoids
# dirname because a SessionStart:startup hook can run with a broken/empty
# PATH (anthropics/claude-code#43127) before any PATH-repairing sibling hook
# in the same startup batch takes effect.
#
# No command substitution or pipe runs in this script: each one forks, and a
# fork costs tens of milliseconds under Git Bash on Windows. The paths stay
# relative to the current directory, which is all the file read below needs.
case "$0" in
*/*|*\\*) SCRIPT_DIR="${0%[/\\]*}" ;;
*) SCRIPT_DIR="." ;;
esac
PLUGIN_ROOT="${SCRIPT_DIR}/.."
# Read using-superpowers content with the read builtin (no cat, for the same
# PATH-independence reason, and no $(< file) subshell). read returns 1 at end
# of file, hence || true; the loop drops trailing newlines as $(< file) did.
skill_md="${PLUGIN_ROOT}/skills/using-superpowers/SKILL.md"
using_superpowers_content="Error reading using-superpowers skill"
if [ -r "$skill_md" ]; then
IFS= read -r -d '' using_superpowers_content < "$skill_md" || true
while [[ $using_superpowers_content == *$'\n' ]]; do
using_superpowers_content="${using_superpowers_content%$'\n'}"
done
fi
# Escape string for JSON embedding using bash parameter substitution.
# Each ${s//old/new} is a single C-level pass - orders of magnitude
# faster than the character-by-character loop this replaces. The result
# goes to a variable instead of stdout, so no $(...) subshell is needed.
escape_for_json() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\n'/\\n}"
s="${s//$'\r'/\\r}"
s="${s//$'\t'/\\t}"
using_superpowers_escaped="$s"
}
escape_for_json "$using_superpowers_content"
session_context="<EXTREMELY_IMPORTANT>\nYou have superpowers.\n\n**Below is the full content of your 'superpowers:using-superpowers' skill - your introduction to using skills. For all other skills, use the 'Skill' tool:**\n\n${using_superpowers_escaped}\n</EXTREMELY_IMPORTANT>"
# Output context injection as JSON.
# Cursor hooks expect additional_context (snake_case).
# Claude Code hooks expect hookSpecificOutput.additionalContext (nested).
# Copilot CLI (v1.0.11+) and others expect additionalContext (top-level, SDK standard).
# Claude Code reads BOTH additional_context and hookSpecificOutput without
# deduplication, so we must emit only the field the current platform consumes.
# Muse sets MUSE_PLUGIN_ROOT and expects additionalContext (SDK standard).
#
# Uses printf instead of heredoc to work around bash 5.3+ heredoc hang.
# See: https://github.com/obra/superpowers/issues/571
#
# emit_json absorbs EPIPE without a pipe through `cat`. On Windows + Git
# Bash, the harness can close the hook's stdout before printf finishes, and
# Git Bash's printf reports that EPIPE as "printf: write error: Permission
# denied", which `set -euo pipefail` would let escape as a startup diagnostic
# every session (#1612). SIGPIPE is ignored so other platforms see the same
# EPIPE error instead of a signal; the error message is dropped and the
# status ignored. No external command is involved, so a broken PATH (#2310)
# changes nothing.
emit_json() {
trap '' PIPE
# shellcheck disable=SC2059 # every caller passes a literal format string
printf "$@" 2>/dev/null || true
}
if [ -n "${CURSOR_PLUGIN_ROOT:-}" ]; then
# Cursor sets CURSOR_PLUGIN_ROOT (may also set CLAUDE_PLUGIN_ROOT)
emit_json '{\n "additional_context": "%s"\n}\n' "$session_context"
elif [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -z "${COPILOT_CLI:-}" ] && [ -z "${MUSE_PLUGIN_ROOT:-}" ]; then
# Claude Code sets CLAUDE_PLUGIN_ROOT without COPILOT_CLI/MUSE_PLUGIN_ROOT
emit_json '{\n "hookSpecificOutput": {\n "hookEventName": "SessionStart",\n "additionalContext": "%s"\n }\n}\n' "$session_context"
elif [ -n "${MUSE_PLUGIN_ROOT:-}" ]; then
# Muse sets MUSE_PLUGIN_ROOT — try Claude-style nested output for Muse Spark
emit_json '{\n "hookSpecificOutput": {\n "hookEventName": "SessionStart",\n "additionalContext": "%s"\n }\n}\n' "$session_context"
else
# Copilot CLI (sets COPILOT_CLI=1) or unknown platform — SDK standard format
emit_json '{\n "additionalContext": "%s"\n}\n' "$session_context"
fi
exit 0