mirror of
https://github.com/obra/superpowers.git
synced 2026-11-07 23:48:10 +00:00
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
90 lines
4.3 KiB
Bash
Executable file
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
|