Skip to content

Commit 2abc36d

Browse files
steveyeggeseanbeardenclaude
committed
fix(nudge): deliver queued nudges via hook trigger, not raw tmux send-keys
Format nudge messages as <system-reminder> blocks so agents process them as background notifications rather than user interruptions. Add watchAndDeliver idle poller to drain queued nudges when agent becomes idle (UserPromptSubmit hook does not fire for idle agents). Closes #2497 Co-Authored-By: Sean Bearden <72461227+seanbearden@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fcb8f0e commit 2abc36d

3 files changed

Lines changed: 217 additions & 4 deletions

File tree

internal/cmd/nudge.go

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,18 @@ const ifFreshMaxAge = 60 * time.Second
124124
// This is a var (not const) so tests can override it to avoid 15s waits.
125125
var waitIdleTimeout = 15 * time.Second
126126

127+
// idleWatcherTimeout is how long the background idle watcher polls after
128+
// queuing a nudge. If the agent becomes idle within this window, the watcher
129+
// drains the queue and delivers directly. This covers the gap where an agent
130+
// finishes work after WaitForIdle's timeout but before anyone sends new input
131+
// (so UserPromptSubmit never fires and the queue never drains).
132+
// Var so tests can override.
133+
var idleWatcherTimeout = 60 * time.Second
134+
135+
// idleWatcherPollInterval is how often the background watcher checks for idle.
136+
// Var so tests can override.
137+
var idleWatcherPollInterval = 1 * time.Second
138+
127139
// deliverNudge routes a nudge based on the --mode flag.
128140
// For "immediate" mode: sends directly via tmux (current behavior).
129141
// For "queue" mode: writes to the nudge queue for cooperative delivery.
@@ -156,8 +168,15 @@ func deliverNudge(t *tmux.Tmux, sessionName, message, sender string) error {
156168
// Try to wait for idle
157169
err := t.WaitForIdle(sessionName, waitIdleTimeout)
158170
if err == nil {
159-
// Agent is idle — safe to deliver directly
160-
return t.NudgeSession(sessionName, prefixedMessage)
171+
// Agent is idle — deliver directly. Format as system-reminder
172+
// so the agent processes it as a background notification rather
173+
// than a user interruption/correction.
174+
formatted := nudge.FormatForInjection([]nudge.QueuedNudge{{
175+
Sender: sender,
176+
Message: message,
177+
Priority: nudgePriorityFlag,
178+
}})
179+
return t.NudgeSession(sessionName, formatted)
161180
}
162181
// Terminal errors (session gone, no server) — propagate, don't queue.
163182
// Queueing a nudge for a dead session means it will never be delivered.
@@ -173,15 +192,82 @@ func deliverNudge(t *tmux.Tmux, sessionName, message, sender string) error {
173192
// Queue failed — fall back to immediate as last resort.
174193
// Better to interrupt than lose the message entirely.
175194
fmt.Fprintf(os.Stderr, "Warning: queue fallback failed (%v), delivering immediately\n", qErr)
176-
return t.NudgeSession(sessionName, prefixedMessage)
177-
}
195+
// Still use FormatForInjection so the agent sees a consistent
196+
// <system-reminder> format regardless of delivery path.
197+
formatted := nudge.FormatForInjection([]nudge.QueuedNudge{{
198+
Sender: sender,
199+
Message: message,
200+
Priority: nudgePriorityFlag,
201+
}})
202+
return t.NudgeSession(sessionName, formatted)
203+
}
204+
// Run watcher synchronously: polls for idle over a longer window.
205+
// The UserPromptSubmit hook drains the queue on agent input, but an
206+
// idle agent receives no input — so queued nudges are lost without
207+
// this watcher. It exits on: delivery, session death, or timeout.
208+
// Must be synchronous (not a goroutine) because gt nudge is a CLI
209+
// command — the process exits after return, killing any goroutines.
210+
watchAndDeliver(t, townRoot, sessionName)
178211
return nil
179212

180213
default: // NudgeModeImmediate
181214
return t.NudgeSession(sessionName, prefixedMessage)
182215
}
183216
}
184217

218+
// watchAndDeliver polls a session for idle state over idleWatcherTimeout.
219+
// When the agent becomes idle, it drains the nudge queue and sends the
220+
// formatted content directly via NudgeSession. This bypasses the
221+
// UserPromptSubmit hook entirely — that hook does not fire for tmux
222+
// send-keys input, so we cannot rely on it.
223+
//
224+
// This runs synchronously — gt nudge blocks until the watcher exits.
225+
// Errors are logged to stderr rather than returned since delivery failure
226+
// after successful queue write is non-fatal (queue persists for next drain).
227+
//
228+
// Exit conditions:
229+
// - Agent becomes idle: drain queue and deliver formatted content, exit.
230+
// - Queue is empty (someone else drained it): exit.
231+
// - Session disappears: exit (nothing to deliver to).
232+
// - Timeout: exit (queue stays for next input or watcher cycle).
233+
func watchAndDeliver(t *tmux.Tmux, townRoot, sessionName string) {
234+
fmt.Fprintf(os.Stderr, "Watching %s for idle (up to %s)...\n", sessionName, idleWatcherTimeout)
235+
deadline := time.Now().Add(idleWatcherTimeout)
236+
for time.Now().Before(deadline) {
237+
time.Sleep(idleWatcherPollInterval)
238+
239+
// If queue is already empty, someone else drained it.
240+
if nudge.QueueLen(townRoot, sessionName) == 0 {
241+
return
242+
}
243+
244+
// Check if session still exists — no point watching a dead session.
245+
if exists, _ := t.HasSession(sessionName); !exists {
246+
return
247+
}
248+
249+
// Use WaitForIdle with a short timeout instead of single-snapshot
250+
// IsIdle to get the consecutive-poll guard (2 polls 200ms apart).
251+
// This avoids false positives during inter-tool-call gaps where
252+
// the prompt briefly appears while Claude Code is still working.
253+
if err := t.WaitForIdle(sessionName, idleWatcherPollInterval); err == nil {
254+
// Drain atomically claims queued entries (rename-based).
255+
// If another process raced and drained first, we get an
256+
// empty slice and skip delivery to avoid duplicates.
257+
drained, _ := nudge.Drain(townRoot, sessionName)
258+
if len(drained) == 0 {
259+
return
260+
}
261+
formatted := nudge.FormatForInjection(drained)
262+
if err := t.NudgeSession(sessionName, formatted); err != nil {
263+
fmt.Fprintf(os.Stderr, "idle-watcher: delivery for %s failed: %v\n", sessionName, err)
264+
}
265+
return
266+
}
267+
}
268+
// Timeout — nudge stays in queue for next watcher or manual drain.
269+
}
270+
185271
// validNudgeModes is the set of allowed --mode values.
186272
var validNudgeModes = map[string]bool{
187273
NudgeModeImmediate: true,

internal/cmd/nudge_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,38 @@ func TestIfFreshSessionAgeCheck(t *testing.T) {
375375
}
376376
}
377377

378+
func TestPostQueueIdleRecovery_SkipsDeliveryWhenDrainEmpty(t *testing.T) {
379+
// Behavioral test (gt-y2zk): when the idle recovery path fires but
380+
// another process already drained the queue, we must NOT deliver to
381+
// avoid duplicates. This exercises the len(drained) > 0 guard.
382+
townRoot := t.TempDir()
383+
session := "gt-crew-test"
384+
385+
// Enqueue a nudge, then drain it (simulating a racing hook).
386+
if err := nudge.Enqueue(townRoot, session, nudge.QueuedNudge{
387+
Sender: "test",
388+
Message: "hello",
389+
}); err != nil {
390+
t.Fatalf("Enqueue: %v", err)
391+
}
392+
drained, err := nudge.Drain(townRoot, session)
393+
if err != nil {
394+
t.Fatalf("first Drain: %v", err)
395+
}
396+
if len(drained) != 1 {
397+
t.Fatalf("first Drain got %d entries, want 1", len(drained))
398+
}
399+
400+
// Second drain should return empty — the racing hook already claimed it.
401+
drained2, err := nudge.Drain(townRoot, session)
402+
if err != nil {
403+
t.Fatalf("second Drain: %v", err)
404+
}
405+
if len(drained2) != 0 {
406+
t.Errorf("second Drain got %d entries, want 0 (already claimed)", len(drained2))
407+
}
408+
}
409+
378410
func TestValidModeMapsMatchConstants(t *testing.T) {
379411
// Ensure the validation maps cover all defined mode constants.
380412
modes := []string{NudgeModeImmediate, NudgeModeQueue, NudgeModeWaitIdle}
@@ -390,3 +422,86 @@ func TestValidModeMapsMatchConstants(t *testing.T) {
390422
}
391423
}
392424
}
425+
426+
func TestIdleWatcherTimeout(t *testing.T) {
427+
// Verify the watcher timeout is in a reasonable range.
428+
if idleWatcherTimeout < 10*time.Second {
429+
t.Errorf("idleWatcherTimeout = %v, too short (min 10s)", idleWatcherTimeout)
430+
}
431+
if idleWatcherTimeout > 5*time.Minute {
432+
t.Errorf("idleWatcherTimeout = %v, too long (max 5m)", idleWatcherTimeout)
433+
}
434+
}
435+
436+
func TestIdleWatcherPollInterval(t *testing.T) {
437+
// Verify the poll interval is reasonable — fast enough to be responsive,
438+
// slow enough to not burn CPU.
439+
if idleWatcherPollInterval < 200*time.Millisecond {
440+
t.Errorf("idleWatcherPollInterval = %v, too fast (min 200ms)", idleWatcherPollInterval)
441+
}
442+
if idleWatcherPollInterval > 5*time.Second {
443+
t.Errorf("idleWatcherPollInterval = %v, too slow (max 5s)", idleWatcherPollInterval)
444+
}
445+
}
446+
447+
func TestIdleWatcherExitsOnEmptyQueue(t *testing.T) {
448+
// watchAndDeliver should exit immediately when queue is empty
449+
// (someone else drained it). We test this by calling with a
450+
// temp dir that has no queue files.
451+
origTimeout := idleWatcherTimeout
452+
origInterval := idleWatcherPollInterval
453+
defer func() {
454+
idleWatcherTimeout = origTimeout
455+
idleWatcherPollInterval = origInterval
456+
}()
457+
458+
// Very short timeout so test doesn't hang
459+
idleWatcherTimeout = 500 * time.Millisecond
460+
idleWatcherPollInterval = 50 * time.Millisecond
461+
462+
tmpDir := t.TempDir()
463+
464+
// watchAndDeliver checks QueueLen first — with no queue files,
465+
// it should exit immediately. We verify it doesn't block.
466+
done := make(chan struct{})
467+
go func() {
468+
// Use a nil-safe Tmux — QueueLen returns 0 before IsIdle is called.
469+
watchAndDeliver(nil, tmpDir, "test-session")
470+
close(done)
471+
}()
472+
473+
select {
474+
case <-done:
475+
// Good — exited because queue was empty
476+
case <-time.After(2 * time.Second):
477+
t.Fatal("watchAndDeliver did not exit within 2s for empty queue")
478+
}
479+
}
480+
481+
func TestQueueLen(t *testing.T) {
482+
tmpDir := t.TempDir()
483+
484+
// Empty queue
485+
if got := nudge.QueueLen(tmpDir, "test-session"); got != 0 {
486+
t.Errorf("QueueLen on empty dir = %d, want 0", got)
487+
}
488+
489+
// Enqueue one
490+
err := nudge.Enqueue(tmpDir, "test-session", nudge.QueuedNudge{
491+
Sender: "test",
492+
Message: "hello",
493+
})
494+
if err != nil {
495+
t.Fatalf("Enqueue failed: %v", err)
496+
}
497+
498+
if got := nudge.QueueLen(tmpDir, "test-session"); got != 1 {
499+
t.Errorf("QueueLen after enqueue = %d, want 1", got)
500+
}
501+
502+
// Drain and verify empty
503+
_, _ = nudge.Drain(tmpDir, "test-session")
504+
if got := nudge.QueueLen(tmpDir, "test-session"); got != 0 {
505+
t.Errorf("QueueLen after drain = %d, want 0", got)
506+
}
507+
}

internal/nudge/queue.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,18 @@ func Pending(townRoot, session string) (int, error) {
272272
return count, nil
273273
}
274274

275+
// QueueLen returns the number of pending nudges for a session without draining.
276+
// Returns 0 on error — callers use this for quick checks. Missing queue
277+
// directories are expected (no nudges yet) and silenced; other filesystem
278+
// errors are logged to stderr so they don't go unnoticed.
279+
func QueueLen(townRoot, session string) int {
280+
n, err := Pending(townRoot, session)
281+
if err != nil {
282+
fmt.Fprintf(os.Stderr, "Warning: nudge queue check failed for %s: %v\n", session, err)
283+
}
284+
return n
285+
}
286+
275287
// FormatForInjection formats queued nudges as a system-reminder block
276288
// suitable for Claude Code hook output.
277289
func FormatForInjection(nudges []QueuedNudge) string {

0 commit comments

Comments
 (0)