Compare commits

..

2 Commits

Author SHA1 Message Date
Affaan Mustafa e555c6a4b7 wip: checkpoint ecc2 board observability prototype 2026-04-17 22:37:38 -04:00
Affaan Mustafa 463eb5fb41 docs: add ecc recovery guidance for wiped setups 2026-04-09 18:12:08 -07:00
26 changed files with 2064 additions and 36902 deletions
-15
View File
@@ -183,21 +183,6 @@ It is mostly:
- clarifying public docs
- continuing the ECC 2.0 operator/control-plane buildout
ECC 2.0 now ships a bounded migration audit entrypoint:
- `ecc migrate audit --source ~/.hermes`
- `ecc migrate plan --source ~/.hermes --output migration-plan.md`
- `ecc migrate scaffold --source ~/.hermes --output-dir migration-artifacts`
- `ecc migrate import-skills --source ~/.hermes --output-dir migration-artifacts/skills`
- `ecc migrate import-tools --source ~/.hermes --output-dir migration-artifacts/tools`
- `ecc migrate import-plugins --source ~/.hermes --output-dir migration-artifacts/plugins`
- `ecc migrate import-schedules --source ~/.hermes --dry-run`
- `ecc migrate import-remote --source ~/.hermes --dry-run`
- `ecc migrate import-env --source ~/.hermes --dry-run`
- `ecc migrate import-memory --source ~/.hermes`
Use that first to inventory the legacy workspace and map detected surfaces onto the current ECC2 scheduler, remote dispatch, memory graph, templates, and manual-translation lanes.
## What Still Belongs In Backlog
The remaining large migration themes are already tracked:
-2
View File
@@ -82,8 +82,6 @@ These stay local and should be configured per operator:
## Suggested Bring-Up Order
0. Run `ecc migrate audit --source ~/.hermes` first to inventory the legacy workspace and see which parts already map onto ECC2.
0.5. Generate and review artifacts with `ecc migrate plan` / `ecc migrate scaffold`, scaffold reusable legacy skills with `ecc migrate import-skills --output-dir migration-artifacts/skills`, scaffold legacy tool translation templates with `ecc migrate import-tools --output-dir migration-artifacts/tools`, scaffold legacy bridge plugins with `ecc migrate import-plugins --output-dir migration-artifacts/plugins`, preview recurring jobs with `ecc migrate import-schedules --dry-run`, preview gateway dispatch with `ecc migrate import-remote --dry-run`, preview safe env/service context with `ecc migrate import-env --dry-run`, then import sanitized workspace memory with `ecc migrate import-memory`.
1. Install ECC and verify the baseline harness setup.
2. Install Hermes and point it at ECC-imported skills.
3. Register the MCP servers you actually use every day.
-168
View File
@@ -2,12 +2,6 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.8.12"
@@ -306,26 +300,6 @@ dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "cron"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07"
dependencies = [
"chrono",
"nom",
"once_cell",
]
[[package]]
name = "crossterm"
version = "0.28.1"
@@ -518,23 +492,19 @@ dependencies = [
"anyhow",
"chrono",
"clap",
"cron",
"crossterm 0.28.1",
"dirs",
"git2",
"libc",
"ratatui",
"regex",
"rusqlite",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.18",
"tokio",
"toml",
"tracing",
"tracing-subscriber",
"ureq",
"uuid",
]
@@ -620,16 +590,6 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -1179,16 +1139,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.1.1"
@@ -1660,20 +1610,6 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
@@ -1723,41 +1659,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -1891,12 +1792,6 @@ dependencies = [
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "siphasher"
version = "1.0.2"
@@ -1958,12 +1853,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "1.0.109"
@@ -2317,30 +2206,6 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "2.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
dependencies = [
"base64",
"flate2",
"log",
"once_cell",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"url",
"webpki-roots 0.26.11",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -2507,24 +2372,6 @@ dependencies = [
"semver",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.6",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "wezterm-bidi"
version = "0.2.3"
@@ -2678,15 +2525,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
@@ -2936,12 +2774,6 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.3"
-4
View File
@@ -25,9 +25,6 @@ git2 = "0.20"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
regex = "1"
sha2 = "0.10"
ureq = { version = "2", features = ["json"] }
# CLI
clap = { version = "4", features = ["derive"] }
@@ -43,7 +40,6 @@ libc = "0.2"
# Time
chrono = { version = "0.4", features = ["serde"] }
cron = "0.12"
# UUID for session IDs
uuid = { version = "1", features = ["v4"] }
+2 -72
View File
@@ -1,41 +1,13 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::fmt;
use crate::session::store::StateStore;
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum TaskPriority {
Low,
#[default]
Normal,
High,
Critical,
}
impl fmt::Display for TaskPriority {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let label = match self {
Self::Low => "low",
Self::Normal => "normal",
Self::High => "high",
Self::Critical => "critical",
};
write!(f, "{label}")
}
}
/// Message types for inter-agent communication.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MessageType {
/// Task handoff from one agent to another
TaskHandoff {
task: String,
context: String,
#[serde(default)]
priority: TaskPriority,
},
TaskHandoff { task: String, context: String },
/// Agent requesting information from another
Query { question: String },
/// Response to a query
@@ -74,16 +46,7 @@ pub fn parse(content: &str) -> Option<MessageType> {
pub fn preview(msg_type: &str, content: &str) -> String {
match parse(content) {
Some(MessageType::TaskHandoff { task, .. }) => {
let priority = handoff_priority(content);
if priority == TaskPriority::Normal {
format!("handoff {}", truncate(&task, 56))
} else {
format!(
"handoff [{}] {}",
priority_label(priority),
truncate(&task, 48)
)
}
format!("handoff {}", truncate(&task, 56))
}
Some(MessageType::Query { question }) => {
format!("query {}", truncate(&question, 56))
@@ -112,39 +75,6 @@ pub fn preview(msg_type: &str, content: &str) -> String {
}
}
pub fn handoff_priority(content: &str) -> TaskPriority {
match parse(content) {
Some(MessageType::TaskHandoff { priority, .. }) => priority,
_ => extract_legacy_handoff_priority(content),
}
}
fn extract_legacy_handoff_priority(content: &str) -> TaskPriority {
let value: serde_json::Value = match serde_json::from_str(content) {
Ok(value) => value,
Err(_) => return TaskPriority::Normal,
};
match value
.get("priority")
.and_then(|priority| priority.as_str())
.unwrap_or("normal")
{
"low" => TaskPriority::Low,
"high" => TaskPriority::High,
"critical" => TaskPriority::Critical,
_ => TaskPriority::Normal,
}
}
fn priority_label(priority: TaskPriority) -> &'static str {
match priority {
TaskPriority::Low => "low",
TaskPriority::Normal => "normal",
TaskPriority::High => "high",
TaskPriority::Critical => "critical",
}
}
fn truncate(value: &str, max_chars: usize) -> String {
let trimmed = value.trim();
if trimmed.chars().count() <= max_chars {
+10 -1611
View File
File diff suppressed because it is too large Load Diff
+61 -9882
View File
File diff suppressed because it is too large Load Diff
-635
View File
@@ -1,635 +0,0 @@
use anyhow::Result;
use chrono::{DateTime, Local, Timelike};
use serde::{Deserialize, Serialize};
use serde_json::json;
#[cfg(not(test))]
use anyhow::Context;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotificationEvent {
SessionStarted,
SessionCompleted,
SessionFailed,
BudgetAlert,
ApprovalRequest,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct QuietHoursConfig {
pub enabled: bool,
pub start_hour: u8,
pub end_hour: u8,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct DesktopNotificationConfig {
pub enabled: bool,
pub session_started: bool,
pub session_completed: bool,
pub session_failed: bool,
pub budget_alerts: bool,
pub approval_requests: bool,
pub quiet_hours: QuietHoursConfig,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CompletionSummaryDelivery {
#[default]
Desktop,
TuiPopup,
DesktopAndTuiPopup,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct CompletionSummaryConfig {
pub enabled: bool,
pub delivery: CompletionSummaryDelivery,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WebhookProvider {
#[default]
Slack,
Discord,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct WebhookTarget {
pub provider: WebhookProvider,
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct WebhookNotificationConfig {
pub enabled: bool,
pub session_started: bool,
pub session_completed: bool,
pub session_failed: bool,
pub budget_alerts: bool,
pub approval_requests: bool,
pub targets: Vec<WebhookTarget>,
}
#[derive(Debug, Clone)]
pub struct DesktopNotifier {
config: DesktopNotificationConfig,
}
#[derive(Debug, Clone)]
pub struct WebhookNotifier {
config: WebhookNotificationConfig,
}
impl Default for QuietHoursConfig {
fn default() -> Self {
Self {
enabled: false,
start_hour: 22,
end_hour: 8,
}
}
}
impl QuietHoursConfig {
pub fn sanitized(self) -> Self {
let valid = self.start_hour <= 23 && self.end_hour <= 23;
if valid {
self
} else {
Self::default()
}
}
pub fn is_active(&self, now: DateTime<Local>) -> bool {
if !self.enabled {
return false;
}
let quiet = self.clone().sanitized();
if quiet.start_hour == quiet.end_hour {
return false;
}
let hour = now.hour() as u8;
if quiet.start_hour < quiet.end_hour {
hour >= quiet.start_hour && hour < quiet.end_hour
} else {
hour >= quiet.start_hour || hour < quiet.end_hour
}
}
}
impl Default for DesktopNotificationConfig {
fn default() -> Self {
Self {
enabled: true,
session_started: false,
session_completed: true,
session_failed: true,
budget_alerts: true,
approval_requests: true,
quiet_hours: QuietHoursConfig::default(),
}
}
}
impl DesktopNotificationConfig {
pub fn sanitized(self) -> Self {
Self {
quiet_hours: self.quiet_hours.sanitized(),
..self
}
}
pub fn allows(&self, event: NotificationEvent, now: DateTime<Local>) -> bool {
let config = self.clone().sanitized();
if !config.enabled || config.quiet_hours.is_active(now) {
return false;
}
match event {
NotificationEvent::SessionStarted => config.session_started,
NotificationEvent::SessionCompleted => config.session_completed,
NotificationEvent::SessionFailed => config.session_failed,
NotificationEvent::BudgetAlert => config.budget_alerts,
NotificationEvent::ApprovalRequest => config.approval_requests,
}
}
}
impl Default for CompletionSummaryConfig {
fn default() -> Self {
Self {
enabled: true,
delivery: CompletionSummaryDelivery::Desktop,
}
}
}
impl CompletionSummaryConfig {
pub fn desktop_enabled(&self) -> bool {
self.enabled
&& matches!(
self.delivery,
CompletionSummaryDelivery::Desktop | CompletionSummaryDelivery::DesktopAndTuiPopup
)
}
pub fn popup_enabled(&self) -> bool {
self.enabled
&& matches!(
self.delivery,
CompletionSummaryDelivery::TuiPopup | CompletionSummaryDelivery::DesktopAndTuiPopup
)
}
}
impl Default for WebhookTarget {
fn default() -> Self {
Self {
provider: WebhookProvider::Slack,
url: String::new(),
}
}
}
impl WebhookTarget {
fn sanitized(self) -> Option<Self> {
let url = self.url.trim().to_string();
if url.starts_with("https://") || url.starts_with("http://") {
Some(Self { url, ..self })
} else {
None
}
}
}
impl Default for WebhookNotificationConfig {
fn default() -> Self {
Self {
enabled: false,
session_started: true,
session_completed: true,
session_failed: true,
budget_alerts: true,
approval_requests: false,
targets: Vec::new(),
}
}
}
impl WebhookNotificationConfig {
pub fn sanitized(self) -> Self {
Self {
targets: self
.targets
.into_iter()
.filter_map(WebhookTarget::sanitized)
.collect(),
..self
}
}
pub fn allows(&self, event: NotificationEvent) -> bool {
let config = self.clone().sanitized();
if !config.enabled || config.targets.is_empty() {
return false;
}
match event {
NotificationEvent::SessionStarted => config.session_started,
NotificationEvent::SessionCompleted => config.session_completed,
NotificationEvent::SessionFailed => config.session_failed,
NotificationEvent::BudgetAlert => config.budget_alerts,
NotificationEvent::ApprovalRequest => config.approval_requests,
}
}
}
impl DesktopNotifier {
pub fn new(config: DesktopNotificationConfig) -> Self {
Self {
config: config.sanitized(),
}
}
pub fn notify(&self, event: NotificationEvent, title: &str, body: &str) -> bool {
match self.try_notify(event, title, body, Local::now()) {
Ok(sent) => sent,
Err(error) => {
tracing::warn!("Failed to send desktop notification: {error}");
false
}
}
}
fn try_notify(
&self,
event: NotificationEvent,
title: &str,
body: &str,
now: DateTime<Local>,
) -> Result<bool> {
if !self.config.allows(event, now) {
return Ok(false);
}
let Some((program, args)) = notification_command(std::env::consts::OS, title, body) else {
return Ok(false);
};
run_notification_command(&program, &args)?;
Ok(true)
}
}
impl WebhookNotifier {
pub fn new(config: WebhookNotificationConfig) -> Self {
Self {
config: config.sanitized(),
}
}
pub fn notify(&self, event: NotificationEvent, message: &str) -> bool {
match self.try_notify(event, message) {
Ok(sent) => sent,
Err(error) => {
tracing::warn!("Failed to send webhook notification: {error}");
false
}
}
}
fn try_notify(&self, event: NotificationEvent, message: &str) -> Result<bool> {
self.try_notify_with(event, message, send_webhook_request)
}
fn try_notify_with<F>(
&self,
event: NotificationEvent,
message: &str,
mut sender: F,
) -> Result<bool>
where
F: FnMut(&WebhookTarget, serde_json::Value) -> Result<()>,
{
if !self.config.allows(event) {
return Ok(false);
}
let mut delivered = false;
for target in &self.config.targets {
let payload = webhook_payload(target, message);
match sender(target, payload) {
Ok(()) => delivered = true,
Err(error) => tracing::warn!(
"Failed to deliver {:?} webhook notification to {}: {error}",
target.provider,
target.url
),
}
}
Ok(delivered)
}
}
fn notification_command(platform: &str, title: &str, body: &str) -> Option<(String, Vec<String>)> {
match platform {
"macos" => Some((
"osascript".to_string(),
vec![
"-e".to_string(),
format!(
"display notification \"{}\" with title \"{}\"",
sanitize_osascript(body),
sanitize_osascript(title)
),
],
)),
"linux" => Some((
"notify-send".to_string(),
vec![
"--app-name".to_string(),
"ECC 2.0".to_string(),
title.trim().to_string(),
body.trim().to_string(),
],
)),
_ => None,
}
}
fn webhook_payload(target: &WebhookTarget, message: &str) -> serde_json::Value {
match target.provider {
WebhookProvider::Slack => json!({
"text": message,
}),
WebhookProvider::Discord => json!({
"content": message,
"allowed_mentions": {
"parse": []
}
}),
}
}
#[cfg(not(test))]
fn run_notification_command(program: &str, args: &[String]) -> Result<()> {
let status = std::process::Command::new(program)
.args(args)
.status()
.with_context(|| format!("launch {program}"))?;
if status.success() {
Ok(())
} else {
anyhow::bail!("{program} exited with {status}");
}
}
#[cfg(test)]
fn run_notification_command(_program: &str, _args: &[String]) -> Result<()> {
Ok(())
}
#[cfg(not(test))]
fn send_webhook_request(target: &WebhookTarget, payload: serde_json::Value) -> Result<()> {
let agent = ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(5))
.timeout_read(std::time::Duration::from_secs(5))
.build();
let response = agent
.post(&target.url)
.send_json(payload)
.with_context(|| format!("POST {}", target.url))?;
if response.status() >= 200 && response.status() < 300 {
Ok(())
} else {
anyhow::bail!("{} returned {}", target.url, response.status());
}
}
#[cfg(test)]
fn send_webhook_request(_target: &WebhookTarget, _payload: serde_json::Value) -> Result<()> {
Ok(())
}
fn sanitize_osascript(value: &str) -> String {
value
.replace('\\', "")
.replace('"', "\u{201C}")
.replace('\n', " ")
}
#[cfg(test)]
mod tests {
use super::{
notification_command, webhook_payload, CompletionSummaryDelivery,
DesktopNotificationConfig, DesktopNotifier, NotificationEvent, QuietHoursConfig,
WebhookNotificationConfig, WebhookNotifier, WebhookProvider, WebhookTarget,
};
use chrono::{Local, TimeZone};
use serde_json::json;
#[test]
fn quiet_hours_support_cross_midnight_ranges() {
let quiet_hours = QuietHoursConfig {
enabled: true,
start_hour: 22,
end_hour: 8,
};
assert!(quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 23, 0, 0).unwrap()));
assert!(quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 7, 0, 0).unwrap()));
assert!(!quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 14, 0, 0).unwrap()));
}
#[test]
fn quiet_hours_support_same_day_ranges() {
let quiet_hours = QuietHoursConfig {
enabled: true,
start_hour: 9,
end_hour: 17,
};
assert!(quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 10, 0, 0).unwrap()));
assert!(!quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 18, 0, 0).unwrap()));
}
#[test]
fn notification_preferences_respect_event_flags() {
let mut config = DesktopNotificationConfig::default();
config.session_completed = false;
let now = Local.with_ymd_and_hms(2026, 4, 9, 12, 0, 0).unwrap();
assert!(!config.allows(NotificationEvent::SessionCompleted, now));
assert!(config.allows(NotificationEvent::BudgetAlert, now));
assert!(!config.allows(NotificationEvent::SessionStarted, now));
}
#[test]
fn notifier_skips_delivery_during_quiet_hours() {
let mut config = DesktopNotificationConfig::default();
config.quiet_hours = QuietHoursConfig {
enabled: true,
start_hour: 22,
end_hour: 8,
};
let notifier = DesktopNotifier::new(config);
assert!(!notifier
.try_notify(
NotificationEvent::ApprovalRequest,
"ECC 2.0: Approval needed",
"worker-123 needs review",
Local.with_ymd_and_hms(2026, 4, 9, 23, 0, 0).unwrap(),
)
.unwrap());
}
#[test]
fn macos_notifications_use_osascript() {
let (program, args) =
notification_command("macos", "ECC 2.0: Completed", "Task finished").unwrap();
assert_eq!(program, "osascript");
assert_eq!(args[0], "-e");
assert!(args[1].contains("display notification"));
assert!(args[1].contains("ECC 2.0: Completed"));
}
#[test]
fn linux_notifications_use_notify_send() {
let (program, args) =
notification_command("linux", "ECC 2.0: Approval needed", "worker-123").unwrap();
assert_eq!(program, "notify-send");
assert_eq!(args[0], "--app-name");
assert_eq!(args[1], "ECC 2.0");
assert_eq!(args[2], "ECC 2.0: Approval needed");
assert_eq!(args[3], "worker-123");
}
#[test]
fn webhook_notifications_require_enabled_targets_and_event() {
let mut config = WebhookNotificationConfig::default();
assert!(!config.allows(NotificationEvent::SessionCompleted));
config.enabled = true;
config.targets = vec![WebhookTarget {
provider: WebhookProvider::Slack,
url: "https://hooks.slack.test/services/abc".to_string(),
}];
assert!(config.allows(NotificationEvent::SessionCompleted));
assert!(config.allows(NotificationEvent::SessionStarted));
assert!(!config.allows(NotificationEvent::ApprovalRequest));
}
#[test]
fn webhook_sanitization_filters_invalid_urls() {
let config = WebhookNotificationConfig {
enabled: true,
targets: vec![
WebhookTarget {
provider: WebhookProvider::Slack,
url: "https://hooks.slack.test/services/abc".to_string(),
},
WebhookTarget {
provider: WebhookProvider::Discord,
url: "ftp://discord.invalid".to_string(),
},
],
..WebhookNotificationConfig::default()
}
.sanitized();
assert_eq!(config.targets.len(), 1);
assert_eq!(config.targets[0].provider, WebhookProvider::Slack);
}
#[test]
fn slack_webhook_payload_uses_text() {
let payload = webhook_payload(
&WebhookTarget {
provider: WebhookProvider::Slack,
url: "https://hooks.slack.test/services/abc".to_string(),
},
"*ECC 2.0* hello",
);
assert_eq!(payload, json!({ "text": "*ECC 2.0* hello" }));
}
#[test]
fn discord_webhook_payload_disables_mentions() {
let payload = webhook_payload(
&WebhookTarget {
provider: WebhookProvider::Discord,
url: "https://discord.test/api/webhooks/123".to_string(),
},
"```text\nsummary\n```",
);
assert_eq!(
payload,
json!({
"content": "```text\nsummary\n```",
"allowed_mentions": { "parse": [] }
})
);
}
#[test]
fn webhook_notifier_sends_to_each_target() {
let notifier = WebhookNotifier::new(WebhookNotificationConfig {
enabled: true,
targets: vec![
WebhookTarget {
provider: WebhookProvider::Slack,
url: "https://hooks.slack.test/services/abc".to_string(),
},
WebhookTarget {
provider: WebhookProvider::Discord,
url: "https://discord.test/api/webhooks/123".to_string(),
},
],
..WebhookNotificationConfig::default()
});
let mut sent = Vec::new();
let delivered = notifier
.try_notify_with(
NotificationEvent::SessionCompleted,
"payload text",
|target, payload| {
sent.push((target.provider, payload));
Ok(())
},
)
.unwrap();
assert!(delivered);
assert_eq!(sent.len(), 2);
assert_eq!(sent[0].0, WebhookProvider::Slack);
assert_eq!(sent[1].0, WebhookProvider::Discord);
}
#[test]
fn completion_summary_delivery_defaults_to_desktop() {
assert_eq!(
CompletionSummaryDelivery::default(),
CompletionSummaryDelivery::Desktop
);
}
}
-13
View File
@@ -9,9 +9,7 @@ pub struct ToolCallEvent {
pub session_id: String,
pub tool_name: String,
pub input_summary: String,
pub input_params_json: String,
pub output_summary: String,
pub trigger_summary: String,
pub duration_ms: u64,
pub risk_score: f64,
}
@@ -49,9 +47,7 @@ impl ToolCallEvent {
.score,
tool_name,
input_summary,
input_params_json: "{}".to_string(),
output_summary: output_summary.into(),
trigger_summary: String::new(),
duration_ms,
}
}
@@ -242,9 +238,7 @@ pub struct ToolLogEntry {
pub session_id: String,
pub tool_name: String,
pub input_summary: String,
pub input_params_json: String,
pub output_summary: String,
pub trigger_summary: String,
pub duration_ms: u64,
pub risk_score: f64,
pub timestamp: String,
@@ -274,9 +268,7 @@ impl<'a> ToolLogger<'a> {
&event.session_id,
&event.tool_name,
&event.input_summary,
&event.input_params_json,
&event.output_summary,
&event.trigger_summary,
event.duration_ms,
event.risk_score,
&timestamp,
@@ -314,8 +306,6 @@ mod tests {
Session {
id: id.to_string(),
task: "test task".to_string(),
project: "workspace".to_string(),
task_group: "general".to_string(),
agent_type: "claude".to_string(),
working_dir: PathBuf::from("/tmp"),
state: SessionState::Pending,
@@ -323,7 +313,6 @@ mod tests {
worktree: None,
created_at: now,
updated_at: now,
last_heartbeat_at: now,
metrics: SessionMetrics::default(),
}
}
@@ -408,8 +397,6 @@ mod tests {
assert_eq!(first_page.entries.len(), 2);
assert_eq!(first_page.entries[0].tool_name, "Bash");
assert_eq!(first_page.entries[1].tool_name, "Write");
assert_eq!(first_page.entries[0].input_params_json, "{}");
assert_eq!(first_page.entries[0].trigger_summary, "");
let second_page = logger.query("sess-1", 2, 2)?;
assert_eq!(second_page.total, 3);
+25 -62
View File
@@ -22,19 +22,13 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
resume_crashed_sessions(&db)?;
let heartbeat_interval = Duration::from_secs(cfg.heartbeat_interval_secs);
let timeout = Duration::from_secs(cfg.session_timeout_secs);
loop {
if let Err(e) = check_sessions(&db, &cfg) {
if let Err(e) = check_sessions(&db, timeout) {
tracing::error!("Session check failed: {e}");
}
if let Err(e) = maybe_run_due_schedules(&db, &cfg).await {
tracing::error!("Scheduled task dispatch pass failed: {e}");
}
if let Err(e) = maybe_run_remote_dispatch(&db, &cfg).await {
tracing::error!("Remote dispatch pass failed: {e}");
}
if let Err(e) = coordinate_backlog_cycle(&db, &cfg).await {
tracing::error!("Backlog coordination pass failed: {e}");
}
@@ -43,14 +37,10 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
tracing::error!("Worktree auto-merge pass failed: {e}");
}
if let Err(e) = maybe_auto_prune_inactive_worktrees(&db, &cfg).await {
if let Err(e) = maybe_auto_prune_inactive_worktrees(&db).await {
tracing::error!("Worktree auto-prune pass failed: {e}");
}
if let Err(e) = manager::activate_pending_worktree_sessions(&db, &cfg).await {
tracing::error!("Queued worktree activation pass failed: {e}");
}
time::sleep(heartbeat_interval).await;
}
}
@@ -92,38 +82,28 @@ where
Ok(failed_sessions)
}
fn check_sessions(db: &StateStore, cfg: &Config) -> Result<()> {
let _ = manager::enforce_session_heartbeats(db, cfg)?;
fn check_sessions(db: &StateStore, timeout: Duration) -> Result<()> {
let sessions = db.list_sessions()?;
for session in sessions {
if session.state != SessionState::Running {
continue;
}
let elapsed = chrono::Utc::now()
.signed_duration_since(session.updated_at)
.to_std()
.unwrap_or(Duration::ZERO);
if elapsed > timeout {
tracing::warn!("Session {} timed out after {:?}", session.id, elapsed);
db.update_state_and_pid(&session.id, &SessionState::Failed, None)?;
}
}
Ok(())
}
async fn maybe_run_due_schedules(db: &StateStore, cfg: &Config) -> Result<usize> {
let outcomes = manager::run_due_schedules(db, cfg, cfg.max_parallel_sessions).await?;
if !outcomes.is_empty() {
tracing::info!("Dispatched {} scheduled task(s)", outcomes.len());
}
Ok(outcomes.len())
}
async fn maybe_run_remote_dispatch(db: &StateStore, cfg: &Config) -> Result<usize> {
let outcomes =
manager::run_remote_dispatch_requests(db, cfg, cfg.max_parallel_sessions).await?;
let routed = outcomes
.iter()
.filter(|outcome| {
matches!(
outcome.action,
manager::RemoteDispatchAction::SpawnedTopLevel
| manager::RemoteDispatchAction::Assigned(_)
)
})
.count();
if routed > 0 {
tracing::info!("Dispatched {} remote request(s)", routed);
}
Ok(routed)
}
async fn maybe_auto_dispatch(db: &StateStore, cfg: &Config) -> Result<usize> {
let summary = maybe_auto_dispatch_with_recorder(
cfg,
@@ -428,9 +408,9 @@ where
Ok(merged)
}
async fn maybe_auto_prune_inactive_worktrees(db: &StateStore, cfg: &Config) -> Result<usize> {
async fn maybe_auto_prune_inactive_worktrees(db: &StateStore) -> Result<usize> {
maybe_auto_prune_inactive_worktrees_with_recorder(
|| manager::prune_inactive_worktrees(db, cfg),
|| manager::prune_inactive_worktrees(db),
|pruned, active| db.record_daemon_auto_prune_pass(pruned, active),
)
.await
@@ -456,7 +436,6 @@ where
let outcome = prune().await?;
let pruned = outcome.cleaned_session_ids.len();
let active = outcome.active_with_worktree_ids.len();
let retained = outcome.retained_session_ids.len();
record(pruned, active)?;
if pruned > 0 {
@@ -465,9 +444,6 @@ where
if active > 0 {
tracing::info!("Skipped {active} active worktree(s) during auto-prune");
}
if retained > 0 {
tracing::info!("Deferred {retained} inactive worktree(s) within retention");
}
Ok(pruned)
}
@@ -515,8 +491,6 @@ mod tests {
Session {
id: id.to_string(),
task: "Recover crashed worker".to_string(),
project: "workspace".to_string(),
task_group: "general".to_string(),
agent_type: "claude".to_string(),
working_dir: PathBuf::from("/tmp"),
state,
@@ -524,7 +498,6 @@ mod tests {
worktree: None,
created_at: now,
updated_at: now,
last_heartbeat_at: now,
metrics: SessionMetrics::default(),
}
}
@@ -1237,11 +1210,9 @@ mod tests {
invoked_flag.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(manager::WorktreeBulkMergeOutcome {
merged: Vec::new(),
rebased: Vec::new(),
active_with_worktree_ids: Vec::new(),
conflicted_session_ids: Vec::new(),
dirty_worktree_ids: Vec::new(),
blocked_by_queue_session_ids: Vec::new(),
failures: Vec::new(),
})
}
@@ -1276,16 +1247,9 @@ mod tests {
cleaned_worktree: true,
},
],
rebased: vec![manager::WorktreeRebaseOutcome {
session_id: "worker-r".to_string(),
branch: "ecc/worker-r".to_string(),
base_branch: "main".to_string(),
already_up_to_date: false,
}],
active_with_worktree_ids: vec!["worker-c".to_string()],
conflicted_session_ids: vec!["worker-d".to_string()],
dirty_worktree_ids: vec!["worker-e".to_string()],
blocked_by_queue_session_ids: vec!["worker-f".to_string()],
failures: Vec::new(),
})
})
@@ -1305,7 +1269,6 @@ mod tests {
Ok(manager::WorktreePruneOutcome {
cleaned_session_ids: vec!["stopped-a".to_string(), "stopped-b".to_string()],
active_with_worktree_ids: vec!["running-a".to_string()],
retained_session_ids: vec!["retained-a".to_string()],
})
},
move |pruned, active| {
File diff suppressed because it is too large Load Diff
+21 -849
View File
@@ -6,307 +6,13 @@ pub mod store;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;
use std::path::Path;
use std::path::PathBuf;
pub type SessionAgentProfile = crate::config::ResolvedAgentProfile;
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum HarnessKind {
#[default]
Unknown,
Claude,
Codex,
OpenCode,
Gemini,
Cursor,
Kiro,
Trae,
Zed,
FactoryDroid,
Windsurf,
}
impl HarnessKind {
pub fn from_agent_type(agent_type: &str) -> Self {
match agent_type.trim().to_ascii_lowercase().as_str() {
"claude" | "claude-code" => Self::Claude,
"codex" => Self::Codex,
"opencode" => Self::OpenCode,
"gemini" | "gemini-cli" => Self::Gemini,
"cursor" => Self::Cursor,
"kiro" => Self::Kiro,
"trae" => Self::Trae,
"zed" => Self::Zed,
"factory-droid" | "factory_droid" | "factorydroid" => Self::FactoryDroid,
"windsurf" => Self::Windsurf,
_ => Self::Unknown,
}
}
pub fn from_db_value(value: &str) -> Self {
match value.trim().to_ascii_lowercase().as_str() {
"claude" => Self::Claude,
"codex" => Self::Codex,
"opencode" => Self::OpenCode,
"gemini" => Self::Gemini,
"cursor" => Self::Cursor,
"kiro" => Self::Kiro,
"trae" => Self::Trae,
"zed" => Self::Zed,
"factory_droid" => Self::FactoryDroid,
"windsurf" => Self::Windsurf,
_ => Self::Unknown,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Unknown => "unknown",
Self::Claude => "claude",
Self::Codex => "codex",
Self::OpenCode => "opencode",
Self::Gemini => "gemini",
Self::Cursor => "cursor",
Self::Kiro => "kiro",
Self::Trae => "trae",
Self::Zed => "zed",
Self::FactoryDroid => "factory_droid",
Self::Windsurf => "windsurf",
}
}
pub fn canonical_agent_type(agent_type: &str) -> String {
match Self::from_agent_type(agent_type) {
Self::Unknown => agent_type.trim().to_ascii_lowercase(),
harness => harness.as_str().to_string(),
}
}
fn supports_direct_execution(self) -> bool {
matches!(
self,
Self::Claude | Self::Codex | Self::OpenCode | Self::Gemini
)
}
fn project_markers(self) -> &'static [&'static str] {
match self {
Self::Claude => &[".claude"],
Self::Codex => &[".codex", ".codex-plugin"],
Self::OpenCode => &[".opencode"],
Self::Gemini => &[".gemini"],
Self::Cursor => &[".cursor"],
Self::Kiro => &[".kiro"],
Self::Trae => &[".trae"],
Self::Zed => &[".zed"],
Self::FactoryDroid => &[".factory-droid", ".factory_droid"],
Self::Windsurf => &[".windsurf"],
Self::Unknown => &[],
}
}
}
impl fmt::Display for HarnessKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionHarnessInfo {
pub primary: HarnessKind,
pub primary_label: String,
pub detected: Vec<HarnessKind>,
pub detected_labels: Vec<String>,
}
impl SessionHarnessInfo {
fn detected_labels_for(detected: &[HarnessKind]) -> Vec<String> {
detected.iter().map(|harness| harness.to_string()).collect()
}
fn configured_detected_labels(cfg: &crate::config::Config, working_dir: &Path) -> Vec<String> {
let mut labels = Vec::new();
for (name, runner) in &cfg.harness_runners {
if runner.project_markers.is_empty() {
continue;
}
if runner
.project_markers
.iter()
.any(|marker| working_dir.join(marker).exists())
{
let label = Self::runner_key(name);
if !label.is_empty() && !labels.contains(&label) {
labels.push(label);
}
}
}
labels
}
pub fn runner_key(agent_type: &str) -> String {
let canonical = HarnessKind::canonical_agent_type(agent_type);
match HarnessKind::from_agent_type(&canonical) {
HarnessKind::Unknown if canonical.is_empty() => {
HarnessKind::Unknown.as_str().to_string()
}
HarnessKind::Unknown => canonical,
harness => harness.as_str().to_string(),
}
}
fn primary_label_for(agent_type: &str, primary: HarnessKind) -> String {
match primary {
HarnessKind::Unknown => {
let label = Self::runner_key(agent_type);
if label.is_empty() {
HarnessKind::Unknown.as_str().to_string()
} else {
label
}
}
harness => harness.as_str().to_string(),
}
}
pub fn detect(agent_type: &str, working_dir: &Path) -> Self {
let runner_key = Self::runner_key(agent_type);
let detected = [
HarnessKind::Claude,
HarnessKind::Codex,
HarnessKind::OpenCode,
HarnessKind::Gemini,
HarnessKind::Cursor,
HarnessKind::Kiro,
HarnessKind::Trae,
HarnessKind::Zed,
HarnessKind::FactoryDroid,
HarnessKind::Windsurf,
]
.into_iter()
.filter(|harness| {
harness
.project_markers()
.iter()
.any(|marker| working_dir.join(marker).exists())
})
.collect::<Vec<_>>();
let primary = match HarnessKind::from_agent_type(&runner_key) {
HarnessKind::Unknown if runner_key == HarnessKind::Unknown.as_str() => {
detected.first().copied().unwrap_or(HarnessKind::Unknown)
}
HarnessKind::Unknown => HarnessKind::Unknown,
harness => harness,
};
let detected_labels = Self::detected_labels_for(&detected);
Self {
primary,
primary_label: Self::primary_label_for(agent_type, primary),
detected,
detected_labels,
}
}
pub fn from_persisted(
harness_label: &str,
agent_type: &str,
working_dir: &Path,
detected: Vec<HarnessKind>,
) -> Self {
let primary = HarnessKind::from_db_value(harness_label);
if primary == HarnessKind::Unknown && detected.is_empty() && harness_label.trim().is_empty()
{
return Self::detect(agent_type, working_dir);
}
let normalized_label = harness_label.trim().to_ascii_lowercase();
let detected_labels = Self::detected_labels_for(&detected);
Self {
primary,
primary_label: if normalized_label.is_empty() {
Self::primary_label_for(agent_type, primary)
} else {
normalized_label
},
detected,
detected_labels,
}
}
pub fn with_config_detection(
mut self,
cfg: &crate::config::Config,
working_dir: &Path,
) -> Self {
for label in Self::configured_detected_labels(cfg, working_dir) {
if !self.detected_labels.contains(&label) {
self.detected_labels.push(label);
}
}
if self.primary == HarnessKind::Unknown
&& self.primary_label == HarnessKind::Unknown.as_str()
&& !self.detected_labels.is_empty()
{
self.primary_label = self.detected_labels[0].clone();
}
self
}
pub fn resolve_requested_agent_type(
cfg: &crate::config::Config,
requested_agent_type: &str,
working_dir: &Path,
) -> String {
let canonical = HarnessKind::canonical_agent_type(requested_agent_type);
if !canonical.is_empty() && canonical != "auto" {
return canonical;
}
let detected = Self::detect("", working_dir).with_config_detection(cfg, working_dir);
if detected.primary_label != HarnessKind::Unknown.as_str()
&& Self::can_launch_detected_label(cfg, &detected.primary_label)
{
return Self::runner_key(&detected.primary_label);
}
for label in &detected.detected_labels {
if Self::can_launch_detected_label(cfg, label) {
return Self::runner_key(label);
}
}
HarnessKind::Claude.as_str().to_string()
}
fn can_launch_detected_label(cfg: &crate::config::Config, label: &str) -> bool {
cfg.harness_runner(label).is_some()
|| HarnessKind::from_agent_type(label).supports_direct_execution()
}
pub fn detected_summary(&self) -> String {
if self.detected_labels.is_empty() {
"none detected".to_string()
} else {
self.detected_labels.join(", ")
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: String,
pub task: String,
pub project: String,
pub task_group: String,
pub agent_type: String,
pub working_dir: PathBuf,
pub state: SessionState,
@@ -314,7 +20,6 @@ pub struct Session {
pub worktree: Option<WorktreeInfo>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub last_heartbeat_at: DateTime<Utc>,
pub metrics: SessionMetrics,
}
@@ -323,7 +28,6 @@ pub enum SessionState {
Pending,
Running,
Idle,
Stale,
Completed,
Failed,
Stopped,
@@ -335,7 +39,6 @@ impl fmt::Display for SessionState {
SessionState::Pending => write!(f, "pending"),
SessionState::Running => write!(f, "running"),
SessionState::Idle => write!(f, "idle"),
SessionState::Stale => write!(f, "stale"),
SessionState::Completed => write!(f, "completed"),
SessionState::Failed => write!(f, "failed"),
SessionState::Stopped => write!(f, "stopped"),
@@ -357,21 +60,12 @@ impl SessionState {
) | (
SessionState::Running,
SessionState::Idle
| SessionState::Stale
| SessionState::Completed
| SessionState::Failed
| SessionState::Stopped
) | (
SessionState::Idle,
SessionState::Running
| SessionState::Stale
| SessionState::Completed
| SessionState::Failed
| SessionState::Stopped
) | (
SessionState::Stale,
SessionState::Running
| SessionState::Idle
| SessionState::Completed
| SessionState::Failed
| SessionState::Stopped
@@ -384,7 +78,6 @@ impl SessionState {
match value {
"running" => SessionState::Running,
"idle" => SessionState::Idle,
"stale" => SessionState::Stale,
"completed" => SessionState::Completed,
"failed" => SessionState::Failed,
"stopped" => SessionState::Stopped,
@@ -402,8 +95,6 @@ pub struct WorktreeInfo {
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionMetrics {
pub input_tokens: u64,
pub output_tokens: u64,
pub tokens_used: u64,
pub tool_calls: u64,
pub files_changed: u32,
@@ -411,6 +102,27 @@ pub struct SessionMetrics {
pub cost_usd: f64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionBoardMeta {
pub lane: String,
pub project: Option<String>,
pub feature: Option<String>,
pub issue: Option<String>,
pub row_label: Option<String>,
pub previous_lane: Option<String>,
pub previous_row_label: Option<String>,
pub column_index: i64,
pub row_index: i64,
pub stack_index: i64,
pub progress_percent: i64,
pub status_detail: Option<String>,
pub movement_note: Option<String>,
pub activity_kind: Option<String>,
pub activity_note: Option<String>,
pub handoff_backlog: i64,
pub conflict_signal: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMessage {
pub id: i64,
@@ -421,543 +133,3 @@ pub struct SessionMessage {
pub read: bool,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScheduledTask {
pub id: i64,
pub cron_expr: String,
pub task: String,
pub agent_type: String,
pub profile_name: Option<String>,
pub working_dir: PathBuf,
pub project: String,
pub task_group: String,
pub use_worktree: bool,
pub last_run_at: Option<DateTime<Utc>>,
pub next_run_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RemoteDispatchRequest {
pub id: i64,
pub request_kind: RemoteDispatchKind,
pub target_session_id: Option<String>,
pub task: String,
pub target_url: Option<String>,
pub priority: crate::comms::TaskPriority,
pub agent_type: String,
pub profile_name: Option<String>,
pub working_dir: PathBuf,
pub project: String,
pub task_group: String,
pub use_worktree: bool,
pub source: String,
pub requester: Option<String>,
pub status: RemoteDispatchStatus,
pub result_session_id: Option<String>,
pub result_action: Option<String>,
pub error: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub dispatched_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RemoteDispatchKind {
Standard,
ComputerUse,
}
impl fmt::Display for RemoteDispatchKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Standard => write!(f, "standard"),
Self::ComputerUse => write!(f, "computer_use"),
}
}
}
impl RemoteDispatchKind {
pub fn from_db_value(value: &str) -> Self {
match value {
"computer_use" => Self::ComputerUse,
_ => Self::Standard,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RemoteDispatchStatus {
Pending,
Dispatched,
Failed,
}
impl fmt::Display for RemoteDispatchStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Pending => write!(f, "pending"),
Self::Dispatched => write!(f, "dispatched"),
Self::Failed => write!(f, "failed"),
}
}
}
impl RemoteDispatchStatus {
pub fn from_db_value(value: &str) -> Self {
match value {
"dispatched" => Self::Dispatched,
"failed" => Self::Failed,
_ => Self::Pending,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FileActivityEntry {
pub session_id: String,
pub action: FileActivityAction,
pub path: String,
pub summary: String,
pub diff_preview: Option<String>,
pub patch_preview: Option<String>,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DecisionLogEntry {
pub id: i64,
pub session_id: String,
pub decision: String,
pub alternatives: Vec<String>,
pub reasoning: String,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextGraphEntity {
pub id: i64,
pub session_id: Option<String>,
pub entity_type: String,
pub name: String,
pub path: Option<String>,
pub summary: String,
pub metadata: BTreeMap<String, String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextGraphRelation {
pub id: i64,
pub session_id: Option<String>,
pub from_entity_id: i64,
pub from_entity_type: String,
pub from_entity_name: String,
pub to_entity_id: i64,
pub to_entity_type: String,
pub to_entity_name: String,
pub relation_type: String,
pub summary: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextGraphEntityDetail {
pub entity: ContextGraphEntity,
pub outgoing: Vec<ContextGraphRelation>,
pub incoming: Vec<ContextGraphRelation>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextGraphObservation {
pub id: i64,
pub session_id: Option<String>,
pub entity_id: i64,
pub entity_type: String,
pub entity_name: String,
pub observation_type: String,
pub priority: ContextObservationPriority,
pub pinned: bool,
pub summary: String,
pub details: BTreeMap<String, String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextGraphRecallEntry {
pub entity: ContextGraphEntity,
pub score: u64,
pub matched_terms: Vec<String>,
pub relation_count: usize,
pub observation_count: usize,
pub max_observation_priority: ContextObservationPriority,
pub has_pinned_observation: bool,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum ContextObservationPriority {
Low,
Normal,
High,
Critical,
}
impl Default for ContextObservationPriority {
fn default() -> Self {
Self::Normal
}
}
impl fmt::Display for ContextObservationPriority {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Low => write!(f, "low"),
Self::Normal => write!(f, "normal"),
Self::High => write!(f, "high"),
Self::Critical => write!(f, "critical"),
}
}
}
impl ContextObservationPriority {
pub fn from_db_value(value: i64) -> Self {
match value {
0 => Self::Low,
2 => Self::High,
3 => Self::Critical,
_ => Self::Normal,
}
}
pub fn as_db_value(self) -> i64 {
match self {
Self::Low => 0,
Self::Normal => 1,
Self::High => 2,
Self::Critical => 3,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextGraphSyncStats {
pub sessions_scanned: usize,
pub decisions_processed: usize,
pub file_events_processed: usize,
pub messages_processed: usize,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ContextGraphCompactionStats {
pub entities_scanned: usize,
pub duplicate_observations_deleted: usize,
pub overflow_observations_deleted: usize,
pub observations_retained: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FileActivityAction {
Read,
Create,
Modify,
Move,
Delete,
Touch,
}
pub fn normalize_group_label(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub fn default_project_label(working_dir: &Path) -> String {
working_dir
.file_name()
.and_then(|value| value.to_str())
.and_then(normalize_group_label)
.unwrap_or_else(|| "workspace".to_string())
}
pub fn default_task_group_label(task: &str) -> String {
normalize_group_label(task).unwrap_or_else(|| "general".to_string())
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionGrouping {
pub project: Option<String>,
pub task_group: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
struct TestDir {
path: PathBuf,
}
impl TestDir {
fn new(label: &str) -> Result<Self, Box<dyn std::error::Error>> {
let path =
std::env::temp_dir().join(format!("ecc2-{}-{}", label, uuid::Uuid::new_v4()));
fs::create_dir_all(&path)?;
Ok(Self { path })
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
#[test]
fn detect_session_harness_prefers_agent_type_and_collects_project_markers(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-detect")?;
fs::create_dir_all(repo.path().join(".codex"))?;
fs::create_dir_all(repo.path().join(".claude"))?;
let harness = SessionHarnessInfo::detect("claude", repo.path());
assert_eq!(harness.primary, HarnessKind::Claude);
assert_eq!(harness.primary_label, "claude");
assert_eq!(
harness.detected,
vec![HarnessKind::Claude, HarnessKind::Codex]
);
assert_eq!(harness.detected_labels, vec!["claude", "codex"]);
assert_eq!(harness.detected_summary(), "claude, codex");
Ok(())
}
#[test]
fn detect_session_harness_falls_back_to_project_markers_when_agent_unspecified(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-markers")?;
fs::create_dir_all(repo.path().join(".gemini"))?;
let harness = SessionHarnessInfo::detect("", repo.path());
assert_eq!(harness.primary, HarnessKind::Gemini);
assert_eq!(harness.primary_label, "gemini");
assert_eq!(harness.detected, vec![HarnessKind::Gemini]);
assert_eq!(harness.detected_labels, vec!["gemini"]);
Ok(())
}
#[test]
fn detect_session_harness_collects_extended_builtin_markers(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-extended-markers")?;
fs::create_dir_all(repo.path().join(".zed"))?;
fs::create_dir_all(repo.path().join(".factory-droid"))?;
fs::create_dir_all(repo.path().join(".windsurf"))?;
let harness = SessionHarnessInfo::detect("", repo.path());
assert_eq!(harness.primary, HarnessKind::Zed);
assert_eq!(harness.primary_label, "zed");
assert_eq!(
harness.detected,
vec![
HarnessKind::Zed,
HarnessKind::FactoryDroid,
HarnessKind::Windsurf
]
);
assert_eq!(
harness.detected_labels,
vec!["zed", "factory_droid", "windsurf"]
);
Ok(())
}
#[test]
fn canonical_agent_type_normalizes_known_aliases() {
assert_eq!(HarnessKind::canonical_agent_type("claude-code"), "claude");
assert_eq!(HarnessKind::canonical_agent_type("gemini-cli"), "gemini");
assert_eq!(
HarnessKind::canonical_agent_type("factory-droid"),
"factory_droid"
);
assert_eq!(
HarnessKind::canonical_agent_type(" custom-runner "),
"custom-runner"
);
}
#[test]
fn detect_session_harness_preserves_custom_agent_label_without_markers() {
let harness = SessionHarnessInfo::detect(" custom-runner ", Path::new("."));
assert_eq!(harness.primary, HarnessKind::Unknown);
assert_eq!(harness.primary_label, "custom-runner");
assert!(harness.detected.is_empty());
assert!(harness.detected_labels.is_empty());
}
#[test]
fn detect_session_harness_preserves_custom_agent_label_with_project_markers(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-custom-markers")?;
fs::create_dir_all(repo.path().join(".claude"))?;
fs::create_dir_all(repo.path().join(".codex"))?;
let harness = SessionHarnessInfo::detect("custom-runner", repo.path());
assert_eq!(harness.primary, HarnessKind::Unknown);
assert_eq!(harness.primary_label, "custom-runner");
assert_eq!(
harness.detected,
vec![HarnessKind::Claude, HarnessKind::Codex]
);
assert_eq!(harness.detected_labels, vec!["claude", "codex"]);
Ok(())
}
#[test]
fn config_detection_adds_custom_markers_to_detected_summary(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-custom-config")?;
fs::create_dir_all(repo.path().join(".acme"))?;
let mut cfg = crate::config::Config::default();
cfg.harness_runners.insert(
"acme-runner".to_string(),
crate::config::HarnessRunnerConfig {
project_markers: vec![PathBuf::from(".acme")],
..Default::default()
},
);
let harness =
SessionHarnessInfo::detect("", repo.path()).with_config_detection(&cfg, repo.path());
assert_eq!(harness.primary, HarnessKind::Unknown);
assert_eq!(harness.primary_label, "acme-runner");
assert_eq!(harness.detected_labels, vec!["acme-runner"]);
assert_eq!(harness.detected_summary(), "acme-runner");
Ok(())
}
#[test]
fn config_detection_preserves_custom_primary_label_and_appends_marker_matches(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-config-append")?;
fs::create_dir_all(repo.path().join(".acme"))?;
fs::create_dir_all(repo.path().join(".codex"))?;
let mut cfg = crate::config::Config::default();
cfg.harness_runners.insert(
"acme-runner".to_string(),
crate::config::HarnessRunnerConfig {
project_markers: vec![PathBuf::from(".acme")],
..Default::default()
},
);
let harness = SessionHarnessInfo::detect("acme-runner", repo.path())
.with_config_detection(&cfg, repo.path());
assert_eq!(harness.primary, HarnessKind::Unknown);
assert_eq!(harness.primary_label, "acme-runner");
assert_eq!(harness.detected_labels, vec!["codex", "acme-runner"]);
assert_eq!(harness.detected_summary(), "codex, acme-runner");
Ok(())
}
#[test]
fn runner_key_uses_canonical_label_for_unknown_harnesses() {
assert_eq!(
SessionHarnessInfo::runner_key(" custom-runner "),
"custom-runner"
);
assert_eq!(SessionHarnessInfo::runner_key("claude-code"), "claude");
}
#[test]
fn resolve_requested_agent_type_uses_detected_builtin_marker_for_auto(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-resolve-auto-built-in")?;
fs::create_dir_all(repo.path().join(".codex"))?;
let resolved = SessionHarnessInfo::resolve_requested_agent_type(
&crate::config::Config::default(),
"auto",
repo.path(),
);
assert_eq!(resolved, "codex");
Ok(())
}
#[test]
fn resolve_requested_agent_type_uses_configured_marker_for_auto(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-resolve-auto-custom")?;
fs::create_dir_all(repo.path().join(".acme"))?;
let mut cfg = crate::config::Config::default();
cfg.harness_runners.insert(
"acme-runner".to_string(),
crate::config::HarnessRunnerConfig {
project_markers: vec![PathBuf::from(".acme")],
..Default::default()
},
);
let resolved = SessionHarnessInfo::resolve_requested_agent_type(&cfg, "auto", repo.path());
assert_eq!(resolved, "acme-runner");
Ok(())
}
#[test]
fn resolve_requested_agent_type_skips_nonlaunchable_builtin_markers_without_runner(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-resolve-auto-nonlaunchable")?;
fs::create_dir_all(repo.path().join(".zed"))?;
let resolved = SessionHarnessInfo::resolve_requested_agent_type(
&crate::config::Config::default(),
"auto",
repo.path(),
);
assert_eq!(resolved, "claude");
Ok(())
}
#[test]
fn resolve_requested_agent_type_uses_configured_runner_for_extended_builtin_markers(
) -> Result<(), Box<dyn std::error::Error>> {
let repo = TestDir::new("session-harness-resolve-auto-extended-runner")?;
fs::create_dir_all(repo.path().join(".windsurf"))?;
let mut cfg = crate::config::Config::default();
cfg.harness_runners.insert(
"windsurf".to_string(),
crate::config::HarnessRunnerConfig {
program: "windsurf".to_string(),
..Default::default()
},
);
let resolved = SessionHarnessInfo::resolve_requested_agent_type(&cfg, "auto", repo.path());
assert_eq!(resolved, "windsurf");
Ok(())
}
#[test]
fn resolve_requested_agent_type_falls_back_to_claude_without_markers() {
let resolved = SessionHarnessInfo::resolve_requested_agent_type(
&crate::config::Config::default(),
"auto",
Path::new("."),
);
assert_eq!(resolved, "claude");
}
}
+4 -27
View File
@@ -32,31 +32,6 @@ impl OutputStream {
pub struct OutputLine {
pub stream: OutputStream,
pub text: String,
pub timestamp: String,
}
impl OutputLine {
pub fn new(
stream: OutputStream,
text: impl Into<String>,
timestamp: impl Into<String>,
) -> Self {
Self {
stream,
text: text.into(),
timestamp: timestamp.into(),
}
}
pub fn with_current_timestamp(stream: OutputStream, text: impl Into<String>) -> Self {
Self::new(stream, text, chrono::Utc::now().to_rfc3339())
}
pub fn occurred_at(&self) -> Option<chrono::DateTime<chrono::Utc>> {
chrono::DateTime::parse_from_rfc3339(&self.timestamp)
.ok()
.map(|timestamp| timestamp.with_timezone(&chrono::Utc))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -95,7 +70,10 @@ impl SessionOutputStore {
}
pub fn push_line(&self, session_id: &str, stream: OutputStream, text: impl Into<String>) {
let line = OutputLine::with_current_timestamp(stream, text);
let line = OutputLine {
stream,
text: text.into(),
};
{
let mut buffers = self.lock_buffers();
@@ -167,6 +145,5 @@ mod tests {
assert_eq!(event.session_id, "session-1");
assert_eq!(event.line.stream, OutputStream::Stderr);
assert_eq!(event.line.text, "problem");
assert!(event.line.occurred_at().is_some());
}
}
+3 -93
View File
@@ -5,7 +5,6 @@ use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
use tokio::process::Command;
use tokio::sync::{mpsc, oneshot};
use tokio::time::{self, MissedTickBehavior};
use super::output::{OutputStream, SessionOutputStore};
use super::store::StateStore;
@@ -27,9 +26,6 @@ enum DbMessage {
line: String,
ack: oneshot::Sender<DbAck>,
},
TouchHeartbeat {
ack: oneshot::Sender<DbAck>,
},
}
#[derive(Clone)]
@@ -57,10 +53,6 @@ impl DbWriter {
.await
}
async fn touch_heartbeat(&self) -> Result<()> {
self.send(|ack| DbMessage::TouchHeartbeat { ack }).await
}
async fn send<F>(&self, build: F) -> Result<()>
where
F: FnOnce(oneshot::Sender<DbAck>) -> DbMessage,
@@ -119,17 +111,6 @@ fn run_db_writer(db_path: PathBuf, session_id: String, mut rx: mpsc::UnboundedRe
};
let _ = ack.send(result);
}
DbMessage::TouchHeartbeat { ack } => {
let result = match opened.as_ref() {
Some(db) => db
.touch_heartbeat(&session_id)
.map_err(|error| error.to_string()),
None => Err(open_error
.clone()
.unwrap_or_else(|| "Failed to open state store".to_string())),
};
let _ = ack.send(result);
}
}
}
}
@@ -139,7 +120,6 @@ pub async fn capture_command_output(
session_id: String,
mut command: Command,
output_store: SessionOutputStore,
heartbeat_interval: std::time::Duration,
) -> Result<ExitStatus> {
let db_writer = DbWriter::start(db_path, session_id.clone());
@@ -172,19 +152,6 @@ pub async fn capture_command_output(
.ok_or_else(|| anyhow::anyhow!("Spawned process did not expose a process id"))?;
db_writer.update_pid(Some(pid)).await?;
db_writer.update_state(SessionState::Running).await?;
db_writer.touch_heartbeat().await?;
let heartbeat_writer = db_writer.clone();
let heartbeat_task = tokio::spawn(async move {
let mut ticker = time::interval(heartbeat_interval);
ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
loop {
ticker.tick().await;
if heartbeat_writer.touch_heartbeat().await.is_err() {
break;
}
}
});
let stdout_task = tokio::spawn(capture_stream(
session_id.clone(),
@@ -202,8 +169,6 @@ pub async fn capture_command_output(
));
let status = child.wait().await?;
heartbeat_task.abort();
let _ = heartbeat_task.await;
stdout_task.await??;
stderr_task.await??;
@@ -272,8 +237,6 @@ mod tests {
db.insert_session(&Session {
id: session_id.clone(),
task: "stream output".to_string(),
project: "workspace".to_string(),
task_group: "general".to_string(),
agent_type: "test".to_string(),
working_dir: env::temp_dir(),
state: SessionState::Pending,
@@ -281,7 +244,6 @@ mod tests {
worktree: None,
created_at: now,
updated_at: now,
last_heartbeat_at: now,
metrics: SessionMetrics::default(),
})?;
@@ -292,14 +254,9 @@ mod tests {
.arg("-c")
.arg("printf 'alpha\\n'; printf 'beta\\n' >&2");
let status = capture_command_output(
db_path.clone(),
session_id.clone(),
command,
output_store,
std::time::Duration::from_millis(10),
)
.await?;
let status =
capture_command_output(db_path.clone(), session_id.clone(), command, output_store)
.await?;
assert!(status.success());
@@ -329,51 +286,4 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn capture_command_output_updates_heartbeat_for_quiet_processes() -> Result<()> {
let db_path = env::temp_dir().join(format!("ecc2-runtime-heartbeat-{}.db", Uuid::new_v4()));
let db = StateStore::open(&db_path)?;
let session_id = "session-heartbeat".to_string();
let now = Utc::now();
db.insert_session(&Session {
id: session_id.clone(),
task: "quiet process".to_string(),
project: "workspace".to_string(),
task_group: "general".to_string(),
agent_type: "test".to_string(),
working_dir: env::temp_dir(),
state: SessionState::Pending,
pid: None,
worktree: None,
created_at: now,
updated_at: now,
last_heartbeat_at: now,
metrics: SessionMetrics::default(),
})?;
let mut command = Command::new("/bin/sh");
command.arg("-c").arg("sleep 0.05");
let _ = capture_command_output(
db_path.clone(),
session_id.clone(),
command,
SessionOutputStore::default(),
std::time::Duration::from_millis(10),
)
.await?;
let db = StateStore::open(&db_path)?;
let session = db
.get_session(&session_id)?
.expect("session should still exist");
assert!(session.last_heartbeat_at > now);
assert_eq!(session.state, SessionState::Completed);
let _ = std::fs::remove_file(db_path);
Ok(())
}
}
+885 -4831
View File
File diff suppressed because it is too large Load Diff
-76
View File
@@ -27,49 +27,9 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
if event::poll(Duration::from_millis(250))? {
if let Event::Key(key) = event::read()? {
if dashboard.has_active_completion_popup() {
match (key.modifiers, key.code) {
(KeyModifiers::CONTROL, KeyCode::Char('c')) => break,
(_, KeyCode::Esc) | (_, KeyCode::Enter) | (_, KeyCode::Char(' ')) => {
dashboard.dismiss_completion_popup();
}
_ => {}
}
continue;
}
if dashboard.is_input_mode() {
match (key.modifiers, key.code) {
(KeyModifiers::CONTROL, KeyCode::Char('c')) => break,
(_, KeyCode::Esc) => dashboard.cancel_input(),
(_, KeyCode::Enter) => dashboard.submit_input().await,
(_, KeyCode::Backspace) => dashboard.pop_input_char(),
(modifiers, KeyCode::Char(ch))
if !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::ALT) =>
{
dashboard.push_input_char(ch);
}
_ => {}
}
continue;
}
if dashboard.is_pane_command_mode() {
if dashboard.handle_pane_command_key(key) {
continue;
}
}
match (key.modifiers, key.code) {
(KeyModifiers::CONTROL, KeyCode::Char('c')) => break,
(KeyModifiers::CONTROL, KeyCode::Char('w')) => {
dashboard.begin_pane_command_mode()
}
(_, KeyCode::Char('q')) => break,
_ if dashboard.handle_pane_navigation_key(key) => {}
(_, KeyCode::Tab) => dashboard.next_pane(),
(KeyModifiers::SHIFT, KeyCode::BackTab) => dashboard.prev_pane(),
(_, KeyCode::Char('+')) | (_, KeyCode::Char('=')) => {
@@ -78,53 +38,17 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
(_, KeyCode::Char('-')) => dashboard.decrease_pane_size(),
(_, KeyCode::Char('j')) | (_, KeyCode::Down) => dashboard.scroll_down(),
(_, KeyCode::Char('k')) | (_, KeyCode::Up) => dashboard.scroll_up(),
(_, KeyCode::Char('[')) => dashboard.focus_previous_delegate(),
(_, KeyCode::Char(']')) => dashboard.focus_next_delegate(),
(_, KeyCode::Enter) => dashboard.open_focused_delegate(),
(_, KeyCode::Char('/')) => dashboard.begin_search(),
(_, KeyCode::Esc) => dashboard.clear_search(),
(_, KeyCode::Char('n')) if dashboard.has_active_search() => {
dashboard.next_search_match()
}
(_, KeyCode::Char('N')) if dashboard.has_active_search() => {
dashboard.prev_search_match()
}
(_, KeyCode::Char('N')) => dashboard.begin_spawn_prompt(),
(_, KeyCode::Char('n')) => dashboard.new_session().await,
(_, KeyCode::Char('a')) => dashboard.assign_selected().await,
(_, KeyCode::Char('b')) => dashboard.rebalance_selected_team().await,
(_, KeyCode::Char('B')) => dashboard.rebalance_all_teams().await,
(_, KeyCode::Char('i')) => dashboard.drain_inbox_selected().await,
(_, KeyCode::Char('I')) => dashboard.focus_next_approval_target(),
(_, KeyCode::Char('g')) => dashboard.auto_dispatch_backlog().await,
(_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await,
(_, KeyCode::Char('K')) => dashboard.toggle_context_graph_mode(),
(_, KeyCode::Char('h')) => dashboard.collapse_selected_pane(),
(_, KeyCode::Char('H')) => dashboard.restore_collapsed_panes(),
(_, KeyCode::Char('y')) => dashboard.toggle_timeline_mode(),
(_, KeyCode::Char('E')) if dashboard.is_context_graph_mode() => {
dashboard.cycle_graph_entity_filter()
}
(_, KeyCode::Char('E')) => dashboard.cycle_timeline_event_filter(),
(_, KeyCode::Char('v')) => dashboard.toggle_output_mode(),
(_, KeyCode::Char('z')) => dashboard.toggle_git_status_mode(),
(_, KeyCode::Char('V')) => dashboard.toggle_diff_view_mode(),
(_, KeyCode::Char('S')) => dashboard.stage_selected_git_status(),
(_, KeyCode::Char('U')) => dashboard.unstage_selected_git_status(),
(_, KeyCode::Char('R')) => dashboard.reset_selected_git_status(),
(_, KeyCode::Char('C')) => dashboard.begin_commit_prompt(),
(_, KeyCode::Char('P')) => dashboard.begin_pr_prompt(),
(_, KeyCode::Char('{')) => dashboard.prev_diff_hunk(),
(_, KeyCode::Char('}')) => dashboard.next_diff_hunk(),
(_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(),
(_, KeyCode::Char('e')) => dashboard.toggle_output_filter(),
(_, KeyCode::Char('f')) => dashboard.cycle_output_time_filter(),
(_, KeyCode::Char('A')) => dashboard.toggle_search_scope(),
(_, KeyCode::Char('o')) => dashboard.toggle_search_agent_filter(),
(_, KeyCode::Char('m')) => dashboard.merge_selected_worktree().await,
(_, KeyCode::Char('M')) => dashboard.merge_ready_worktrees().await,
(_, KeyCode::Char('l')) => dashboard.cycle_pane_layout(),
(_, KeyCode::Char('T')) => dashboard.toggle_theme(),
(_, KeyCode::Char('p')) => dashboard.toggle_auto_dispatch_policy(),
(_, KeyCode::Char('t')) => dashboard.toggle_auto_worktree_policy(),
(_, KeyCode::Char('w')) => dashboard.toggle_auto_merge_policy(),
+888 -10658
View File
File diff suppressed because it is too large Load Diff
+33 -134
View File
@@ -1,49 +1,30 @@
use crate::config::BudgetAlertThresholds;
use ratatui::{
prelude::*,
text::{Line, Span},
widgets::{Gauge, Paragraph, Widget},
};
pub(crate) const WARNING_THRESHOLD: f64 = 0.8;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum BudgetState {
Unconfigured,
Normal,
Alert50,
Alert75,
Alert90,
Warning,
OverBudget,
}
impl BudgetState {
fn badge(self, thresholds: BudgetAlertThresholds) -> Option<String> {
match self {
Self::Alert50 => Some(threshold_label(thresholds.advisory)),
Self::Alert75 => Some(threshold_label(thresholds.warning)),
Self::Alert90 => Some(threshold_label(thresholds.critical)),
Self::OverBudget => Some("over budget".to_string()),
Self::Unconfigured => Some("no budget".to_string()),
Self::Normal => None,
}
pub(crate) const fn is_warning(self) -> bool {
matches!(self, Self::Warning | Self::OverBudget)
}
pub(crate) fn summary_suffix(self, thresholds: BudgetAlertThresholds) -> Option<String> {
fn badge(self) -> Option<&'static str> {
match self {
Self::Alert50 => Some(format!(
"Budget alert {}",
threshold_label(thresholds.advisory)
)),
Self::Alert75 => Some(format!(
"Budget alert {}",
threshold_label(thresholds.warning)
)),
Self::Alert90 => Some(format!(
"Budget alert {}",
threshold_label(thresholds.critical)
)),
Self::OverBudget => Some("Budget exceeded".to_string()),
Self::Unconfigured | Self::Normal => None,
Self::Warning => Some("warning"),
Self::OverBudget => Some("over budget"),
Self::Unconfigured => Some("no budget"),
Self::Normal => None,
}
}
@@ -51,13 +32,11 @@ impl BudgetState {
let base = Style::default().fg(match self {
Self::Unconfigured => Color::DarkGray,
Self::Normal => Color::DarkGray,
Self::Alert50 => Color::Cyan,
Self::Alert75 => Color::Yellow,
Self::Alert90 => Color::LightRed,
Self::Warning => Color::Yellow,
Self::OverBudget => Color::Red,
});
if matches!(self, Self::Alert75 | Self::Alert90 | Self::OverBudget) {
if self.is_warning() {
base.add_modifier(Modifier::BOLD)
} else {
base
@@ -76,43 +55,30 @@ pub(crate) struct TokenMeter<'a> {
title: &'a str,
used: f64,
budget: f64,
thresholds: BudgetAlertThresholds,
format: MeterFormat,
}
impl<'a> TokenMeter<'a> {
pub(crate) fn tokens(
title: &'a str,
used: u64,
budget: u64,
thresholds: BudgetAlertThresholds,
) -> Self {
pub(crate) fn tokens(title: &'a str, used: u64, budget: u64) -> Self {
Self {
title,
used: used as f64,
budget: budget as f64,
thresholds,
format: MeterFormat::Tokens,
}
}
pub(crate) fn currency(
title: &'a str,
used: f64,
budget: f64,
thresholds: BudgetAlertThresholds,
) -> Self {
pub(crate) fn currency(title: &'a str, used: f64, budget: f64) -> Self {
Self {
title,
used,
budget,
thresholds,
format: MeterFormat::Currency,
}
}
pub(crate) fn state(&self) -> BudgetState {
budget_state(self.used, self.budget, self.thresholds)
budget_state(self.used, self.budget)
}
fn ratio(&self) -> f64 {
@@ -131,7 +97,7 @@ impl<'a> TokenMeter<'a> {
.add_modifier(Modifier::BOLD),
)];
if let Some(badge) = self.state().badge(self.thresholds) {
if let Some(badge) = self.state().badge() {
spans.push(Span::raw(" "));
spans.push(Span::styled(format!("[{badge}]"), self.state().style()));
}
@@ -199,7 +165,7 @@ impl Widget for TokenMeter<'_> {
.label(self.display_label())
.gauge_style(
Style::default()
.fg(gradient_color(self.ratio(), self.thresholds))
.fg(gradient_color(self.ratio()))
.add_modifier(Modifier::BOLD),
)
.style(Style::default().fg(Color::DarkGray))
@@ -216,51 +182,35 @@ pub(crate) fn budget_ratio(used: f64, budget: f64) -> f64 {
}
}
pub(crate) fn budget_state(
used: f64,
budget: f64,
thresholds: BudgetAlertThresholds,
) -> BudgetState {
pub(crate) fn budget_state(used: f64, budget: f64) -> BudgetState {
if budget <= 0.0 {
BudgetState::Unconfigured
} else if used / budget >= 1.0 {
BudgetState::OverBudget
} else if used / budget >= thresholds.critical {
BudgetState::Alert90
} else if used / budget >= thresholds.warning {
BudgetState::Alert75
} else if used / budget >= thresholds.advisory {
BudgetState::Alert50
} else if used / budget >= WARNING_THRESHOLD {
BudgetState::Warning
} else {
BudgetState::Normal
}
}
pub(crate) fn gradient_color(ratio: f64, thresholds: BudgetAlertThresholds) -> Color {
pub(crate) fn gradient_color(ratio: f64) -> Color {
const GREEN: (u8, u8, u8) = (34, 197, 94);
const YELLOW: (u8, u8, u8) = (234, 179, 8);
const RED: (u8, u8, u8) = (239, 68, 68);
let clamped = ratio.clamp(0.0, 1.0);
if clamped <= thresholds.warning {
interpolate_rgb(
GREEN,
YELLOW,
clamped / thresholds.warning.max(f64::EPSILON),
)
if clamped <= WARNING_THRESHOLD {
interpolate_rgb(GREEN, YELLOW, clamped / WARNING_THRESHOLD)
} else {
interpolate_rgb(
YELLOW,
RED,
(clamped - thresholds.warning) / (1.0 - thresholds.warning),
(clamped - WARNING_THRESHOLD) / (1.0 - WARNING_THRESHOLD),
)
}
}
fn threshold_label(value: f64) -> String {
format!("{}%", (value * 100.0).round() as u64)
}
pub(crate) fn format_currency(value: f64) -> String {
format!("${value:.2}")
}
@@ -296,76 +246,25 @@ fn interpolate_rgb(from: (u8, u8, u8), to: (u8, u8, u8), ratio: f64) -> Color {
mod tests {
use ratatui::{buffer::Buffer, layout::Rect, style::Color, widgets::Widget};
use crate::config::{BudgetAlertThresholds, Config};
use super::{gradient_color, threshold_label, BudgetState, TokenMeter};
use super::{gradient_color, BudgetState, TokenMeter};
#[test]
fn budget_state_uses_alert_threshold_ladder() {
assert_eq!(
TokenMeter::tokens("Token Budget", 50, 100, Config::BUDGET_ALERT_THRESHOLDS).state(),
BudgetState::Alert50
);
assert_eq!(
TokenMeter::tokens("Token Budget", 75, 100, Config::BUDGET_ALERT_THRESHOLDS).state(),
BudgetState::Alert75
);
assert_eq!(
TokenMeter::tokens("Token Budget", 90, 100, Config::BUDGET_ALERT_THRESHOLDS).state(),
BudgetState::Alert90
);
assert_eq!(
TokenMeter::tokens("Token Budget", 100, 100, Config::BUDGET_ALERT_THRESHOLDS).state(),
BudgetState::OverBudget
);
fn warning_state_starts_at_eighty_percent() {
let meter = TokenMeter::tokens("Token Budget", 80, 100);
assert_eq!(meter.state(), BudgetState::Warning);
}
#[test]
fn gradient_runs_from_green_to_yellow_to_red() {
assert_eq!(
gradient_color(0.0, Config::BUDGET_ALERT_THRESHOLDS),
Color::Rgb(34, 197, 94)
);
assert_eq!(
gradient_color(0.75, Config::BUDGET_ALERT_THRESHOLDS),
Color::Rgb(234, 179, 8)
);
assert_eq!(
gradient_color(1.0, Config::BUDGET_ALERT_THRESHOLDS),
Color::Rgb(239, 68, 68)
);
}
#[test]
fn token_meter_uses_custom_budget_thresholds() {
let meter = TokenMeter::tokens(
"Token Budget",
45,
100,
BudgetAlertThresholds {
advisory: 0.40,
warning: 0.70,
critical: 0.85,
},
);
assert_eq!(meter.state(), BudgetState::Alert50);
}
#[test]
fn threshold_label_rounds_to_percent() {
assert_eq!(threshold_label(0.4), "40%");
assert_eq!(threshold_label(0.875), "88%");
assert_eq!(gradient_color(0.0), Color::Rgb(34, 197, 94));
assert_eq!(gradient_color(0.8), Color::Rgb(234, 179, 8));
assert_eq!(gradient_color(1.0), Color::Rgb(239, 68, 68));
}
#[test]
fn token_meter_renders_compact_usage_label() {
let meter = TokenMeter::tokens(
"Token Budget",
4_000,
10_000,
Config::BUDGET_ALERT_THRESHOLDS,
);
let meter = TokenMeter::tokens("Token Budget", 4_000, 10_000);
let area = Rect::new(0, 0, 48, 2);
let mut buffer = Buffer::empty(area);
+53 -1882
View File
File diff suppressed because it is too large Load Diff
-12
View File
@@ -260,18 +260,6 @@
"description": "Capture governance events from tool outputs. Enable with ECC_GOVERNANCE_CAPTURE=1",
"id": "post:governance-capture"
},
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:session-activity-tracker\" \"scripts/hooks/session-activity-tracker.js\" \"standard,strict\"",
"timeout": 10
}
],
"description": "Track per-session tool calls and file activity for ECC2 metrics",
"id": "post:session-activity-tracker"
},
{
"matcher": "*",
"hooks": [
+1 -1
View File
@@ -126,7 +126,7 @@
"devDependencies": {
"@eslint/js": "^9.39.2",
"@opencode-ai/plugin": "^1.0.0",
"@types/node": "^25.6.0",
"@types/node": "^20.19.24",
"c8": "^11.0.0",
"eslint": "^9.39.2",
"globals": "^17.4.0",
+1 -1
View File
@@ -55,7 +55,7 @@ process.stdin.on('end', () => {
const outputTokens = toNumber(usage.output_tokens || usage.completion_tokens || 0);
const model = String(input.model || input._cursor?.model || process.env.CLAUDE_MODEL || 'unknown');
const sessionId = String(process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || 'default');
const sessionId = String(process.env.CLAUDE_SESSION_ID || 'default');
const metricsDir = path.join(getClaudeDir(), 'metrics');
ensureDir(metricsDir);
-611
View File
@@ -1,611 +0,0 @@
#!/usr/bin/env node
/**
* Session Activity Tracker Hook
*
* PostToolUse hook that records sanitized per-tool activity to
* ~/.claude/metrics/tool-usage.jsonl for ECC2 metric sync.
*/
'use strict';
const crypto = require('crypto');
const path = require('path');
const { spawnSync } = require('child_process');
const {
appendFile,
getClaudeDir,
stripAnsi,
} = require('../lib/utils');
const MAX_STDIN = 1024 * 1024;
const METRICS_FILE_NAME = 'tool-usage.jsonl';
const FILE_PATH_KEYS = new Set([
'file_path',
'file_paths',
'source_path',
'destination_path',
'old_file_path',
'new_file_path',
]);
function redactSecrets(value) {
return String(value || '')
.replace(/\n/g, ' ')
.replace(/--token[= ][^ ]*/g, '--token=<REDACTED>')
.replace(/Authorization:[: ]*[^ ]*[: ]*[^ ]*/gi, 'Authorization:<REDACTED>')
.replace(/\bAKIA[A-Z0-9]{16}\b/g, '<REDACTED>')
.replace(/\bASIA[A-Z0-9]{16}\b/g, '<REDACTED>')
.replace(/password[= ][^ ]*/gi, 'password=<REDACTED>')
.replace(/\bghp_[A-Za-z0-9_]+\b/g, '<REDACTED>')
.replace(/\bgho_[A-Za-z0-9_]+\b/g, '<REDACTED>')
.replace(/\bghs_[A-Za-z0-9_]+\b/g, '<REDACTED>')
.replace(/\bgithub_pat_[A-Za-z0-9_]+\b/g, '<REDACTED>');
}
function truncateSummary(value, maxLength = 220) {
const normalized = stripAnsi(redactSecrets(value)).trim().replace(/\s+/g, ' ');
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength - 3)}...`;
}
function sanitizeParamValue(value, depth = 0) {
if (depth >= 4) {
return '[Truncated]';
}
if (value == null) {
return value;
}
if (typeof value === 'string') {
return truncateSummary(value, 160);
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value;
}
if (Array.isArray(value)) {
return value.slice(0, 8).map(entry => sanitizeParamValue(entry, depth + 1));
}
if (typeof value === 'object') {
const output = {};
for (const [key, nested] of Object.entries(value).slice(0, 20)) {
output[key] = sanitizeParamValue(nested, depth + 1);
}
return output;
}
return truncateSummary(String(value), 160);
}
function sanitizeInputParams(toolInput) {
if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) {
return '{}';
}
try {
return JSON.stringify(sanitizeParamValue(toolInput));
} catch {
return '{}';
}
}
function pushPathCandidate(paths, value) {
const candidate = String(value || '').trim();
if (!candidate) {
return;
}
if (/^(https?:\/\/|app:\/\/|plugin:\/\/|mcp:\/\/)/i.test(candidate)) {
return;
}
if (!paths.includes(candidate)) {
paths.push(candidate);
}
}
function pushFileEvent(events, value, action, diffPreview, patchPreview) {
const candidate = String(value || '').trim();
if (!candidate) {
return;
}
if (/^(https?:\/\/|app:\/\/|plugin:\/\/|mcp:\/\/)/i.test(candidate)) {
return;
}
const normalizedDiffPreview = typeof diffPreview === 'string' && diffPreview.trim()
? diffPreview.trim()
: undefined;
const normalizedPatchPreview = typeof patchPreview === 'string' && patchPreview.trim()
? patchPreview.trim()
: undefined;
if (!events.some(event =>
event.path === candidate
&& event.action === action
&& (event.diff_preview || undefined) === normalizedDiffPreview
&& (event.patch_preview || undefined) === normalizedPatchPreview
)) {
const event = { path: candidate, action };
if (normalizedDiffPreview) {
event.diff_preview = normalizedDiffPreview;
}
if (normalizedPatchPreview) {
event.patch_preview = normalizedPatchPreview;
}
events.push(event);
}
}
function sanitizeDiffText(value, maxLength = 96) {
if (typeof value !== 'string' || !value.trim()) {
return '';
}
return truncateSummary(value, maxLength);
}
function sanitizePatchLines(value, maxLines = 4, maxLineLength = 120) {
if (typeof value !== 'string' || !value.trim()) {
return [];
}
return stripAnsi(redactSecrets(value))
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean)
.slice(0, maxLines)
.map(line => line.length <= maxLineLength ? line : `${line.slice(0, maxLineLength - 3)}...`);
}
function buildReplacementPreview(oldValue, newValue) {
const before = sanitizeDiffText(oldValue);
const after = sanitizeDiffText(newValue);
if (!before && !after) {
return undefined;
}
if (!before) {
return `-> ${after}`;
}
if (!after) {
return `${before} ->`;
}
return `${before} -> ${after}`;
}
function buildCreationPreview(content) {
const normalized = sanitizeDiffText(content);
if (!normalized) {
return undefined;
}
return `+ ${normalized}`;
}
function buildPatchPreviewFromReplacement(oldValue, newValue) {
const beforeLines = sanitizePatchLines(oldValue);
const afterLines = sanitizePatchLines(newValue);
if (beforeLines.length === 0 && afterLines.length === 0) {
return undefined;
}
const lines = ['@@'];
for (const line of beforeLines) {
lines.push(`- ${line}`);
}
for (const line of afterLines) {
lines.push(`+ ${line}`);
}
return lines.join('\n');
}
function buildPatchPreviewFromContent(content, prefix) {
const lines = sanitizePatchLines(content);
if (lines.length === 0) {
return undefined;
}
return lines.map(line => `${prefix} ${line}`).join('\n');
}
function buildDiffPreviewFromPatchPreview(patchPreview) {
if (typeof patchPreview !== 'string' || !patchPreview.trim()) {
return undefined;
}
const lines = patchPreview
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean);
const removed = lines.find(line => line.startsWith('- ') || line.startsWith('-'));
const added = lines.find(line => line.startsWith('+ ') || line.startsWith('+'));
if (!removed && !added) {
return undefined;
}
const before = removed ? removed.replace(/^- ?/, '') : '';
const after = added ? added.replace(/^\+ ?/, '') : '';
if (before && after) {
return `${before} -> ${after}`;
}
if (before) {
return `${before} ->`;
}
return `-> ${after}`;
}
function inferDefaultFileAction(toolName) {
const normalized = String(toolName || '').trim().toLowerCase();
if (normalized.includes('read')) {
return 'read';
}
if (normalized.includes('write')) {
return 'create';
}
if (normalized.includes('edit')) {
return 'modify';
}
if (normalized.includes('delete') || normalized.includes('remove')) {
return 'delete';
}
if (normalized.includes('move') || normalized.includes('rename')) {
return 'move';
}
return 'touch';
}
function actionForFileKey(toolName, key) {
if (key === 'source_path' || key === 'old_file_path') {
return 'move';
}
if (key === 'destination_path' || key === 'new_file_path') {
return 'move';
}
return inferDefaultFileAction(toolName);
}
function collectFilePaths(value, paths) {
if (!value) {
return;
}
if (Array.isArray(value)) {
for (const entry of value) {
collectFilePaths(entry, paths);
}
return;
}
if (typeof value === 'string') {
pushPathCandidate(paths, value);
return;
}
if (typeof value !== 'object') {
return;
}
for (const [key, nested] of Object.entries(value)) {
if (FILE_PATH_KEYS.has(key)) {
collectFilePaths(nested, paths);
continue;
}
if (nested && (Array.isArray(nested) || typeof nested === 'object')) {
collectFilePaths(nested, paths);
}
}
}
function extractFilePaths(toolInput) {
const paths = [];
if (!toolInput || typeof toolInput !== 'object') {
return paths;
}
collectFilePaths(toolInput, paths);
return paths;
}
function fileEventDiffPreview(toolName, value, action) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
if (typeof value.old_string === 'string' || typeof value.new_string === 'string') {
return buildReplacementPreview(value.old_string, value.new_string);
}
if (action === 'create') {
return buildCreationPreview(value.content || value.file_text || value.text);
}
return undefined;
}
function fileEventPatchPreview(value, action) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
if (typeof value.old_string === 'string' || typeof value.new_string === 'string') {
return buildPatchPreviewFromReplacement(value.old_string, value.new_string);
}
if (action === 'create') {
return buildPatchPreviewFromContent(value.content || value.file_text || value.text, '+');
}
if (action === 'delete') {
return buildPatchPreviewFromContent(value.content || value.old_string || value.file_text, '-');
}
return undefined;
}
function runGit(args, cwd) {
const result = spawnSync('git', args, {
cwd,
encoding: 'utf8',
timeout: 2500,
});
if (result.error || result.status !== 0) {
return null;
}
return String(result.stdout || '').trim();
}
function gitRepoRoot(cwd) {
return runGit(['rev-parse', '--show-toplevel'], cwd);
}
function repoRelativePath(repoRoot, filePath) {
const absolute = path.isAbsolute(filePath)
? path.resolve(filePath)
: path.resolve(process.cwd(), filePath);
const relative = path.relative(repoRoot, absolute);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
return null;
}
return relative.split(path.sep).join('/');
}
function patchPreviewFromGitDiff(repoRoot, repoRelative) {
const patch = runGit(
['diff', '--no-ext-diff', '--no-color', '--unified=1', '--', repoRelative],
repoRoot
);
if (!patch) {
return undefined;
}
const relevant = patch
.split(/\r?\n/)
.filter(line =>
line.startsWith('@@')
|| (line.startsWith('+') && !line.startsWith('+++'))
|| (line.startsWith('-') && !line.startsWith('---'))
)
.slice(0, 6);
if (relevant.length === 0) {
return undefined;
}
return relevant.join('\n');
}
function trackedInGit(repoRoot, repoRelative) {
return runGit(['ls-files', '--error-unmatch', '--', repoRelative], repoRoot) !== null;
}
function enrichFileEventFromWorkingTree(toolName, event) {
if (!event || typeof event !== 'object' || !event.path) {
return event;
}
const repoRoot = gitRepoRoot(process.cwd());
if (!repoRoot) {
return event;
}
const repoRelative = repoRelativePath(repoRoot, event.path);
if (!repoRelative) {
return event;
}
const tool = String(toolName || '').trim().toLowerCase();
const tracked = trackedInGit(repoRoot, repoRelative);
const patchPreview = patchPreviewFromGitDiff(repoRoot, repoRelative) || event.patch_preview;
const diffPreview = buildDiffPreviewFromPatchPreview(patchPreview) || event.diff_preview;
if (tool.includes('write')) {
return {
...event,
action: tracked ? 'modify' : event.action,
diff_preview: diffPreview,
patch_preview: patchPreview,
};
}
if (tracked && patchPreview) {
return {
...event,
diff_preview: diffPreview,
patch_preview: patchPreview,
};
}
return event;
}
function collectFileEvents(toolName, value, events, key = null, parentValue = null) {
if (!value) {
return;
}
if (Array.isArray(value)) {
for (const entry of value) {
collectFileEvents(toolName, entry, events, key, parentValue);
}
return;
}
if (typeof value === 'string') {
if (key && FILE_PATH_KEYS.has(key)) {
const action = actionForFileKey(toolName, key);
pushFileEvent(
events,
value,
action,
fileEventDiffPreview(toolName, parentValue, action),
fileEventPatchPreview(parentValue, action)
);
}
return;
}
if (typeof value !== 'object') {
return;
}
for (const [nestedKey, nested] of Object.entries(value)) {
if (FILE_PATH_KEYS.has(nestedKey)) {
collectFileEvents(toolName, nested, events, nestedKey, value);
continue;
}
if (nested && (Array.isArray(nested) || typeof nested === 'object')) {
collectFileEvents(toolName, nested, events, null, nested);
}
}
}
function extractFileEvents(toolName, toolInput) {
const events = [];
if (!toolInput || typeof toolInput !== 'object') {
return events;
}
collectFileEvents(toolName, toolInput, events);
return events;
}
function summarizeInput(toolName, toolInput, filePaths) {
if (toolName === 'Bash') {
return truncateSummary(toolInput?.command || 'bash');
}
if (filePaths.length > 0) {
return truncateSummary(`${toolName} ${filePaths.join(', ')}`);
}
if (toolInput && typeof toolInput === 'object') {
const shallow = {};
for (const [key, value] of Object.entries(toolInput)) {
if (value == null) {
continue;
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
shallow[key] = value;
}
}
const serialized = Object.keys(shallow).length > 0 ? JSON.stringify(shallow) : toolName;
return truncateSummary(serialized);
}
return truncateSummary(toolName);
}
function summarizeOutput(toolOutput) {
if (toolOutput == null) {
return '';
}
if (typeof toolOutput === 'string') {
return truncateSummary(toolOutput);
}
if (typeof toolOutput === 'object' && typeof toolOutput.output === 'string') {
return truncateSummary(toolOutput.output);
}
return truncateSummary(JSON.stringify(toolOutput));
}
function buildActivityRow(input, env = process.env) {
const hookEvent = String(env.CLAUDE_HOOK_EVENT_NAME || '').trim();
if (hookEvent && hookEvent !== 'PostToolUse') {
return null;
}
const toolName = String(input?.tool_name || '').trim();
const sessionId = String(env.ECC_SESSION_ID || env.CLAUDE_SESSION_ID || '').trim();
if (!toolName || !sessionId) {
return null;
}
const toolInput = input?.tool_input || {};
const fileEvents = extractFileEvents(toolName, toolInput).map(event =>
enrichFileEventFromWorkingTree(toolName, event)
);
const filePaths = fileEvents.length > 0
? [...new Set(fileEvents.map(event => event.path))]
: extractFilePaths(toolInput);
return {
id: `tool-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`,
timestamp: new Date().toISOString(),
session_id: sessionId,
tool_name: toolName,
input_summary: summarizeInput(toolName, toolInput, filePaths),
input_params_json: sanitizeInputParams(toolInput),
output_summary: summarizeOutput(input?.tool_output),
duration_ms: 0,
file_paths: filePaths,
file_events: fileEvents,
};
}
function run(rawInput) {
try {
const input = rawInput.trim() ? JSON.parse(rawInput) : {};
const row = buildActivityRow(input);
if (row) {
appendFile(
path.join(getClaudeDir(), 'metrics', METRICS_FILE_NAME),
`${JSON.stringify(row)}\n`
);
}
} catch {
// Keep hook non-blocking.
}
return rawInput;
}
function main() {
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
process.stdout.write(run(raw));
});
}
if (require.main === module) {
main();
}
module.exports = {
buildActivityRow,
extractFileEvents,
extractFilePaths,
summarizeInput,
summarizeOutput,
run,
};
-21
View File
@@ -131,27 +131,6 @@ function runTests() {
fs.rmSync(tmpHome, { recursive: true, force: true });
}) ? passed++ : failed++);
// 6. Prefers ECC_SESSION_ID for ECC2 session correlation
(test('prefers ECC_SESSION_ID over CLAUDE_SESSION_ID when both are present', () => {
const tmpHome = makeTempDir();
const input = {
model: 'claude-sonnet-4-20250514',
usage: { input_tokens: 120, output_tokens: 30 },
};
const result = runScript(input, {
...withTempHome(tmpHome),
ECC_SESSION_ID: 'ecc-session-1234',
CLAUDE_SESSION_ID: 'claude-session-9999',
});
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.strictEqual(row.session_id, 'ecc-session-1234', 'Expected ECC_SESSION_ID to win');
fs.rmSync(tmpHome, { recursive: true, force: true });
}) ? passed++ : failed++);
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
@@ -1,360 +0,0 @@
/**
* Tests for session-activity-tracker.js hook.
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const script = path.join(
__dirname,
'..',
'..',
'scripts',
'hooks',
'session-activity-tracker.js'
);
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (err) {
console.log(`${name}`);
console.log(` Error: ${err.message}`);
return false;
}
}
function makeTempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'session-activity-tracker-test-'));
}
function withTempHome(homeDir) {
return {
HOME: homeDir,
USERPROFILE: homeDir,
};
}
function runScript(input, envOverrides = {}, options = {}) {
const inputStr = typeof input === 'string' ? input : JSON.stringify(input);
const result = spawnSync('node', [script], {
encoding: 'utf8',
input: inputStr,
timeout: 10000,
env: { ...process.env, ...envOverrides },
cwd: options.cwd,
});
return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '' };
}
function runTests() {
console.log('\n=== Testing session-activity-tracker.js ===\n');
let passed = 0;
let failed = 0;
(test('passes through input on stdout', () => {
const input = {
tool_name: 'Read',
tool_input: { file_path: 'README.md' },
tool_output: { output: 'ok' },
};
const inputStr = JSON.stringify(input);
const result = runScript(input, {
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
ECC_SESSION_ID: 'sess-123',
});
assert.strictEqual(result.code, 0);
assert.strictEqual(result.stdout, inputStr);
}) ? passed++ : failed++);
(test('creates tool activity metrics rows with file paths', () => {
const tmpHome = makeTempDir();
const input = {
tool_name: 'Write',
tool_input: {
file_path: 'src/app.rs',
},
tool_output: { output: 'wrote src/app.rs' },
};
const result = runScript(input, {
...withTempHome(tmpHome),
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
ECC_SESSION_ID: 'ecc-session-1234',
});
assert.strictEqual(result.code, 0);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl');
assert.ok(fs.existsSync(metricsFile), `Expected metrics file at ${metricsFile}`);
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.strictEqual(row.session_id, 'ecc-session-1234');
assert.strictEqual(row.tool_name, 'Write');
assert.strictEqual(row.input_params_json, '{"file_path":"src/app.rs"}');
assert.deepStrictEqual(row.file_paths, ['src/app.rs']);
assert.deepStrictEqual(row.file_events, [{ path: 'src/app.rs', action: 'create' }]);
assert.ok(row.id, 'Expected stable event id');
assert.ok(row.timestamp, 'Expected timestamp');
fs.rmSync(tmpHome, { recursive: true, force: true });
}) ? passed++ : failed++);
(test('captures typed move file events from source/destination inputs', () => {
const tmpHome = makeTempDir();
const input = {
tool_name: 'Move',
tool_input: {
source_path: 'src/old.rs',
destination_path: 'src/new.rs',
},
tool_output: { output: 'moved file' },
};
const result = runScript(input, {
...withTempHome(tmpHome),
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
ECC_SESSION_ID: 'ecc-session-5678',
});
assert.strictEqual(result.code, 0);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.deepStrictEqual(row.file_paths, ['src/old.rs', 'src/new.rs']);
assert.deepStrictEqual(row.file_events, [
{ path: 'src/old.rs', action: 'move' },
{ path: 'src/new.rs', action: 'move' },
]);
fs.rmSync(tmpHome, { recursive: true, force: true });
}) ? passed++ : failed++);
(test('captures replacement diff previews for edit tool input', () => {
const tmpHome = makeTempDir();
const input = {
tool_name: 'Edit',
tool_input: {
file_path: 'src/config.ts',
old_string: 'API_URL=http://localhost:3000',
new_string: 'API_URL=https://api.example.com',
},
tool_output: { output: 'updated config' },
};
const result = runScript(input, {
...withTempHome(tmpHome),
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
ECC_SESSION_ID: 'ecc-session-edit',
});
assert.strictEqual(result.code, 0);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.deepStrictEqual(row.file_events, [
{
path: 'src/config.ts',
action: 'modify',
diff_preview: 'API_URL=http://localhost:3000 -> API_URL=https://api.example.com',
patch_preview: '@@\n- API_URL=http://localhost:3000\n+ API_URL=https://api.example.com',
},
]);
fs.rmSync(tmpHome, { recursive: true, force: true });
}) ? passed++ : failed++);
(test('captures MultiEdit nested edits with typed diff previews', () => {
const tmpHome = makeTempDir();
const input = {
tool_name: 'MultiEdit',
tool_input: {
edits: [
{
file_path: 'src/a.ts',
old_string: 'const a = 1;',
new_string: 'const a = 2;',
},
{
file_path: 'src/b.ts',
old_string: 'old name',
new_string: 'new name',
},
],
},
tool_output: { output: 'updated two files' },
};
const result = runScript(input, {
...withTempHome(tmpHome),
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
ECC_SESSION_ID: 'ecc-session-multiedit',
});
assert.strictEqual(result.code, 0);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.deepStrictEqual(row.file_paths, ['src/a.ts', 'src/b.ts']);
assert.deepStrictEqual(row.file_events, [
{
path: 'src/a.ts',
action: 'modify',
diff_preview: 'const a = 1; -> const a = 2;',
patch_preview: '@@\n- const a = 1;\n+ const a = 2;',
},
{
path: 'src/b.ts',
action: 'modify',
diff_preview: 'old name -> new name',
patch_preview: '@@\n- old name\n+ new name',
},
]);
fs.rmSync(tmpHome, { recursive: true, force: true });
}) ? passed++ : failed++);
(test('reclassifies tracked Write activity as modify using git diff context', () => {
const tmpHome = makeTempDir();
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-activity-tracker-repo-'));
spawnSync('git', ['init'], { cwd: repoDir, encoding: 'utf8' });
spawnSync('git', ['config', 'user.email', 'ecc@example.com'], { cwd: repoDir, encoding: 'utf8' });
spawnSync('git', ['config', 'user.name', 'ECC Tests'], { cwd: repoDir, encoding: 'utf8' });
const srcDir = path.join(repoDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
const trackedFile = path.join(srcDir, 'app.ts');
fs.writeFileSync(trackedFile, 'const count = 1;\n', 'utf8');
spawnSync('git', ['add', 'src/app.ts'], { cwd: repoDir, encoding: 'utf8' });
spawnSync('git', ['commit', '-m', 'init'], { cwd: repoDir, encoding: 'utf8' });
fs.writeFileSync(trackedFile, 'const count = 2;\n', 'utf8');
const input = {
tool_name: 'Write',
tool_input: {
file_path: 'src/app.ts',
content: 'const count = 2;\n',
},
tool_output: { output: 'updated src/app.ts' },
};
const result = runScript(input, {
...withTempHome(tmpHome),
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
ECC_SESSION_ID: 'ecc-session-write-modify',
}, {
cwd: repoDir,
});
assert.strictEqual(result.code, 0);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.deepStrictEqual(row.file_events, [
{
path: 'src/app.ts',
action: 'modify',
diff_preview: 'const count = 1; -> const count = 2;',
patch_preview: '@@ -1 +1 @@\n-const count = 1;\n+const count = 2;',
},
]);
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(repoDir, { recursive: true, force: true });
}) ? passed++ : failed++);
(test('captures tracked Delete activity using git diff context', () => {
const tmpHome = makeTempDir();
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-activity-tracker-delete-repo-'));
spawnSync('git', ['init'], { cwd: repoDir, encoding: 'utf8' });
spawnSync('git', ['config', 'user.email', 'ecc@example.com'], { cwd: repoDir, encoding: 'utf8' });
spawnSync('git', ['config', 'user.name', 'ECC Tests'], { cwd: repoDir, encoding: 'utf8' });
const srcDir = path.join(repoDir, 'src');
fs.mkdirSync(srcDir, { recursive: true });
const trackedFile = path.join(srcDir, 'obsolete.ts');
fs.writeFileSync(trackedFile, 'export const obsolete = true;\n', 'utf8');
spawnSync('git', ['add', 'src/obsolete.ts'], { cwd: repoDir, encoding: 'utf8' });
spawnSync('git', ['commit', '-m', 'init'], { cwd: repoDir, encoding: 'utf8' });
fs.rmSync(trackedFile, { force: true });
const input = {
tool_name: 'Delete',
tool_input: {
file_path: 'src/obsolete.ts',
},
tool_output: { output: 'deleted src/obsolete.ts' },
};
const result = runScript(input, {
...withTempHome(tmpHome),
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
ECC_SESSION_ID: 'ecc-session-delete',
}, {
cwd: repoDir,
});
assert.strictEqual(result.code, 0);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.deepStrictEqual(row.file_events, [
{
path: 'src/obsolete.ts',
action: 'delete',
diff_preview: 'export const obsolete = true; ->',
patch_preview: '@@ -1 +0,0 @@\n-export const obsolete = true;',
},
]);
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(repoDir, { recursive: true, force: true });
}) ? passed++ : failed++);
(test('prefers ECC_SESSION_ID over CLAUDE_SESSION_ID and redacts bash summaries', () => {
const tmpHome = makeTempDir();
const input = {
tool_name: 'Bash',
tool_input: {
command: 'curl --token abc123 -H "Authorization: Bearer topsecret" https://example.com',
},
tool_output: { output: 'done' },
};
const result = runScript(input, {
...withTempHome(tmpHome),
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
ECC_SESSION_ID: 'ecc-session-1',
CLAUDE_SESSION_ID: 'claude-session-2',
});
assert.strictEqual(result.code, 0);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.strictEqual(row.session_id, 'ecc-session-1');
assert.ok(row.input_summary.includes('<REDACTED>'));
assert.ok(!row.input_summary.includes('abc123'));
assert.ok(!row.input_summary.includes('topsecret'));
assert.ok(row.input_params_json.includes('<REDACTED>'));
assert.ok(!row.input_params_json.includes('abc123'));
assert.ok(!row.input_params_json.includes('topsecret'));
fs.rmSync(tmpHome, { recursive: true, force: true });
}) ? passed++ : failed++);
(test('handles invalid JSON gracefully', () => {
const tmpHome = makeTempDir();
const invalidInput = 'not valid json {{{';
const result = runScript(invalidInput, {
...withTempHome(tmpHome),
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
ECC_SESSION_ID: 'sess-123',
});
assert.strictEqual(result.code, 0);
assert.strictEqual(result.stdout, invalidInput);
fs.rmSync(tmpHome, { recursive: true, force: true });
}) ? passed++ : failed++);
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();
+10 -10
View File
@@ -240,12 +240,12 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:^25.6.0":
version: 25.6.0
resolution: "@types/node@npm:25.6.0"
"@types/node@npm:^20.19.24":
version: 20.19.39
resolution: "@types/node@npm:20.19.39"
dependencies:
undici-types: "npm:~7.19.0"
checksum: 10c0/d2d2015630ff098a201407f55f5077a20270ae4f465c739b40865cd9933b91b9c5d2b85568eadaf3db0801b91e267333ca7eb39f007428b173d1cdab4b339ac5
undici-types: "npm:~6.21.0"
checksum: 10c0/1d16da7b5f47a7415b827fcf3b94d279febf4c14671afec74a03e47856b5270023d9beb1b9aeab4d3b622fd97d61a60206cfc2cca588663181331bc592468289
languageName: node
linkType: hard
@@ -549,7 +549,7 @@ __metadata:
"@eslint/js": "npm:^9.39.2"
"@iarna/toml": "npm:^2.2.5"
"@opencode-ai/plugin": "npm:^1.0.0"
"@types/node": "npm:^25.6.0"
"@types/node": "npm:^20.19.24"
ajv: "npm:^8.18.0"
c8: "npm:^11.0.0"
eslint: "npm:^9.39.2"
@@ -1810,10 +1810,10 @@ __metadata:
languageName: node
linkType: hard
"undici-types@npm:~7.19.0":
version: 7.19.2
resolution: "undici-types@npm:7.19.2"
checksum: 10c0/7159f10546f9f6c47d36776bb1bbf8671e87c1e587a6fee84ae1f111ae8de4f914efa8ca0dfcd224f4f4a9dfc3f6028f627ccb5ddaccf82d7fd54671b89fac3e
"undici-types@npm:~6.21.0":
version: 6.21.0
resolution: "undici-types@npm:6.21.0"
checksum: 10c0/c01ed51829b10aa72fc3ce64b747f8e74ae9b60eafa19a7b46ef624403508a54c526ffab06a14a26b3120d055e1104d7abe7c9017e83ced038ea5cf52f8d5e04
languageName: node
linkType: hard