@@ -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.
125125var 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.
186272var validNudgeModes = map [string ]bool {
187273 NudgeModeImmediate : true ,
0 commit comments