Skip to content

Commit aec6465

Browse files
committed
feat: Skip non-initial sessions in SessionStart hook
The check_version.sh SessionStart hook now reads the JSON input from stdin and checks the 'source' field to detect non-initial sessions (resume, clear/compact). These sessions skip all checks immediately and return empty JSON, reducing noise and avoiding redundant checks. Initial sessions (source='startup' or missing for backwards compat) continue to run the full deepwork installation and version checks. This replaces the previous environment variable-based re-entry guard which was unreliable across session resumptions. https://claude.ai/code/session_014fkpR16Pjm2cukh551fiUM
1 parent ed566db commit aec6465

File tree

2 files changed

+169
-16
lines changed

2 files changed

+169
-16
lines changed

src/deepwork/hooks/check_version.sh

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,54 @@
1010
#
1111
# Uses hookSpecificOutput.additionalContext to pass messages to Claude's context.
1212

13+
# ============================================================================
14+
# READ STDIN INPUT
15+
# ============================================================================
16+
# SessionStart hooks receive JSON input via stdin with session information.
17+
# We need to read this to check the session source (startup, resume, clear).
18+
19+
HOOK_INPUT=""
20+
if [ ! -t 0 ]; then
21+
HOOK_INPUT=$(cat)
22+
fi
23+
24+
# ============================================================================
25+
# SKIP NON-INITIAL SESSIONS
26+
# ============================================================================
27+
# SessionStart hooks can be triggered for different reasons:
28+
# - "startup": Initial session start (user ran `claude` or similar)
29+
# - "resume": Session resumed (user ran `claude --resume`)
30+
# - "clear": Context was cleared/compacted
31+
#
32+
# We only want to run the full check on initial startup. For resumed or
33+
# compacted sessions, return immediately with empty JSON to avoid redundant
34+
# checks and noise.
35+
36+
get_session_source() {
37+
# Extract the "source" field from the JSON input
38+
# Returns empty string if not found or not valid JSON
39+
if [ -n "$HOOK_INPUT" ]; then
40+
# Use grep and sed for simple JSON parsing (avoid jq dependency)
41+
echo "$HOOK_INPUT" | grep -o '"source"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*:.*"\([^"]*\)"/\1/' | head -1
42+
fi
43+
}
44+
45+
SESSION_SOURCE=$(get_session_source)
46+
47+
# If source is anything other than "startup" (or empty/missing for backwards compat),
48+
# skip this hook entirely. Empty source means older Claude Code version that doesn't
49+
# send the source field - we treat that as an initial session to maintain backwards compat.
50+
if [ -n "$SESSION_SOURCE" ] && [ "$SESSION_SOURCE" != "startup" ]; then
51+
# Non-initial session (resume, clear, etc.) - skip all checks
52+
echo '{}'
53+
exit 0
54+
fi
55+
1356
# ============================================================================
1457
# DEEPWORK INSTALLATION CHECK (BLOCKING)
1558
# ============================================================================
16-
# This check runs on EVERY hook invocation (no re-entry guard) because if
17-
# deepwork is not installed, nothing else will work.
59+
# This check runs on initial session start because if deepwork is not installed,
60+
# nothing else will work.
1861

1962
check_deepwork_installed() {
2063
# Run 'deepwork rules clear_queue' instead of just '--version' for double utility:
@@ -70,20 +113,10 @@ if ! check_deepwork_installed; then
70113
exit 2 # Blocking error - prevent session from continuing
71114
fi
72115

73-
# ============================================================================
74-
# RE-ENTRY GUARD (for version check only)
75-
# ============================================================================
76-
# SessionStart hooks can be triggered multiple times in a session (on resume,
77-
# clear, etc.). We only want to show the version warning once per session to
78-
# avoid spamming the user. We use an environment variable to track whether
79-
# we've already run. Note: This relies on the parent process preserving env
80-
# vars across hook invocations within the same session.
81-
if [ -n "$DEEPWORK_VERSION_CHECK_DONE" ]; then
82-
# Already checked version this session, exit silently with empty JSON
83-
echo '{}'
84-
exit 0
85-
fi
86-
export DEEPWORK_VERSION_CHECK_DONE=1
116+
# Note: We previously had a re-entry guard using DEEPWORK_VERSION_CHECK_DONE
117+
# environment variable, but that was unreliable across session resumptions.
118+
# Now we use the source field in the hook input JSON to detect initial sessions
119+
# vs resumed/compacted sessions (see SKIP NON-INITIAL SESSIONS section above).
87120

88121
# ============================================================================
89122
# MINIMUM VERSION CONFIGURATION

tests/shell_script_tests/test_check_version.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def run_check_version_with_mock_claude(
2222
mock_version: str | None,
2323
cwd: Path | None = None,
2424
mock_deepwork: bool = True,
25+
stdin_json: str | None = None,
2526
) -> tuple[str, str, int]:
2627
"""
2728
Run check_version.sh with a mocked claude command.
@@ -32,6 +33,7 @@ def run_check_version_with_mock_claude(
3233
cwd: Working directory
3334
mock_deepwork: If True, create a mock deepwork command that succeeds.
3435
If False, do not create mock deepwork (simulates not installed).
36+
stdin_json: Optional JSON string to pass via stdin (simulates hook input)
3537
3638
Returns:
3739
Tuple of (stdout, stderr, return_code)
@@ -66,6 +68,7 @@ def run_check_version_with_mock_claude(
6668
text=True,
6769
cwd=cwd or tmpdir,
6870
env=env,
71+
input=stdin_json,
6972
)
7073

7174
return result.stdout, result.stderr, result.returncode
@@ -290,3 +293,120 @@ def test_deepwork_check_happens_before_version_check(self, check_version_script:
290293
assert "DEEPWORK NOT INSTALLED" in stderr
291294
# Should NOT show version warning
292295
assert "CLAUDE CODE VERSION WARNING" not in stderr
296+
297+
298+
class TestSessionSourceDetection:
299+
"""Tests for skipping non-initial sessions based on source field."""
300+
301+
def test_startup_source_runs_normally(self, check_version_script: Path) -> None:
302+
"""Test that source='startup' runs the full check."""
303+
import json
304+
305+
stdin_json = json.dumps({"source": "startup", "session_id": "test123"})
306+
stdout, stderr, code = run_check_version_with_mock_claude(
307+
check_version_script, "3.0.0", stdin_json=stdin_json
308+
)
309+
310+
# Should run normally and output empty JSON (version OK)
311+
assert code == 0
312+
assert stdout.strip() == "{}"
313+
314+
def test_resume_source_skips_check(self, check_version_script: Path) -> None:
315+
"""Test that source='resume' skips all checks and returns empty JSON."""
316+
import json
317+
318+
stdin_json = json.dumps({"source": "resume", "session_id": "test123"})
319+
stdout, stderr, code = run_check_version_with_mock_claude(
320+
check_version_script,
321+
"1.0.0",
322+
stdin_json=stdin_json, # Low version that would trigger warning
323+
)
324+
325+
# Should skip and return empty JSON without warnings
326+
assert code == 0
327+
assert stdout.strip() == "{}"
328+
assert "WARNING" not in stderr
329+
assert "DEEPWORK" not in stderr
330+
331+
def test_clear_source_skips_check(self, check_version_script: Path) -> None:
332+
"""Test that source='clear' (compact) skips all checks."""
333+
import json
334+
335+
stdin_json = json.dumps({"source": "clear", "session_id": "test123"})
336+
stdout, stderr, code = run_check_version_with_mock_claude(
337+
check_version_script, "1.0.0", stdin_json=stdin_json
338+
)
339+
340+
# Should skip and return empty JSON
341+
assert code == 0
342+
assert stdout.strip() == "{}"
343+
assert "WARNING" not in stderr
344+
345+
def test_no_source_field_runs_normally(self, check_version_script: Path) -> None:
346+
"""Test backwards compatibility: missing source field runs full check."""
347+
import json
348+
349+
# JSON without source field (older Claude Code version)
350+
stdin_json = json.dumps({"session_id": "test123"})
351+
stdout, stderr, code = run_check_version_with_mock_claude(
352+
check_version_script,
353+
"2.0.0",
354+
stdin_json=stdin_json, # Low version
355+
)
356+
357+
# Should run normally and show warning (backwards compat)
358+
assert code == 0
359+
assert "WARNING" in stderr
360+
361+
def test_empty_stdin_runs_normally(self, check_version_script: Path) -> None:
362+
"""Test that empty stdin runs full check (backwards compat)."""
363+
stdout, stderr, code = run_check_version_with_mock_claude(
364+
check_version_script, "2.0.0", stdin_json=""
365+
)
366+
367+
# Should run normally and show warning
368+
assert code == 0
369+
assert "WARNING" in stderr
370+
371+
def test_resume_skips_even_with_missing_deepwork(self, check_version_script: Path) -> None:
372+
"""Test that resume sessions skip before deepwork check."""
373+
import json
374+
375+
stdin_json = json.dumps({"source": "resume"})
376+
stdout, stderr, code = run_check_version_with_mock_claude(
377+
check_version_script, "3.0.0", mock_deepwork=False, stdin_json=stdin_json
378+
)
379+
380+
# Should skip immediately, NOT block on deepwork
381+
assert code == 0
382+
assert stdout.strip() == "{}"
383+
assert "DEEPWORK NOT INSTALLED" not in stderr
384+
385+
def test_startup_with_low_version_shows_warning(self, check_version_script: Path) -> None:
386+
"""Test that startup sessions with low version show warning."""
387+
import json
388+
389+
stdin_json = json.dumps({"source": "startup"})
390+
stdout, stderr, code = run_check_version_with_mock_claude(
391+
check_version_script, "2.0.0", stdin_json=stdin_json
392+
)
393+
394+
# Should run full check and show warning
395+
assert code == 0
396+
assert "WARNING" in stderr
397+
assert "hookSpecificOutput" in stdout
398+
399+
def test_unknown_source_skips_check(self, check_version_script: Path) -> None:
400+
"""Test that unknown source values skip the check."""
401+
import json
402+
403+
# Future-proofing: unknown source values should be treated as non-startup
404+
stdin_json = json.dumps({"source": "unknown_future_value"})
405+
stdout, stderr, code = run_check_version_with_mock_claude(
406+
check_version_script, "1.0.0", stdin_json=stdin_json
407+
)
408+
409+
# Should skip and return empty JSON
410+
assert code == 0
411+
assert stdout.strip() == "{}"
412+
assert "WARNING" not in stderr

0 commit comments

Comments
 (0)