Skip to content

Commit ec9354b

Browse files
committed
More ntfy fixes
1 parent 529d9ef commit ec9354b

8 files changed

Lines changed: 559 additions & 15 deletions

File tree

cmd/claude-code-ntfy/app.go

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,18 +49,34 @@ func NewDependencies(cfg *config.Config) (*Dependencies, error) {
4949
// Start auto-refresh to keep the indicator visible despite Claude's screen clears
5050
deps.StatusIndicator.StartAutoRefresh(deps.stopChan)
5151

52+
// Create pattern matcher and output monitor first (needed for terminal title)
53+
deps.PatternMatcher = monitor.NewSimplePatternMatcher(cfg.Patterns)
54+
outputMonitor := monitor.NewOutputMonitor(cfg, deps.PatternMatcher, deps.IdleDetector, nil) // nil notifier for now
55+
deps.OutputMonitor = outputMonitor
56+
5257
// Create notification components
53-
deps.Notifier = notification.NewNtfyClient(cfg.NtfyServer, cfg.NtfyTopic)
58+
baseNotifier := notification.NewNtfyClient(cfg.NtfyServer, cfg.NtfyTopic)
59+
60+
// Wrap with context notifier
61+
contextNotifier := notification.NewContextNotifier(baseNotifier, func() string {
62+
return outputMonitor.GetTerminalTitle()
63+
})
64+
65+
// Wrap with backstop notifier if configured
66+
var finalNotifier notification.Notifier = contextNotifier
67+
if cfg.BackstopTimeout > 0 {
68+
finalNotifier = notification.NewBackstopNotifier(contextNotifier, cfg.BackstopTimeout)
69+
}
70+
deps.Notifier = finalNotifier
71+
5472
deps.RateLimiter = notification.NewTokenBucketRateLimiter(cfg.RateLimit.MaxMessages, cfg.RateLimit.Window)
5573
deps.NotificationManager = notification.NewManager(cfg, deps.Notifier, deps.RateLimiter)
5674

5775
// Connect status reporter to notification manager
5876
deps.NotificationManager.SetStatusReporter(deps.StatusReporter)
59-
60-
// Create pattern matcher and output monitor
61-
deps.PatternMatcher = monitor.NewSimplePatternMatcher(cfg.Patterns)
62-
outputMonitor := monitor.NewOutputMonitor(cfg, deps.PatternMatcher, deps.IdleDetector, deps.NotificationManager)
63-
deps.OutputMonitor = outputMonitor
77+
78+
// Now set the notification manager on the output monitor
79+
outputMonitor.SetNotifier(deps.NotificationManager)
6480

6581
// Connect status indicator to output monitor for screen clear detection
6682
if statusEnabled {
@@ -95,6 +111,12 @@ func (d *Dependencies) Close() {
95111
_ = d.StatusIndicator.Clear() // Best effort
96112
}
97113

114+
// Close notifiers
115+
// First try to close as backstop notifier
116+
if backstopNotifier, ok := d.Notifier.(*notification.BackstopNotifier); ok {
117+
_ = backstopNotifier.Close()
118+
}
119+
98120
if d.NotificationManager != nil {
99121
_ = d.NotificationManager.Close()
100122
}

pkg/config/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ type Config struct {
2626
DefaultClaudeArgs []string `yaml:"default_claude_args"`
2727
EnableFocusDetection bool `yaml:"enable_focus_detection" env:"CLAUDE_NOTIFY_FOCUS_DETECTION"`
2828

29+
// Backstop notification - send notification after inactivity
30+
BackstopTimeout time.Duration `yaml:"backstop_timeout" env:"CLAUDE_NOTIFY_BACKSTOP_TIMEOUT"`
31+
2932
// Claude path configuration
3033
ClaudePath string `yaml:"claude_path" env:"CLAUDE_NOTIFY_CLAUDE_PATH"`
3134

@@ -71,6 +74,7 @@ func DefaultConfig() *Config {
7174
IdleTimeout: 2 * time.Minute,
7275
StartupGracePeriod: 10 * time.Second,
7376
EnableFocusDetection: true,
77+
BackstopTimeout: 30 * time.Second,
7478
Patterns: []Pattern{
7579
{
7680
Name: "bell",

pkg/monitor/output_monitor.go

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ func (om *OutputMonitor) SetScreenEventHandler(handler interfaces.ScreenEventHan
5656
om.screenEventHandler = handler
5757
}
5858

59+
// SetNotifier sets the notifier
60+
func (om *OutputMonitor) SetNotifier(notifier notification.Notifier) {
61+
om.mu.Lock()
62+
defer om.mu.Unlock()
63+
om.notifier = notifier
64+
}
65+
5966
// HandleData processes raw output data
6067
func (om *OutputMonitor) HandleData(data []byte) {
6168
// Detect terminal sequences before locking (non-blocking operation)
@@ -69,6 +76,11 @@ func (om *OutputMonitor) HandleData(data []byte) {
6976
// Update last output time
7077
om.lastOutputTime = time.Now()
7178

79+
// Notify activity marker if we have one
80+
if marker, ok := om.notifier.(notification.ActivityMarker); ok {
81+
marker.MarkActivity()
82+
}
83+
7284
// Add data to line buffer
7385
om.lineBuffer.Write(data)
7486

@@ -117,14 +129,8 @@ func (om *OutputMonitor) processLine(line string) {
117129
if om.shouldNotify() {
118130
// Create notifications for each match
119131
for _, match := range matches {
120-
// Include terminal title in notification if available
121-
title := "Claude Code Match: " + match.PatternName
122-
if termTitle := om.terminalState.GetTitle(); termTitle != "" {
123-
title = "Claude Code [" + termTitle + "]: " + match.PatternName
124-
}
125-
126132
n := notification.Notification{
127-
Title: title,
133+
Title: "Claude Code: " + match.PatternName,
128134
Message: line,
129135
Time: time.Now(),
130136
Pattern: match.PatternName,
@@ -248,3 +254,18 @@ func (om *OutputMonitor) ResetStartTime() {
248254
defer om.mu.Unlock()
249255
om.startTime = time.Now()
250256
}
257+
258+
// LastOutputTime returns the time of the last output
259+
func (om *OutputMonitor) LastOutputTime() time.Time {
260+
om.mu.Lock()
261+
defer om.mu.Unlock()
262+
return om.lastOutputTime
263+
}
264+
265+
// GetTerminalTitle returns the current terminal title
266+
func (om *OutputMonitor) GetTerminalTitle() string {
267+
if om.terminalState != nil {
268+
return om.terminalState.GetTitle()
269+
}
270+
return ""
271+
}

pkg/monitor/output_monitor_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -525,8 +525,8 @@ func TestOutputMonitorTerminalTitleInNotifications(t *testing.T) {
525525
t.Fatalf("expected 1 notification, got %d", len(notifs))
526526
}
527527

528-
// Verify title includes terminal title
529-
expectedTitle := "Claude Code [My Task Title]: test"
528+
// Verify title is simple (context is added by ContextNotifier now)
529+
expectedTitle := "Claude Code: test"
530530
if notifs[0].Title != expectedTitle {
531531
t.Errorf("expected title %q, got %q", expectedTitle, notifs[0].Title)
532532
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package notification
2+
3+
import (
4+
"sync"
5+
"time"
6+
)
7+
8+
// ActivityMarker is an interface for marking activity
9+
type ActivityMarker interface {
10+
MarkActivity()
11+
}
12+
13+
// BackstopNotifier wraps another notifier and sends a notification after inactivity
14+
type BackstopNotifier struct {
15+
underlying Notifier
16+
timeout time.Duration
17+
18+
mu sync.Mutex
19+
lastNotificationTime time.Time
20+
lastActivityTime time.Time
21+
timer *time.Timer
22+
}
23+
24+
// NewBackstopNotifier creates a new backstop notifier
25+
func NewBackstopNotifier(underlying Notifier, timeout time.Duration) *BackstopNotifier {
26+
bn := &BackstopNotifier{
27+
underlying: underlying,
28+
timeout: timeout,
29+
lastActivityTime: time.Now(),
30+
}
31+
32+
if timeout > 0 {
33+
bn.startTimer()
34+
}
35+
36+
return bn
37+
}
38+
39+
// Send implements the Notifier interface
40+
func (bn *BackstopNotifier) Send(notification Notification) error {
41+
bn.mu.Lock()
42+
defer bn.mu.Unlock()
43+
44+
// Reset activity time
45+
bn.lastActivityTime = time.Now()
46+
bn.lastNotificationTime = time.Now()
47+
48+
// Reset the timer
49+
if bn.timer != nil {
50+
bn.timer.Stop()
51+
}
52+
// Always restart timer after a notification
53+
if bn.timeout > 0 {
54+
bn.timer = time.AfterFunc(bn.timeout, bn.sendBackstopNotification)
55+
}
56+
57+
// Forward to underlying notifier
58+
return bn.underlying.Send(notification)
59+
}
60+
61+
// MarkActivity marks that there was activity (output) without sending a notification
62+
func (bn *BackstopNotifier) MarkActivity() {
63+
bn.mu.Lock()
64+
defer bn.mu.Unlock()
65+
66+
bn.lastActivityTime = time.Now()
67+
68+
// Reset the timer
69+
if bn.timer != nil {
70+
bn.timer.Stop()
71+
}
72+
// Always restart timer after activity
73+
if bn.timeout > 0 {
74+
bn.timer = time.AfterFunc(bn.timeout, bn.sendBackstopNotification)
75+
}
76+
}
77+
78+
// sendBackstopNotification sends a notification after inactivity
79+
func (bn *BackstopNotifier) sendBackstopNotification() {
80+
bn.mu.Lock()
81+
defer bn.mu.Unlock()
82+
83+
// Send backstop notification
84+
notification := Notification{
85+
Title: "Claude Code: Inactive",
86+
Message: "No activity detected - task may be complete",
87+
Time: time.Now(),
88+
Pattern: "backstop",
89+
}
90+
91+
bn.lastNotificationTime = time.Now()
92+
93+
// Send via underlying notifier
94+
_ = bn.underlying.Send(notification)
95+
96+
// Restart timer for next backstop
97+
if bn.timeout > 0 {
98+
bn.timer = time.AfterFunc(bn.timeout, bn.sendBackstopNotification)
99+
}
100+
}
101+
102+
// startTimer starts the initial timer
103+
func (bn *BackstopNotifier) startTimer() {
104+
bn.mu.Lock()
105+
defer bn.mu.Unlock()
106+
107+
if bn.timeout > 0 {
108+
bn.timer = time.AfterFunc(bn.timeout, bn.sendBackstopNotification)
109+
}
110+
}
111+
112+
// Close stops the timer
113+
func (bn *BackstopNotifier) Close() error {
114+
bn.mu.Lock()
115+
defer bn.mu.Unlock()
116+
117+
if bn.timer != nil {
118+
bn.timer.Stop()
119+
}
120+
121+
return nil
122+
}

0 commit comments

Comments
 (0)