Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,28 @@ pub struct ProvidersConfig {
pub discord: DiscordConfig,
#[serde(default)]
pub slack: SlackConfig,
#[serde(default)]
pub gemini: GeminiConfig,
#[serde(default)]
pub openrouter: OpenRouterConfig,
#[serde(default)]
pub openai: OpenAiConfig,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GeminiConfig {
pub api_key: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OpenRouterConfig {
pub api_key: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OpenAiConfig {
pub api_key: Option<String>,
pub base_url: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
Expand Down Expand Up @@ -287,6 +309,25 @@ pub struct TmuxSessionMonitor {
pub channel: Option<String>,
pub mention: Option<String>,
pub format: Option<MessageFormat>,
// ── Optional tmux content transformation ──
#[serde(default)]
pub summarize: bool,
#[serde(default = "default_summarizer")]
pub summarizer: String,
#[serde(default)]
pub heartbeat_mins: u64,
/// Minimum number of new lines added before triggering summarization. 0 = no filter.
#[serde(default)]
pub min_new_lines: usize,
/// Minimum minutes between LLM summarization calls for this session. 0 = no throttle.
#[serde(default)]
pub summarize_interval_mins: u64,
/// Minutes between heartbeat events. 0 = disable. Overrides heartbeat_mins when set.
#[serde(default)]
pub heartbeat_interval: u64,
/// Minimum minutes between AI summary events. 0 = use summarize_interval_mins.
#[serde(default)]
pub summary_interval: u64,
}

impl Default for TmuxSessionMonitor {
Expand All @@ -299,6 +340,13 @@ impl Default for TmuxSessionMonitor {
channel: None,
mention: None,
format: None,
summarize: false,
summarizer: default_summarizer(),
heartbeat_mins: 0,
min_new_lines: 0,
summarize_interval_mins: 0,
heartbeat_interval: 0,
summary_interval: 0,
}
}
}
Expand Down Expand Up @@ -428,6 +476,10 @@ fn default_true() -> bool {
true
}

fn default_summarizer() -> String {
"gemini:gemini-2.5-flash".to_string()
}

pub fn default_sink_name() -> String {
"discord".to_string()
}
Expand Down Expand Up @@ -1082,6 +1134,7 @@ mod tests {
legacy_default_channel: None,
},
slack: SlackConfig::default(),
..Default::default()
},
routes: vec![RouteRule {
event: "tmux.keyword".into(),
Expand Down Expand Up @@ -1319,6 +1372,7 @@ message = " ping "
legacy_default_channel: None,
},
slack: SlackConfig::default(),
..Default::default()
},
cron: CronConfig {
poll_interval_secs: 30,
Expand Down
1 change: 1 addition & 0 deletions src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,7 @@ mod tests {
name: Some("codex".into()),
}),
active_wrapper_monitor: true,
..Default::default()
},
);
let state = AppState {
Expand Down
16 changes: 15 additions & 1 deletion src/discord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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<Duration> {
if status != StatusCode::TOO_MANY_REQUESTS {
return None;
Expand Down
1 change: 1 addition & 0 deletions src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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-")
}

Expand Down
47 changes: 47 additions & 0 deletions src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,53 @@ impl IncomingEvent {
}
}

/// Tmux content changed — with AI-generated summary.
#[allow(clippy::too_many_arguments)]
pub fn tmux_content_changed_with_metadata(
session: String,
pane_name: String,
summary: String,
raw_truncated: String,
backend: String,
content_mode: String,
channel: Option<String>,
) -> Self {
Self {
kind: "tmux.content_changed".to_string(),
channel,
mention: None,
format: None,
template: None,
payload: json!({
"session": session,
"pane": pane_name,
"summary": summary,
"raw_truncated": raw_truncated,
"backend": backend,
"content_mode": content_mode,
}),
}
}

/// Heartbeat — no changes detected for a given interval.
pub fn tmux_heartbeat(
session: String,
minutes_since_change: u64,
channel: Option<String>,
) -> Self {
Self {
kind: "tmux.heartbeat".to_string(),
channel,
mention: None,
format: None,
template: None,
payload: json!({
"session": session,
"minutes_since_change": minutes_since_change,
}),
}
}

pub fn with_mention(mut self, mention: Option<String>) -> Self {
self.mention = mention;
self
Expand Down
2 changes: 2 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ mod router;
mod sink;
mod slack;
mod source;
mod summarize;
mod tmux_wrapper;
mod update;

Expand Down Expand Up @@ -399,6 +400,7 @@ mod tests {
name: Some("codex".into()),
}),
active_wrapper_monitor: true,
..Default::default()
}]);

assert!(output.contains(
Expand Down
112 changes: 112 additions & 0 deletions src/render/default.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,53 @@ impl Renderer for DefaultRenderer {
),
("tmux.stale", MessageFormat::Raw) => serde_json::to_string_pretty(payload)?,

("tmux.content_changed", MessageFormat::Compact) => {
let session = string_field(payload, "session")?;
if content_mode(payload) == "raw" {
let raw = string_field(payload, "raw_truncated")?;
format!("📋 **{session}**: {raw}")
} else {
let summary = string_field(payload, "summary")?;
format!("🤖 **{session}**: {summary}")
}
}
("tmux.content_changed", MessageFormat::Alert) => {
let session = string_field(payload, "session")?;
if content_mode(payload) == "raw" {
let raw = string_field(payload, "raw_truncated")?;
format!("📋 **{session}**\n```\n{raw}\n```")
} else {
let summary = string_field(payload, "summary")?;
format!("🤖 **{session}**\n{summary}")
}
}
("tmux.content_changed", MessageFormat::Inline) => {
let session = string_field(payload, "session")?;
if content_mode(payload) == "raw" {
format!("📋 [{session}]")
} else {
let summary = string_field(payload, "summary")?;
format!("🤖 [{session}] {summary}")
}
}
("tmux.content_changed", MessageFormat::Raw) => serde_json::to_string_pretty(payload)?,

("tmux.heartbeat", MessageFormat::Compact) => {
let session = string_field(payload, "session")?;
let minutes = payload.field_u64("minutes_since_change")?;
format!("💓 **{session}**: idle for {minutes}m")
}
("tmux.heartbeat", MessageFormat::Alert) => {
let session = string_field(payload, "session")?;
let minutes = payload.field_u64("minutes_since_change")?;
format!("💓 **{session}**: idle for {minutes}m")
}
("tmux.heartbeat", MessageFormat::Inline) => {
let session = string_field(payload, "session")?;
format!("💓 [{session}]")
}
("tmux.heartbeat", MessageFormat::Raw) => serde_json::to_string_pretty(payload)?,

(_, MessageFormat::Raw) => serde_json::to_string_pretty(payload)?,
(_, _) => serde_json::to_string(payload)?,
};
Expand Down Expand Up @@ -330,6 +377,10 @@ fn optional_string_field(payload: &Value, key: &str) -> Option<String> {
.map(ToString::to_string)
}

fn content_mode(payload: &Value) -> String {
optional_string_field(payload, "content_mode").unwrap_or_else(|| "summary".to_string())
}

fn optional_u64_field(payload: &Value, key: &str) -> Option<u64> {
payload.get(key).and_then(Value::as_u64)
}
Expand Down Expand Up @@ -965,4 +1016,65 @@ mod tests {
assert!(rendered.starts_with("🚨"));
assert!(rendered.contains("release published"));
}

#[test]
fn renders_tmux_content_changed_formats() {
let renderer = DefaultRenderer;
let event = IncomingEvent::tmux_content_changed_with_metadata(
"issue-24".into(),
"0.1".into(),
"Agent fixed the failing test".into(),
"tail".into(),
"gemini-cli".into(),
"summary".into(),
None,
);

assert_eq!(
renderer.render(&event, &MessageFormat::Compact).unwrap(),
"🤖 **issue-24**: Agent fixed the failing test"
);
assert_eq!(
renderer.render(&event, &MessageFormat::Inline).unwrap(),
"🤖 [issue-24] Agent fixed the failing test"
);
}

#[test]
fn renders_tmux_content_changed_raw_formats() {
let renderer = DefaultRenderer;
let event = IncomingEvent::tmux_content_changed_with_metadata(
"issue-24".into(),
"0.1".into(),
"ignored".into(),
"cargo build\nerror: failed".into(),
"raw".into(),
"raw".into(),
None,
);

assert_eq!(
renderer.render(&event, &MessageFormat::Compact).unwrap(),
"📋 **issue-24**: cargo build\nerror: failed"
);
assert_eq!(
renderer.render(&event, &MessageFormat::Inline).unwrap(),
"📋 [issue-24]"
);
}

#[test]
fn renders_tmux_heartbeat_formats() {
let renderer = DefaultRenderer;
let event = IncomingEvent::tmux_heartbeat("issue-24".into(), 42, None);

assert_eq!(
renderer.render(&event, &MessageFormat::Compact).unwrap(),
"💓 **issue-24**: idle for 42m"
);
assert_eq!(
renderer.render(&event, &MessageFormat::Inline).unwrap(),
"💓 [issue-24]"
);
}
}
3 changes: 3 additions & 0 deletions src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,9 @@ fn route_candidates(kind: &str) -> Vec<&str> {
| "session.handoff-needed" => {
vec![kind, "session.*"]
}
"tmux.content_changed" | "tmux.heartbeat" => {
vec![kind, "tmux.*"]
}
other => vec![other],
}
}
Expand Down
Loading