Skip to content

Commit 455b6ed

Browse files
gtrrz-victorclaude
andcommitted
feat(claudecode): set a 60s timeout on the local-dev SessionEnd hook
The local-dev launcher (scripts/entire-dev) builds the CLI from source, so the SessionEnd hook can exceed Claude Code's short default exit-grace and get cancelled before it runs. An explicit timeout makes Claude Code wait for it. Scoped to local-dev SessionEnd only: production hooks and all other hooks keep Claude Code's default. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 01KY9SWD2PDPSHCK821DBQ8WXH
1 parent 74f573e commit 455b6ed

3 files changed

Lines changed: 74 additions & 8 deletions

File tree

cmd/entire/cli/agent/claudecode/hooks.go

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ const metadataDenyRule = "Read(./.entire/metadata/**)"
6060
// repository root when it runs hooks.
6161
const localDevHookCmdPrefix = "${CLAUDE_PROJECT_DIR}/scripts/entire-dev "
6262

63+
// localDevSessionEndTimeoutSecs gives the local-dev SessionEnd hook an explicit
64+
// timeout (seconds) so Claude Code waits for it on exit instead of cancelling it
65+
// after its short default exit-grace, which the build-from-source dev launcher
66+
// (scripts/entire-dev) can exceed. Only set in local-dev mode; production leaves
67+
// Claude Code's default in place.
68+
const localDevSessionEndTimeoutSecs = 60
69+
6370
// entireHookPrefixes are command prefixes that identify Entire hooks. The
6471
// "go run" prefix is retained so hooks installed by older versions are still
6572
// recognized for removal/upgrade.
@@ -168,33 +175,41 @@ func (c *ClaudeCodeAgent) InstallHooks(ctx context.Context, localDev bool, force
168175

169176
count := 0
170177

178+
// The local-dev SessionEnd hook gets an explicit timeout so Claude Code
179+
// waits for it on exit; every other hook (and all production hooks) keeps
180+
// Claude Code's default.
181+
sessionEndTimeoutSecs := 0
182+
if localDev {
183+
sessionEndTimeoutSecs = localDevSessionEndTimeoutSecs
184+
}
185+
171186
// Add hooks if they don't exist
172187
if !hookCommandExists(sessionStart, sessionStartCmd) {
173-
sessionStart = addHookToMatcher(sessionStart, "", sessionStartCmd)
188+
sessionStart = addHookToMatcher(sessionStart, "", sessionStartCmd, 0)
174189
count++
175190
}
176191
if !hookCommandExists(sessionEnd, sessionEndCmd) {
177-
sessionEnd = addHookToMatcher(sessionEnd, "", sessionEndCmd)
192+
sessionEnd = addHookToMatcher(sessionEnd, "", sessionEndCmd, sessionEndTimeoutSecs)
178193
count++
179194
}
180195
if !hookCommandExists(stop, stopCmd) {
181-
stop = addHookToMatcher(stop, "", stopCmd)
196+
stop = addHookToMatcher(stop, "", stopCmd, 0)
182197
count++
183198
}
184199
if !hookCommandExists(userPromptSubmit, userPromptSubmitCmd) {
185-
userPromptSubmit = addHookToMatcher(userPromptSubmit, "", userPromptSubmitCmd)
200+
userPromptSubmit = addHookToMatcher(userPromptSubmit, "", userPromptSubmitCmd, 0)
186201
count++
187202
}
188203
if !hookCommandExistsWithMatcher(preToolUse, subagentToolMatcher, preTaskCmd) {
189-
preToolUse = addHookToMatcher(preToolUse, subagentToolMatcher, preTaskCmd)
204+
preToolUse = addHookToMatcher(preToolUse, subagentToolMatcher, preTaskCmd, 0)
190205
count++
191206
}
192207
if !hookCommandExistsWithMatcher(postToolUse, subagentToolMatcher, postTaskCmd) {
193-
postToolUse = addHookToMatcher(postToolUse, subagentToolMatcher, postTaskCmd)
208+
postToolUse = addHookToMatcher(postToolUse, subagentToolMatcher, postTaskCmd, 0)
194209
count++
195210
}
196211
if !hookCommandExistsWithMatcher(postToolUse, taskToolMatcher, postTodoCmd) {
197-
postToolUse = addHookToMatcher(postToolUse, taskToolMatcher, postTodoCmd)
212+
postToolUse = addHookToMatcher(postToolUse, taskToolMatcher, postTodoCmd, 0)
198213
count++
199214
}
200215

@@ -545,10 +560,11 @@ func hookCommandExistsWithMatcher(matchers []ClaudeHookMatcher, matcherName, com
545560
return false
546561
}
547562

548-
func addHookToMatcher(matchers []ClaudeHookMatcher, matcherName, command string) []ClaudeHookMatcher {
563+
func addHookToMatcher(matchers []ClaudeHookMatcher, matcherName, command string, timeoutSecs int) []ClaudeHookMatcher {
549564
entry := ClaudeHookEntry{
550565
Type: "command",
551566
Command: command,
567+
Timeout: timeoutSecs,
552568
}
553569

554570
// If no matcher name, add to a matcher with empty string

cmd/entire/cli/agent/claudecode/hooks_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,53 @@ func TestUninstallHooks_RemovesLocalDevHooks(t *testing.T) {
467467
}
468468
}
469469

470+
// TestInstallHooks_LocalDevSessionEndTimeout verifies the local-dev SessionEnd
471+
// hook carries an explicit timeout so Claude Code waits for it on exit instead
472+
// of cancelling it after its short default exit-grace. The timeout is scoped to
473+
// local-dev SessionEnd only: production and other local-dev hooks stay untimed.
474+
func TestInstallHooks_LocalDevSessionEndTimeout(t *testing.T) {
475+
t.Run("local-dev SessionEnd gets the timeout", func(t *testing.T) {
476+
tempDir := t.TempDir()
477+
t.Chdir(tempDir)
478+
479+
agent := &ClaudeCodeAgent{}
480+
if _, err := agent.InstallHooks(context.Background(), true, false); err != nil {
481+
t.Fatalf("InstallHooks(localDev=true) error = %v", err)
482+
}
483+
484+
settings := readClaudeSettings(t, tempDir)
485+
if len(settings.Hooks.SessionEnd) == 0 || len(settings.Hooks.SessionEnd[0].Hooks) == 0 {
486+
t.Fatal("expected a SessionEnd hook to be installed")
487+
}
488+
if got := settings.Hooks.SessionEnd[0].Hooks[0].Timeout; got != localDevSessionEndTimeoutSecs {
489+
t.Errorf("local-dev SessionEnd timeout = %d, want %d", got, localDevSessionEndTimeoutSecs)
490+
}
491+
492+
// Scoping: other local-dev hooks must not inherit the timeout.
493+
if got := settings.Hooks.Stop[0].Hooks[0].Timeout; got != 0 {
494+
t.Errorf("local-dev Stop timeout = %d, want 0 (timeout is SessionEnd-only)", got)
495+
}
496+
})
497+
498+
t.Run("production SessionEnd stays untimed", func(t *testing.T) {
499+
tempDir := t.TempDir()
500+
t.Chdir(tempDir)
501+
502+
agent := &ClaudeCodeAgent{}
503+
if _, err := agent.InstallHooks(context.Background(), false, false); err != nil {
504+
t.Fatalf("InstallHooks(localDev=false) error = %v", err)
505+
}
506+
507+
settings := readClaudeSettings(t, tempDir)
508+
if len(settings.Hooks.SessionEnd) == 0 || len(settings.Hooks.SessionEnd[0].Hooks) == 0 {
509+
t.Fatal("expected a SessionEnd hook to be installed")
510+
}
511+
if got := settings.Hooks.SessionEnd[0].Hooks[0].Timeout; got != 0 {
512+
t.Errorf("production SessionEnd timeout = %d, want 0 (dev-only)", got)
513+
}
514+
})
515+
}
516+
470517
// readClaudeSettings reads and parses the Claude Code settings file
471518
func readClaudeSettings(t *testing.T, tempDir string) ClaudeSettings {
472519
t.Helper()

cmd/entire/cli/agent/claudecode/types.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ type ClaudeHookMatcher struct {
2727
type ClaudeHookEntry struct {
2828
Type string `json:"type"`
2929
Command string `json:"command"`
30+
// Timeout is the hook's timeout in seconds. Omitted (0) leaves Claude Code's
31+
// default in place; set only where a hook needs an explicit budget.
32+
Timeout int `json:"timeout,omitempty"`
3033
}
3134

3235
// sessionInfoRaw is the JSON structure from SessionStart/SessionEnd/Stop hooks.

0 commit comments

Comments
 (0)