Skip to content

Commit 582c520

Browse files
authored
fix(hook): emit SessionStart hint via systemMessage JSON contract (#5)
1 parent 4a3775b commit 582c520

4 files changed

Lines changed: 75 additions & 41 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "passthru",
3-
"version": "0.2.1",
3+
"version": "0.2.2",
44
"description": "Regex-based permission rules for Claude Code via hooks",
55
"owner": {
66
"name": "nnemirovsky"

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "passthru",
3-
"version": "0.2.1",
3+
"version": "0.2.2",
44
"description": "Regex-based permission rules for Claude Code via hooks",
55
"license": "MIT"
66
}

hooks/handlers/session-start.sh

Lines changed: 33 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,21 @@
44
# Purpose:
55
# One-time, best-effort hint nudging brand-new passthru users to run
66
# `/passthru:bootstrap` if they already have native permission rules in
7-
# their `~/.claude/settings.json` that could be imported. Prints a
8-
# single-line plain-text message on stdout, which Claude Code surfaces
9-
# in the session header as "SessionStart:startup says: <text>". Then
10-
# touches a marker so the hint does not fire again.
7+
# their `~/.claude/settings.json` that could be imported. Emits a JSON
8+
# envelope `{"systemMessage": "<text>"}` on stdout, which Claude Code
9+
# surfaces in the session view as "SessionStart:startup says: <text>".
10+
# Then touches a marker so the hint does not fire again.
1111
#
1212
# Contract:
1313
# stdin - JSON envelope from Claude Code (SessionStart hook). We do
14-
# not need any of its fields; we still drain stdin defensively
15-
# so the writer does not hit SIGPIPE.
16-
# stdout - a single line of plain text when the hint should fire,
17-
# otherwise EMPTY. Per Claude Code docs, SessionStart stdout
18-
# is appended to the session view as
19-
# "SessionStart:startup says: <text>". Anything else (e.g.
20-
# `{}`) would appear verbatim to the user.
14+
# not need any of its fields, and `exec < /dev/null` up front
15+
# ensures nothing downstream ever blocks on reading it.
16+
# stdout - a single-line JSON object `{"systemMessage":"<text>"}` when
17+
# the hint should fire, otherwise EMPTY. Per Claude Code
18+
# docs, SessionStart `systemMessage` is surfaced in the
19+
# session view as "SessionStart:startup says: <text>". Plain
20+
# text stdout does NOT surface - the JSON envelope is the
21+
# correct contract.
2122
# exit - always 0. Any error fails open (empty stdout, exit 0).
2223
#
2324
# Gating:
@@ -28,14 +29,23 @@
2829
# marker and exit silently.
2930
# - If `~/.claude/settings.json` has no `.permissions.allow` entries,
3031
# there is nothing to import - touch the marker and exit silently.
31-
# - Otherwise: count the allow entries, emit the hint on stdout, touch
32-
# the marker.
32+
# - Otherwise: count the allow entries, emit the hint as a JSON
33+
# `systemMessage` envelope on stdout, touch the marker.
3334
#
3435
# Paths honor PASSTHRU_USER_HOME and PASSTHRU_PROJECT_DIR so bats tests
3536
# never touch the real ~/.claude.
3637

3738
set -euo pipefail
3839

40+
# ---------------------------------------------------------------------------
41+
# Redirect stdin to /dev/null before sourcing common.sh or running any
42+
# command that might read from it. SessionStart never needs stdin contents,
43+
# and on macOS Claude Code can keep the pipe open across `claude --resume`
44+
# which would hang any cat/read call. This mirrors the pattern used by
45+
# memsearch's session-start.sh.
46+
# ---------------------------------------------------------------------------
47+
exec < /dev/null
48+
3949
# ---------------------------------------------------------------------------
4050
# Locate and source common.sh (same pattern as pre/post handlers)
4151
# ---------------------------------------------------------------------------
@@ -58,15 +68,6 @@ fi
5868
# ---------------------------------------------------------------------------
5969
trap 'printf "[passthru] unexpected error in session-start.sh\n" >&2; exit 0' ERR
6070

61-
# ---------------------------------------------------------------------------
62-
# Drain stdin defensively so the parent does not see SIGPIPE on its
63-
# write end. We do not use the payload; SessionStart fields are not needed
64-
# for this one-time hint.
65-
# ---------------------------------------------------------------------------
66-
if [ ! -t 0 ]; then
67-
cat >/dev/null 2>&1 || true
68-
fi
69-
7071
USER_HOME="$(passthru_user_home)"
7172
MARKER="${USER_HOME}/.claude/passthru.bootstrap-hint-shown"
7273

@@ -132,10 +133,17 @@ if [ "$COUNT" -eq 0 ]; then
132133
fi
133134

134135
# ---------------------------------------------------------------------------
135-
# 4. Emit the one-time hint on stdout and persist the marker.
136-
# Single line keeps the session header readable.
136+
# 4. Emit the one-time hint on stdout as a JSON `systemMessage` envelope
137+
# and persist the marker. Claude Code surfaces the systemMessage in the
138+
# session view as "SessionStart:startup says: <text>".
137139
# ---------------------------------------------------------------------------
138-
printf 'passthru: detected %s importable permission rule(s) in ~/.claude/settings.json. Run /passthru:bootstrap to convert them. This tip only shows once.\n' "$COUNT"
140+
HINT_MSG="passthru: detected ${COUNT} importable permission rule(s) in ~/.claude/settings.json. Run /passthru:bootstrap to convert them. This tip only shows once."
141+
142+
# jq -nc builds a compact JSON object with proper escaping. Fail-open: if
143+
# jq cannot produce output, we stay silent rather than emit broken JSON.
144+
if ENVELOPE="$(jq -nc --arg msg "$HINT_MSG" '{systemMessage:$msg}' 2>/dev/null)"; then
145+
printf '%s\n' "$ENVELOPE"
146+
fi
139147

140148
touch_marker
141149
exit 0

tests/session_start_hook.bats

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@
2020
# Hermetic via PASSTHRU_USER_HOME / PASSTHRU_PROJECT_DIR.
2121
#
2222
# Contract notes:
23-
# Per Claude Code docs, SessionStart stdout is plain text and surfaces in the
24-
# session view as "SessionStart:startup says: <text>". The handler therefore
25-
# emits the hint on stdout (not stderr) when it fires, and emits nothing on
26-
# stdout in all other cases - including the marker-present short-circuit -
27-
# so the session header stays clean.
23+
# Per Claude Code docs, the SessionStart hook surfaces its `systemMessage`
24+
# JSON field in the session view as "SessionStart:startup says: <text>".
25+
# The handler therefore emits `{"systemMessage":"<text>"}` on stdout when
26+
# the hint fires, and emits nothing on stdout in all other cases -
27+
# including the marker-present short-circuit - so the session header stays
28+
# clean. Plain text stdout does NOT surface, so earlier versions of this
29+
# hook were silently ignored by Claude Code.
2830

2931
setup() {
3032
REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
@@ -65,6 +67,19 @@ run_handler() {
6567
export status STDOUT STDERR
6668
}
6769

70+
# assert_hint_envelope: verify STDOUT is a well-formed JSON object with
71+
# `.systemMessage` containing the passthru hint fragments. Requires jq.
72+
assert_hint_envelope() {
73+
# Must be parseable as JSON.
74+
jq -e '.' <<<"$STDOUT" >/dev/null
75+
# Must have a systemMessage string field.
76+
jq -e '.systemMessage | type == "string"' <<<"$STDOUT" >/dev/null
77+
local msg
78+
msg="$(jq -r '.systemMessage' <<<"$STDOUT")"
79+
[[ "$msg" == *"/passthru:bootstrap"* ]]
80+
[[ "$msg" == *"only shows once"* ]]
81+
}
82+
6883
# ---------------------------------------------------------------------------
6984
# Marker short-circuit
7085
# ---------------------------------------------------------------------------
@@ -77,7 +92,7 @@ run_handler() {
7792

7893
run_handler '{}'
7994
[ "$status" -eq 0 ]
80-
# stdout must be empty - no `{}` noise in the session header.
95+
# stdout must be empty - no JSON envelope in the session header.
8196
[ -z "$STDOUT" ]
8297
# No stderr hint.
8398
[ -z "$STDERR" ]
@@ -167,17 +182,27 @@ run_handler() {
167182
# Actual hint path.
168183
# ---------------------------------------------------------------------------
169184

170-
@test "session-start: settings.json with N allow entries -> hint on stdout mentions N and /passthru:bootstrap" {
185+
@test "session-start: settings.json with N allow entries -> hint systemMessage mentions N and /passthru:bootstrap" {
171186
printf '%s\n' '{"permissions":{"allow":["Bash(ls:*)","Bash(echo hello)","mcp__context7__query-docs"]}}' \
172187
> "$USER_ROOT/.claude/settings.json"
173188

174189
run_handler '{}'
175190
[ "$status" -eq 0 ]
176191

177-
# Hint lands on stdout so Claude Code can surface it in the session header.
178-
[[ "$STDOUT" == *"3 importable"* ]]
179-
[[ "$STDOUT" == *"/passthru:bootstrap"* ]]
180-
[[ "$STDOUT" == *"only shows once"* ]]
192+
# Hint lands on stdout wrapped in the Claude Code JSON contract so it
193+
# surfaces as "SessionStart:startup says: <text>".
194+
jq -e '.' <<<"$STDOUT" >/dev/null
195+
local msg
196+
msg="$(jq -r '.systemMessage' <<<"$STDOUT")"
197+
[[ "$msg" == *"3 importable"* ]]
198+
[[ "$msg" == *"/passthru:bootstrap"* ]]
199+
[[ "$msg" == *"only shows once"* ]]
200+
201+
# Only the systemMessage key should be present - keep the envelope
202+
# minimal so we do not accidentally inject context.
203+
local keys
204+
keys="$(jq -r 'keys | join(",")' <<<"$STDOUT")"
205+
[ "$keys" = "systemMessage" ]
181206

182207
# Nothing on stderr in the happy path.
183208
[ -z "$STDERR" ]
@@ -193,6 +218,7 @@ run_handler() {
193218
run_handler '{}'
194219
[ "$status" -eq 0 ]
195220
[ -n "$STDOUT" ]
221+
assert_hint_envelope
196222
[ -f "$(marker_path)" ]
197223

198224
# Second invocation must be silent on both streams.
@@ -213,8 +239,8 @@ run_handler() {
213239

214240
run_handler 'not-json{{{'
215241
[ "$status" -eq 0 ]
216-
# The hint should still fire (stdin is drained, never parsed).
217-
[[ "$STDOUT" == *"/passthru:bootstrap"* ]]
242+
# The hint should still fire (stdin is redirected to /dev/null, never parsed).
243+
assert_hint_envelope
218244
# Marker must still be touched since nothing else went wrong.
219245
[ -f "$(marker_path)" ]
220246
}
@@ -225,7 +251,7 @@ run_handler() {
225251

226252
run_handler ''
227253
[ "$status" -eq 0 ]
228-
[[ "$STDOUT" == *"/passthru:bootstrap"* ]]
254+
assert_hint_envelope
229255
[ -f "$(marker_path)" ]
230256
}
231257

0 commit comments

Comments
 (0)