Skip to content

Commit d663b48

Browse files
sgx-labsclaude
andcommitted
Debounce Stop hooks to prevent duplicate artifacts per turn
Claude Code fires Stop on every assistant turn, not just session end. Without debouncing, handoff/decision/feedback hooks re-parse the full transcript and create duplicate artifacts every response. 5-minute cooldown per hook per session via session_state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 14a2d79 commit d663b48

3 files changed

Lines changed: 44 additions & 2 deletions

File tree

internal/hooks/decision_extractor.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import (
1010
)
1111

1212
// runDecisionExtractor reads the transcript, extracts decisions, and appends to the log.
13-
func runDecisionExtractor(_ *store.DB, input *HookInput) *HookOutput {
13+
func runDecisionExtractor(db *store.DB, input *HookInput) *HookOutput {
14+
if stopHookDebounce(db, input.SessionID, "decision-extractor") {
15+
return nil
16+
}
17+
1418
transcriptPath := input.TranscriptPath
1519
if transcriptPath == "" {
1620
writeVerboseLog("decision-extractor: no transcript path provided\n")

internal/hooks/feedback.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import (
1414
// This closes the learning loop: notes that get used rise in confidence,
1515
// notes that are surfaced but ignored gradually decay.
1616
func runFeedbackLoop(db *store.DB, input *HookInput) *HookOutput {
17+
if stopHookDebounce(db, input.SessionID, "feedback-loop") {
18+
return nil
19+
}
20+
1721
if input.TranscriptPath == "" || input.SessionID == "" {
1822
writeVerboseLog("feedback-loop: no transcript path or session ID provided\n")
1923
return nil

internal/hooks/handoff_generator.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,47 @@ package hooks
33
import (
44
"fmt"
55
"os"
6+
"strconv"
7+
"time"
68

79
"github.com/sgx-labs/statelessagent/internal/memory"
810
"github.com/sgx-labs/statelessagent/internal/store"
911
)
1012

13+
// stopHookCooldown is the minimum seconds between successive runs of each
14+
// Stop hook within the same session. Claude Code fires the Stop event on
15+
// every assistant turn, not just session end — without debouncing, hooks
16+
// would re-parse the transcript and create duplicate artifacts every turn.
17+
const stopHookCooldown = 300 // 5 minutes
18+
19+
// stopHookDebounce checks whether a Stop hook ran recently for this session.
20+
// Returns true if the hook should be skipped (still within cooldown).
21+
// On first run or after cooldown expires, returns false and records the timestamp.
22+
func stopHookDebounce(db *store.DB, sessionID, hookName string) bool {
23+
if sessionID == "" {
24+
return false // no session tracking possible, let it run
25+
}
26+
key := "stop_cooldown_" + hookName
27+
if last, ok := db.SessionStateGet(sessionID, key); ok {
28+
if ts, err := strconv.ParseInt(last, 10, 64); err == nil {
29+
if time.Now().Unix()-ts < stopHookCooldown {
30+
writeVerboseLog(fmt.Sprintf("%s: skipped (cooldown, last ran %ds ago)\n",
31+
hookName, time.Now().Unix()-ts))
32+
return true
33+
}
34+
}
35+
}
36+
// Record this run
37+
_ = db.SessionStateSet(sessionID, key, strconv.FormatInt(time.Now().Unix(), 10))
38+
return false
39+
}
40+
1141
// runHandoffGenerator generates a handoff note from the transcript.
12-
func runHandoffGenerator(_ *store.DB, input *HookInput) *HookOutput {
42+
func runHandoffGenerator(db *store.DB, input *HookInput) *HookOutput {
43+
if stopHookDebounce(db, input.SessionID, "handoff-generator") {
44+
return nil
45+
}
46+
1347
transcriptPath := input.TranscriptPath
1448
if transcriptPath == "" {
1549
writeVerboseLog("handoff-generator: no transcript path provided\n")

0 commit comments

Comments
 (0)