diff --git a/src/config.rs b/src/config.rs index 2f4c0c6..865b612 100644 --- a/src/config.rs +++ b/src/config.rs @@ -287,6 +287,15 @@ pub struct TmuxSessionMonitor { pub channel: Option, pub mention: Option, pub format: Option, + #[serde(default)] + pub detect_waiting: bool, + /// Cooldown minutes between waiting-for-input alerts. 0 = no cooldown. + #[serde(default)] + pub waiting_interval: u64, + /// Event kinds that trigger the @mention. Empty = mention applies to all events. + /// Valid values: "keyword", "waiting_for_input", "content_changed", "stale", "heartbeat". + #[serde(default)] + pub mention_on: Vec, } impl Default for TmuxSessionMonitor { @@ -299,6 +308,9 @@ impl Default for TmuxSessionMonitor { channel: None, mention: None, format: None, + detect_waiting: false, + waiting_interval: 0, + mention_on: Vec::new(), } } } diff --git a/src/daemon.rs b/src/daemon.rs index a99eac5..6e352af 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -734,6 +734,7 @@ mod tests { name: Some("codex".into()), }), active_wrapper_monitor: true, + ..Default::default() }, ); let state = AppState { diff --git a/src/discord.rs b/src/discord.rs index 589c0f2..e02423d 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -113,7 +113,9 @@ impl DiscordClient { return Ok(()); } Err(error) => { - self.record_failure(&key); + if is_infrastructure_failure(&error) { + self.record_failure(&key); + } if let Some(retry_after) = error.retry_after && attempt < MAX_ATTEMPTS { @@ -270,6 +272,18 @@ impl DiscordClient { } } +/// Returns true only for infrastructure failures (5xx, network/connection errors). +/// Discord API policy rejections (4xx: 404, 400/30046, 401, 429) are NOT infrastructure +/// failures and should not open the circuit breaker — they are handled at each call site. +fn is_infrastructure_failure(error: &DiscordSendError) -> bool { + let m = &error.message; + m.contains("500 ") + || m.contains("502 ") + || m.contains("503 ") + || m.contains("504 ") + || (m.contains("failed:") && !m.contains("failed with")) +} + fn parse_retry_after(status: StatusCode, body: &str) -> Option { if status != StatusCode::TOO_MANY_REQUESTS { return None; diff --git a/src/dispatch.rs b/src/dispatch.rs index 98a9277..ec4041d 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -688,6 +688,7 @@ fn should_bypass_routine_batch(event: &IncomingEvent) -> bool { kind.ends_with(".failed") || kind.ends_with(".blocked") || kind == "tmux.stale" + || kind == "tmux.session_ended" || kind.starts_with("github.ci-") } diff --git a/src/events.rs b/src/events.rs index 420e80d..5eb5f14 100644 --- a/src/events.rs +++ b/src/events.rs @@ -605,6 +605,27 @@ impl IncomingEvent { } } + /// Session is waiting for user input. + pub fn tmux_waiting_for_input( + session: String, + pane_name: String, + prompt_snapshot: String, + channel: Option, + ) -> Self { + Self { + kind: "tmux.waiting_for_input".to_string(), + channel, + mention: None, + format: None, + template: None, + payload: json!({ + "session": session, + "pane": pane_name, + "prompt_snapshot": prompt_snapshot, + }), + } + } + pub fn with_mention(mut self, mention: Option) -> Self { self.mention = mention; self diff --git a/src/main.rs b/src/main.rs index 6f0e423..98fd35e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -399,6 +399,7 @@ mod tests { name: Some("codex".into()), }), active_wrapper_monitor: true, + ..Default::default() }]); assert!(output.contains( diff --git a/src/render/default.rs b/src/render/default.rs index 2052082..28b1013 100644 --- a/src/render/default.rs +++ b/src/render/default.rs @@ -303,6 +303,26 @@ impl Renderer for DefaultRenderer { ), ("tmux.stale", MessageFormat::Raw) => serde_json::to_string_pretty(payload)?, + ("tmux.waiting_for_input", MessageFormat::Compact) => { + let session = string_field(payload, "session")?; + let prompt = optional_string_field(payload, "prompt_snapshot") + .unwrap_or_else(|| "no prompt".to_string()); + format!("ā³ **{session}**:\n```\n{prompt}\n```") + } + ("tmux.waiting_for_input", MessageFormat::Alert) => { + let session = string_field(payload, "session")?; + let prompt = optional_string_field(payload, "prompt_snapshot") + .unwrap_or_else(|| "no prompt".to_string()); + format!("ā³ **{session}**:\n```\n{prompt}\n```") + } + ("tmux.waiting_for_input", MessageFormat::Inline) => { + let session = string_field(payload, "session")?; + format!("ā³ [{session}]") + } + ("tmux.waiting_for_input", MessageFormat::Raw) => { + serde_json::to_string_pretty(payload)? + } + (_, MessageFormat::Raw) => serde_json::to_string_pretty(payload)?, (_, _) => serde_json::to_string(payload)?, }; @@ -965,4 +985,26 @@ mod tests { assert!(rendered.starts_with("🚨")); assert!(rendered.contains("release published")); } + + #[test] + fn renders_tmux_waiting_for_input_formats() { + let renderer = DefaultRenderer; + let event = IncomingEvent::tmux_waiting_for_input( + "issue-24".into(), + "0.1".into(), + "Press enter to continue".into(), + None, + ); + + assert_eq!( + renderer.render(&event, &MessageFormat::Inline).unwrap(), + "ā³ [issue-24]" + ); + assert!( + renderer + .render(&event, &MessageFormat::Compact) + .unwrap() + .starts_with("ā³ **issue-24**") + ); + } } diff --git a/src/router.rs b/src/router.rs index 00b0be5..4f1bf65 100644 --- a/src/router.rs +++ b/src/router.rs @@ -321,6 +321,9 @@ fn route_candidates(kind: &str) -> Vec<&str> { | "session.handoff-needed" => { vec![kind, "session.*"] } + "tmux.waiting_for_input" => { + vec![kind, "tmux.*"] + } other => vec![other], } } diff --git a/src/source/tmux.rs b/src/source/tmux.rs index 84b8521..6f17df7 100644 --- a/src/source/tmux.rs +++ b/src/source/tmux.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; +use serde_json::json; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tokio::process::Command; use tokio::sync::{RwLock, mpsc}; @@ -44,7 +45,7 @@ pub struct ParentProcessInfo { pub name: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct RegisteredTmuxSession { pub session: String, pub channel: Option, @@ -65,6 +66,13 @@ pub struct RegisteredTmuxSession { pub parent_process: Option, #[serde(default)] pub active_wrapper_monitor: bool, + #[serde(default)] + pub detect_waiting: bool, + #[serde(default)] + pub waiting_interval: u64, + /// Event kinds for which the `mention` is prepended. Empty = mention applies to all events. + #[serde(default)] + pub mention_on: Vec, } impl From<&TmuxSessionMonitor> for RegisteredTmuxSession { @@ -82,10 +90,26 @@ impl From<&TmuxSessionMonitor> for RegisteredTmuxSession { registration_source: RegistrationSource::ConfigMonitor, parent_process: None, active_wrapper_monitor: false, + detect_waiting: value.detect_waiting, + waiting_interval: value.waiting_interval, + mention_on: value.mention_on.clone(), } } } +/// Returns the mention string for an event kind, respecting `mention_on` filter. +/// If `mention_on` is empty, the mention applies to all event kinds. +fn effective_mention(registration: &RegisteredTmuxSession, event_kind: &str) -> Option { + if registration.mention_on.is_empty() { + return registration.mention.clone(); + } + if registration.mention_on.iter().any(|k| k == event_kind) { + registration.mention.clone() + } else { + None + } +} + pub struct TmuxSource { config: Arc, registry: SharedTmuxRegistry, @@ -145,6 +169,7 @@ struct TmuxPaneState { last_change: Instant, last_stale_notification: Option, pane_dead: bool, + is_waiting: bool, } #[derive(Default)] @@ -217,6 +242,7 @@ pub async fn monitor_registered_session( last_change: now, last_stale_notification: None, pane_dead: pane.pane_dead, + is_waiting: false, }, ); } @@ -230,6 +256,33 @@ pub async fn monitor_registered_session( ); push_pending_keyword_hits(&mut pending_keyword_hits, now, hits); + if registration.detect_waiting { + let waiting_prompt = is_waiting_for_input(&pane.content); + match (existing.is_waiting, &waiting_prompt) { + (false, Some(prompt)) => { + client + .emit(tmux_waiting_for_input_event( + ®istration, + pane.session.clone(), + pane.pane_name.clone(), + prompt.clone(), + )) + .await?; + } + (true, None) => { + client + .emit(tmux_waiting_resolved_event( + ®istration, + pane.session.clone(), + pane.pane_name.clone(), + )) + .await?; + } + _ => {} + } + existing.is_waiting = waiting_prompt.is_some(); + } + existing.session = pane.session; existing.pane_name = pane.pane_name; existing.content_hash = hash; @@ -371,6 +424,7 @@ async fn poll_tmux( last_change: now, last_stale_notification: None, pane_dead: pane.pane_dead, + is_waiting: false, }, ); None @@ -383,6 +437,30 @@ async fn poll_tmux( &pane.content, ®istration.keywords, ); + if registration.detect_waiting { + let waiting_prompt = is_waiting_for_input(&pane.content); + match (existing.is_waiting, &waiting_prompt) { + (false, Some(prompt)) => { + tx.emit(tmux_waiting_for_input_event( + registration, + session_name.clone(), + pane.pane_name.clone(), + prompt.clone(), + )) + .await?; + } + (true, None) => { + tx.emit(tmux_waiting_resolved_event( + registration, + session_name.clone(), + pane.pane_name.clone(), + )) + .await?; + } + _ => {} + } + existing.is_waiting = waiting_prompt.is_some(); + } existing.pane_name = pane.pane_name; existing.snapshot = pane.content; existing.content_hash = hash; @@ -630,7 +708,7 @@ fn tmux_keyword_event( event .with_routing_metadata(®istration.routing) - .with_mention(registration.mention.clone()) + .with_mention(effective_mention(registration, "keyword")) .with_format(registration.format.clone()) } @@ -648,10 +726,150 @@ fn tmux_stale_event( registration.channel.clone(), ) .with_routing_metadata(®istration.routing) - .with_mention(registration.mention.clone()) + .with_mention(effective_mention(registration, "stale")) .with_format(registration.format.clone()) } +fn tmux_waiting_for_input_event( + registration: &RegisteredTmuxSession, + session: String, + pane: String, + prompt_snapshot: String, +) -> IncomingEvent { + IncomingEvent::tmux_waiting_for_input( + session, + pane, + prompt_snapshot, + registration.channel.clone(), + ) + .with_mention(effective_mention(registration, "waiting_for_input")) + .with_format(registration.format.clone()) +} + +fn tmux_waiting_resolved_event( + registration: &RegisteredTmuxSession, + session: String, + pane_name: String, +) -> IncomingEvent { + let mut event = IncomingEvent::tmux_waiting_for_input( + session, + pane_name, + String::new(), + registration.channel.clone(), + ); + event.payload["resolved"] = json!(true); + event.with_format(registration.format.clone()) +} + +fn is_waiting_for_input(content: &str) -> Option { + // Patterns checked against the last 3 non-empty lines (case-insensitive). + // Covers common interactive prompts and OMC/OMX agent harness approval flows. + let multiline_patterns: &[&str] = &[ + // Generic waiting phrases + "waiting for input", + "awaiting user input", + "press enter", + "hit enter", + "press any key", + // Confirmation prompts + "[y/n]", + "(y/n)", + "[yes/no]", + "(yes/no)", + // Common action confirmations + "proceed?", + "continue?", + "overwrite?", + "replace?", + "do you want to", + // Menu / choice prompts + "enter your choice", + "select an option", + // Claude Code / OMC tool approval patterns + "allow, deny", + "allow this action", + "always allow", + "approve or deny", + "run this tool", + // Generic approval keyword at line start + "approve:", + // Credential prompts (password entry blocks terminal) + "password:", + "passphrase:", + "enter password", + // Common agent/tool confirmation phrases + "want me to", + "shall i", + "should i proceed", + "should i continue", + // Interactive setup confirmations + "is this ok?", + "(ctrl+c to abort)", + // CC permission mode picker indicator + "bypassPermissions", + ]; + + // Take the last 3 non-empty lines, skipping blank padding rows. + let recent: Vec<&str> = content + .lines() + .rev() + .filter(|l| !l.trim().is_empty()) + .take(3) + .collect(); + if recent.is_empty() { + return None; + } + + // recent[0] is the LAST non-empty line (collected with .rev()). + let last_nonempty = recent.first().copied().unwrap_or(""); + let last_trimmed = last_nonempty.trim_end(); + + // If the last line looks like a completed shell prompt (e.g. + // "user@host:~/dir$ "), the session has moved past any interactive + // prompt still visible in prior lines. Used to avoid false-positive + // detections after the user already answered a [y/n] style prompt. + let last_line_is_shell_prompt = { + let t = last_trimmed; + ((t.ends_with('$') || t.ends_with('#') || t.ends_with('%')) && t.contains('@')) + || t == "$" + || t == "#" + || t == "%" + }; + + let snapshot: String = recent.iter().rev().copied().collect::>().join("\n"); + let snapshot_lower = snapshot.to_lowercase(); + + for pattern in multiline_patterns { + if snapshot_lower.contains(pattern) { + // If the match is only in a prior line (not the last line) and the + // last line looks like a completed shell prompt, the input was + // already provided — skip this pattern. + let in_last_line = last_nonempty.to_ascii_lowercase().contains(pattern); + if !in_last_line && last_line_is_shell_prompt { + continue; + } + return Some(snapshot.clone()); + } + } + + // Check the last non-empty line for short interactive-prompt endings. + if last_trimmed.len() <= 40 { + let prompt_suffixes: &[&str] = &["āÆ", "$ ", "% ", "... ", "? "]; + for suffix in prompt_suffixes { + if last_trimmed.ends_with(suffix) || last_trimmed == suffix.trim() { + return Some(snapshot); + } + } + } + + // āÆ at the start of a short line indicates an interactive menu selector + if last_trimmed.starts_with('āÆ') && last_trimmed.len() <= 80 { + return Some(snapshot); + } + + None +} + async fn flush_pending_keyword_hits( pending_keyword_hits: &mut Option, registration: &RegisteredTmuxSession, @@ -870,6 +1088,9 @@ mod tests { registration_source: RegistrationSource::ConfigMonitor, parent_process: None, active_wrapper_monitor: false, + detect_waiting: false, + waiting_interval: 0, + mention_on: Vec::new(), } } @@ -988,6 +1209,7 @@ PR created #7", keyword_window_secs: 30, stale_minutes: 10, format: None, + ..Default::default() }; let registration = RegisteredTmuxSession::from(&monitor); @@ -1018,6 +1240,7 @@ PR created #7", registration_source: RegistrationSource::ConfigMonitor, parent_process: None, active_wrapper_monitor: false, + ..Default::default() }, ), ( @@ -1038,6 +1261,7 @@ PR created #7", name: Some("codex".into()), }), active_wrapper_monitor: true, + ..Default::default() }, ), ( @@ -1055,6 +1279,7 @@ PR created #7", registration_source: RegistrationSource::ConfigMonitor, parent_process: None, active_wrapper_monitor: false, + ..Default::default() }, ), ]); @@ -1076,6 +1301,7 @@ PR created #7", registration_source: RegistrationSource::ConfigMonitor, parent_process: None, active_wrapper_monitor: false, + ..Default::default() }, )]), ); @@ -1386,6 +1612,7 @@ error: failed"; registration_source: RegistrationSource::ConfigMonitor, parent_process: None, active_wrapper_monitor: false, + ..Default::default() }], Some(&available_sessions), ); @@ -1416,6 +1643,7 @@ error: failed"; registration_source: RegistrationSource::ConfigMonitor, parent_process: None, active_wrapper_monitor: false, + ..Default::default() }, RegisteredTmuxSession { session: "omx-*".into(), @@ -1430,6 +1658,7 @@ error: failed"; registration_source: RegistrationSource::ConfigMonitor, parent_process: None, active_wrapper_monitor: false, + ..Default::default() }, ], Some(&available_sessions), @@ -1458,6 +1687,7 @@ error: failed"; registration_source: RegistrationSource::ConfigMonitor, parent_process: None, active_wrapper_monitor: false, + ..Default::default() }, RegisteredTmuxSession { session: "rcc-*".into(), @@ -1472,6 +1702,7 @@ error: failed"; registration_source: RegistrationSource::ConfigMonitor, parent_process: None, active_wrapper_monitor: false, + ..Default::default() }, ], None, @@ -1499,6 +1730,7 @@ error: failed"; registration_source: RegistrationSource::ConfigMonitor, parent_process: None, active_wrapper_monitor: false, + ..Default::default() }, RegisteredTmuxSession { session: "rcc-api".into(), @@ -1513,6 +1745,7 @@ error: failed"; registration_source: RegistrationSource::ConfigMonitor, parent_process: None, active_wrapper_monitor: false, + ..Default::default() }, ], Some(&available_sessions), @@ -1540,6 +1773,7 @@ error: failed"; registration_source: RegistrationSource::ConfigMonitor, parent_process: None, active_wrapper_monitor: false, + ..Default::default() }, RegisteredTmuxSession { session: "rcc-*".into(), @@ -1554,6 +1788,7 @@ error: failed"; registration_source: RegistrationSource::ConfigMonitor, parent_process: None, active_wrapper_monitor: false, + ..Default::default() }, ], Some(&available_sessions), @@ -1586,6 +1821,7 @@ error: failed"; registration_source: RegistrationSource::ConfigMonitor, parent_process: None, active_wrapper_monitor: false, + ..Default::default() }, RegisteredTmuxSession { session: "abc*".into(), @@ -1600,6 +1836,7 @@ error: failed"; registration_source: RegistrationSource::ConfigMonitor, parent_process: None, active_wrapper_monitor: false, + ..Default::default() }, ], Some(&available_sessions), @@ -1622,6 +1859,7 @@ error: failed"; last_change: Instant::now() - Duration::from_secs(3600), last_stale_notification: None, pane_dead: false, + is_waiting: false, }; // stale_minutes=0 should never emit, even after 1 hour idle assert!(!should_emit_stale(&pane, Instant::now(), 0)); @@ -1637,6 +1875,7 @@ error: failed"; last_change: Instant::now() - Duration::from_secs(3600), last_stale_notification: None, pane_dead: false, + is_waiting: false, }; // stale_minutes=1 should emit after 1 hour idle assert!(should_emit_stale(&pane, Instant::now(), 1)); @@ -1652,8 +1891,164 @@ error: failed"; last_change: Instant::now() - Duration::from_secs(3600), last_stale_notification: None, pane_dead: true, + is_waiting: false, }; // Dead pane should never emit stale, even after 1 hour idle assert!(!should_emit_stale(&pane, Instant::now(), 1)); } + + // ── is_waiting_for_input tests ────────────────────────────────────────── + + #[test] + fn waiting_detects_press_enter() { + assert!(is_waiting_for_input("some output\nPress enter to continue:").is_some()); + } + + #[test] + fn waiting_detects_yn_bracket() { + assert!(is_waiting_for_input("Overwrite file? [Y/n]").is_some()); + assert!(is_waiting_for_input("Continue? [y/N]").is_some()); + assert!(is_waiting_for_input("Proceed? (y/n)").is_some()); + } + + #[test] + fn waiting_detects_proceed_continue_overwrite() { + assert!(is_waiting_for_input("Do you want to proceed?").is_some()); + assert!(is_waiting_for_input("continue?").is_some()); + assert!(is_waiting_for_input("overwrite?").is_some()); + } + + #[test] + fn waiting_detects_omc_tool_approval() { + // Claude Code tool approval format + assert!(is_waiting_for_input("Allow, Deny, Always allow (A/d/!)?").is_some()); + assert!(is_waiting_for_input("always allow").is_some()); + assert!(is_waiting_for_input("run this tool").is_some()); + } + + #[test] + fn waiting_detects_menu_prompts() { + assert!(is_waiting_for_input("Enter your choice:").is_some()); + assert!(is_waiting_for_input("Select an option").is_some()); + assert!(is_waiting_for_input("Press any key to continue").is_some()); + } + + #[test] + fn waiting_detects_do_you_want_to() { + assert!(is_waiting_for_input("Do you want to install these packages?").is_some()); + } + + #[test] + fn waiting_ignores_normal_output() { + assert!( + is_waiting_for_input( + "cargo build --release\nCompiling clawhip v0.5.4\nFinished dev profile" + ) + .is_none() + ); + assert!(is_waiting_for_input("").is_none()); + } + + #[test] + fn waiting_only_checks_last_3_lines() { + // Trigger phrase buried beyond the 3-line window should NOT match. + // 4 non-empty filler lines push "press enter" out of the window. + let buried = "press enter\n".to_string() + &"line\n".repeat(4); + assert!(is_waiting_for_input(&buried).is_none()); + // Trailing blank lines are ignored, so this still doesn't match + let buried_with_blanks = + "press enter\n".to_string() + &"line\n".repeat(4) + &"\n".repeat(20); + assert!(is_waiting_for_input(&buried_with_blanks).is_none()); + // Pattern at exactly position 3 (last of window) still matches + let at_edge = "line\n".repeat(2) + "press enter\n"; + assert!(is_waiting_for_input(&at_edge).is_some()); + } + + #[test] + fn waiting_detects_omc_cursor_prompt() { + // Short last line ending with āÆ (OMC tool approval cursor) + assert!(is_waiting_for_input("Allow, Deny, Always allow\nāÆ").is_some()); + } + + // ── mention_on tests ──────────────────────────────────────────────────── + + #[test] + fn effective_mention_empty_mention_on_applies_to_all() { + let reg = RegisteredTmuxSession { + mention: Some("<@123>".into()), + mention_on: vec![], + ..registration(vec![]) + }; + assert_eq!( + effective_mention(®, "keyword").as_deref(), + Some("<@123>") + ); + assert_eq!( + effective_mention(®, "waiting_for_input").as_deref(), + Some("<@123>") + ); + assert_eq!( + effective_mention(®, "heartbeat").as_deref(), + Some("<@123>") + ); + } + + #[test] + fn effective_mention_filters_by_event_kind() { + let reg = RegisteredTmuxSession { + mention: Some("<@123>".into()), + mention_on: vec!["waiting_for_input".into()], + ..registration(vec![]) + }; + assert_eq!( + effective_mention(®, "waiting_for_input").as_deref(), + Some("<@123>") + ); + assert_eq!(effective_mention(®, "keyword").as_deref(), None); + assert_eq!(effective_mention(®, "heartbeat").as_deref(), None); + } + + #[test] + fn effective_mention_no_mention_returns_none() { + let reg = RegisteredTmuxSession { + mention: None, + mention_on: vec!["waiting_for_input".into()], + ..registration(vec![]) + }; + assert_eq!( + effective_mention(®, "waiting_for_input").as_deref(), + None + ); + } + + #[test] + fn is_waiting_clears_after_yn_prompt_answered() { + // After the user types "y" and the prompt is in the capture history, + // the session should NOT be detected as waiting any more because the + // last non-empty line is now a shell prompt. + let content = "Are you sure? [y/n]: y\nsnibbor@snibbor-vm:~/projects$ "; + assert!( + is_waiting_for_input(content).is_none(), + "should not detect waiting after [y/n] prompt was already answered" + ); + } + + #[test] + fn is_waiting_detects_unanswered_yn_prompt() { + let content = "snibbor@snibbor-vm:~/projects$ some-cmd\nAre you sure? [y/n]: "; + assert!( + is_waiting_for_input(content).is_some(), + "should detect unanswered [y/n] prompt as the last line" + ); + } + + #[test] + fn is_waiting_clears_after_read_prompt_answered_with_hostname_shell() { + // Simulates: read -p "Confirm action [y/n]: " answer → user types y + let content = "read -p 'Confirm action [y/n]: ' answer\nConfirm action [y/n]: y\nsnibbor@snibbor-vm:~/projects$ "; + assert!( + is_waiting_for_input(content).is_none(), + "should not detect waiting when shell prompt follows answered read" + ); + } } diff --git a/src/tmux_wrapper.rs b/src/tmux_wrapper.rs index 0ae3554..b88d363 100644 --- a/src/tmux_wrapper.rs +++ b/src/tmux_wrapper.rs @@ -119,6 +119,9 @@ impl From for RegisteredTmuxSession { registration_source: value.registration_source, parent_process: value.parent_process, active_wrapper_monitor: true, + detect_waiting: false, + waiting_interval: 0, + mention_on: Vec::new(), } } } @@ -622,6 +625,7 @@ mod tests { name: Some("codex".into()), }), active_wrapper_monitor: true, + ..Default::default() }); assert!(log.contains("session=issue-105"));