mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-15 12:41:26 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bdf88e5ad | |||
| 7992f8fcb8 |
@@ -411,6 +411,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,
|
||||
|
||||
+805
-2
@@ -19,8 +19,8 @@ use super::{
|
||||
ContextGraphObservation, ContextGraphRecallEntry, ContextGraphRelation, ContextGraphSyncStats,
|
||||
ContextObservationPriority, DecisionLogEntry, FileActivityAction, FileActivityEntry,
|
||||
HarnessKind, RemoteDispatchKind, RemoteDispatchRequest, RemoteDispatchStatus, ScheduledTask,
|
||||
Session, SessionAgentProfile, SessionHarnessInfo, SessionMessage, SessionMetrics, SessionState,
|
||||
WorktreeInfo,
|
||||
Session, SessionAgentProfile, SessionBoardMeta, SessionHarnessInfo, SessionMessage,
|
||||
SessionMetrics, SessionState, WorktreeInfo,
|
||||
};
|
||||
|
||||
pub struct StateStore {
|
||||
@@ -241,6 +241,28 @@ impl StateStore {
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_board (
|
||||
session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
lane TEXT NOT NULL,
|
||||
project TEXT,
|
||||
feature TEXT,
|
||||
issue TEXT,
|
||||
row_label TEXT,
|
||||
previous_lane TEXT,
|
||||
previous_row_label TEXT,
|
||||
column_index INTEGER NOT NULL DEFAULT 0,
|
||||
row_index INTEGER NOT NULL DEFAULT 0,
|
||||
stack_index INTEGER NOT NULL DEFAULT 0,
|
||||
progress_percent INTEGER NOT NULL DEFAULT 0,
|
||||
status_detail TEXT,
|
||||
movement_note TEXT,
|
||||
activity_kind TEXT,
|
||||
activity_note TEXT,
|
||||
handoff_backlog INTEGER NOT NULL DEFAULT 0,
|
||||
conflict_signal TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS decision_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
@@ -386,6 +408,9 @@ impl StateStore {
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_session, read);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_output_session
|
||||
ON session_output(session_id, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_board_lane ON session_board(lane);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_board_coords
|
||||
ON session_board(column_index, row_index, stack_index);
|
||||
CREATE INDEX IF NOT EXISTS idx_decision_log_session
|
||||
ON decision_log(session_id, timestamp, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_context_graph_entities_session
|
||||
@@ -409,6 +434,8 @@ impl StateStore {
|
||||
",
|
||||
)?;
|
||||
self.ensure_session_columns()?;
|
||||
self.ensure_session_board_columns()?;
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -482,6 +509,51 @@ impl StateStore {
|
||||
.context("Failed to add output_tokens column to sessions table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("sessions", "tokens_used")? {
|
||||
self.conn
|
||||
.execute(
|
||||
"ALTER TABLE sessions ADD COLUMN tokens_used INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.context("Failed to add tokens_used column to sessions table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("sessions", "tool_calls")? {
|
||||
self.conn
|
||||
.execute(
|
||||
"ALTER TABLE sessions ADD COLUMN tool_calls INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.context("Failed to add tool_calls column to sessions table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("sessions", "files_changed")? {
|
||||
self.conn
|
||||
.execute(
|
||||
"ALTER TABLE sessions ADD COLUMN files_changed INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.context("Failed to add files_changed column to sessions table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("sessions", "duration_secs")? {
|
||||
self.conn
|
||||
.execute(
|
||||
"ALTER TABLE sessions ADD COLUMN duration_secs INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.context("Failed to add duration_secs column to sessions table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("sessions", "cost_usd")? {
|
||||
self.conn
|
||||
.execute(
|
||||
"ALTER TABLE sessions ADD COLUMN cost_usd REAL NOT NULL DEFAULT 0.0",
|
||||
[],
|
||||
)
|
||||
.context("Failed to add cost_usd column to sessions table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("sessions", "last_heartbeat_at")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE sessions ADD COLUMN last_heartbeat_at TEXT", [])
|
||||
@@ -496,6 +568,24 @@ impl StateStore {
|
||||
.context("Failed to backfill last_heartbeat_at column")?;
|
||||
}
|
||||
|
||||
if !self.has_column("sessions", "worktree_path")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE sessions ADD COLUMN worktree_path TEXT", [])
|
||||
.context("Failed to add worktree_path column to sessions table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("sessions", "worktree_branch")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE sessions ADD COLUMN worktree_branch TEXT", [])
|
||||
.context("Failed to add worktree_branch column to sessions table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("sessions", "worktree_base")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE sessions ADD COLUMN worktree_base TEXT", [])
|
||||
.context("Failed to add worktree_base column to sessions table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("tool_log", "hook_event_id")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE tool_log ADD COLUMN hook_event_id TEXT", [])
|
||||
@@ -712,6 +802,103 @@ impl StateStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_session_board_columns(&self) -> Result<()> {
|
||||
if !self.has_column("session_board", "row_label")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE session_board ADD COLUMN row_label TEXT", [])
|
||||
.context("Failed to add row_label column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "previous_lane")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE session_board ADD COLUMN previous_lane TEXT", [])
|
||||
.context("Failed to add previous_lane column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "previous_row_label")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE session_board ADD COLUMN previous_row_label TEXT", [])
|
||||
.context("Failed to add previous_row_label column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "column_index")? {
|
||||
self.conn
|
||||
.execute(
|
||||
"ALTER TABLE session_board ADD COLUMN column_index INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.context("Failed to add column_index column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "row_index")? {
|
||||
self.conn
|
||||
.execute(
|
||||
"ALTER TABLE session_board ADD COLUMN row_index INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.context("Failed to add row_index column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "stack_index")? {
|
||||
self.conn
|
||||
.execute(
|
||||
"ALTER TABLE session_board ADD COLUMN stack_index INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.context("Failed to add stack_index column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "progress_percent")? {
|
||||
self.conn
|
||||
.execute(
|
||||
"ALTER TABLE session_board ADD COLUMN progress_percent INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.context("Failed to add progress_percent column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "status_detail")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE session_board ADD COLUMN status_detail TEXT", [])
|
||||
.context("Failed to add status_detail column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "movement_note")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE session_board ADD COLUMN movement_note TEXT", [])
|
||||
.context("Failed to add movement_note column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "activity_kind")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE session_board ADD COLUMN activity_kind TEXT", [])
|
||||
.context("Failed to add activity_kind column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "activity_note")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE session_board ADD COLUMN activity_note TEXT", [])
|
||||
.context("Failed to add activity_note column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "handoff_backlog")? {
|
||||
self.conn
|
||||
.execute(
|
||||
"ALTER TABLE session_board ADD COLUMN handoff_backlog INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.context("Failed to add handoff_backlog column to session_board table")?;
|
||||
}
|
||||
|
||||
if !self.has_column("session_board", "conflict_signal")? {
|
||||
self.conn
|
||||
.execute("ALTER TABLE session_board ADD COLUMN conflict_signal TEXT", [])
|
||||
.context("Failed to add conflict_signal column to session_board table")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn has_column(&self, table: &str, column: &str) -> Result<bool> {
|
||||
let pragma = format!("PRAGMA table_info({table})");
|
||||
let mut stmt = self.conn.prepare(&pragma)?;
|
||||
@@ -789,6 +976,7 @@ impl StateStore {
|
||||
session.last_heartbeat_at.to_rfc3339(),
|
||||
],
|
||||
)?;
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -909,6 +1097,7 @@ impl StateStore {
|
||||
anyhow::bail!("Session not found: {session_id}");
|
||||
}
|
||||
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -949,6 +1138,7 @@ impl StateStore {
|
||||
anyhow::bail!("Session not found: {session_id}");
|
||||
}
|
||||
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -970,6 +1160,7 @@ impl StateStore {
|
||||
anyhow::bail!("Session not found: {session_id}");
|
||||
}
|
||||
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1003,6 +1194,7 @@ impl StateStore {
|
||||
anyhow::bail!("Session not found: {session_id}");
|
||||
}
|
||||
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1030,6 +1222,7 @@ impl StateStore {
|
||||
anyhow::bail!("Session not found: {session_id}");
|
||||
}
|
||||
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1386,6 +1579,7 @@ impl StateStore {
|
||||
session_id,
|
||||
],
|
||||
)?;
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1437,6 +1631,7 @@ impl StateStore {
|
||||
}
|
||||
}
|
||||
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1522,6 +1717,7 @@ impl StateStore {
|
||||
)?;
|
||||
}
|
||||
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1876,6 +2072,7 @@ impl StateStore {
|
||||
WHERE id = ?2",
|
||||
rusqlite::params![chrono::Utc::now().to_rfc3339(), session_id],
|
||||
)?;
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1979,6 +2176,46 @@ impl StateStore {
|
||||
Ok(harnesses)
|
||||
}
|
||||
|
||||
pub fn list_session_board_meta(&self) -> Result<HashMap<String, SessionBoardMeta>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT session_id, lane, project, feature, issue, row_label,
|
||||
previous_lane, previous_row_label,
|
||||
column_index, row_index, stack_index, progress_percent,
|
||||
status_detail, movement_note, activity_kind, activity_note,
|
||||
handoff_backlog, conflict_signal
|
||||
FROM session_board",
|
||||
)?;
|
||||
|
||||
let meta = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
SessionBoardMeta {
|
||||
lane: row.get(1)?,
|
||||
project: row.get(2)?,
|
||||
feature: row.get(3)?,
|
||||
issue: row.get(4)?,
|
||||
row_label: row.get(5)?,
|
||||
previous_lane: row.get(6)?,
|
||||
previous_row_label: row.get(7)?,
|
||||
column_index: row.get(8)?,
|
||||
row_index: row.get(9)?,
|
||||
stack_index: row.get(10)?,
|
||||
progress_percent: row.get(11)?,
|
||||
status_detail: row.get(12)?,
|
||||
movement_note: row.get(13)?,
|
||||
activity_kind: row.get(14)?,
|
||||
activity_note: row.get(15)?,
|
||||
handoff_backlog: row.get(16)?,
|
||||
conflict_signal: row.get(17)?,
|
||||
},
|
||||
))
|
||||
})?
|
||||
.collect::<Result<HashMap<_, _>, _>>()?;
|
||||
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
pub fn get_session_harness_info(&self, session_id: &str) -> Result<Option<SessionHarnessInfo>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT harness, detected_harnesses_json, agent_type, working_dir
|
||||
@@ -2008,6 +2245,94 @@ impl StateStore {
|
||||
Ok(self.list_sessions()?.into_iter().next())
|
||||
}
|
||||
|
||||
fn refresh_session_board_meta(&self) -> Result<()> {
|
||||
self.conn.execute(
|
||||
"DELETE FROM session_board
|
||||
WHERE session_id NOT IN (SELECT id FROM sessions)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
let existing_meta = self.list_session_board_meta().unwrap_or_default();
|
||||
let sessions = self.list_sessions()?;
|
||||
let board_meta = derive_board_meta_map(&sessions);
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
for session in sessions {
|
||||
let mut meta = board_meta
|
||||
.get(&session.id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| SessionBoardMeta {
|
||||
lane: board_lane_for_state(&session.state).to_string(),
|
||||
..SessionBoardMeta::default()
|
||||
});
|
||||
if let Some(previous) = existing_meta.get(&session.id) {
|
||||
annotate_board_motion(&mut meta, previous);
|
||||
}
|
||||
if let Some((activity_kind, activity_note)) =
|
||||
self.latest_task_handoff_activity(&session.id)?
|
||||
{
|
||||
meta.activity_kind = Some(activity_kind);
|
||||
meta.activity_note = Some(activity_note);
|
||||
} else {
|
||||
meta.activity_kind = None;
|
||||
meta.activity_note = None;
|
||||
}
|
||||
meta.handoff_backlog = self.unread_task_handoff_count(&session.id)? as i64;
|
||||
|
||||
self.conn.execute(
|
||||
"INSERT INTO session_board (
|
||||
session_id, lane, project, feature, issue, row_label,
|
||||
previous_lane, previous_row_label,
|
||||
column_index, row_index, stack_index, progress_percent,
|
||||
status_detail, movement_note, activity_kind, activity_note,
|
||||
handoff_backlog, conflict_signal, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
lane = excluded.lane,
|
||||
project = excluded.project,
|
||||
feature = excluded.feature,
|
||||
issue = excluded.issue,
|
||||
row_label = excluded.row_label,
|
||||
previous_lane = excluded.previous_lane,
|
||||
previous_row_label = excluded.previous_row_label,
|
||||
column_index = excluded.column_index,
|
||||
row_index = excluded.row_index,
|
||||
stack_index = excluded.stack_index,
|
||||
progress_percent = excluded.progress_percent,
|
||||
status_detail = excluded.status_detail,
|
||||
movement_note = excluded.movement_note,
|
||||
activity_kind = excluded.activity_kind,
|
||||
activity_note = excluded.activity_note,
|
||||
handoff_backlog = excluded.handoff_backlog,
|
||||
conflict_signal = excluded.conflict_signal,
|
||||
updated_at = excluded.updated_at",
|
||||
rusqlite::params![
|
||||
session.id,
|
||||
meta.lane,
|
||||
meta.project,
|
||||
meta.feature,
|
||||
meta.issue,
|
||||
meta.row_label,
|
||||
meta.previous_lane,
|
||||
meta.previous_row_label,
|
||||
meta.column_index,
|
||||
meta.row_index,
|
||||
meta.stack_index,
|
||||
meta.progress_percent,
|
||||
meta.status_detail,
|
||||
meta.movement_note,
|
||||
meta.activity_kind,
|
||||
meta.activity_note,
|
||||
meta.handoff_backlog,
|
||||
meta.conflict_signal,
|
||||
now,
|
||||
],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_session(&self, id: &str) -> Result<Option<Session>> {
|
||||
let sessions = self.list_sessions()?;
|
||||
Ok(sessions
|
||||
@@ -2038,6 +2363,7 @@ impl StateStore {
|
||||
anyhow::bail!("Session not found: {session_id}");
|
||||
}
|
||||
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2048,6 +2374,7 @@ impl StateStore {
|
||||
rusqlite::params![from, to, content, msg_type, chrono::Utc::now().to_rfc3339()],
|
||||
)?;
|
||||
self.sync_context_graph_message(from, to, content, msg_type)?;
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2318,6 +2645,7 @@ impl StateStore {
|
||||
rusqlite::params![session_id],
|
||||
)?;
|
||||
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
@@ -2327,6 +2655,7 @@ impl StateStore {
|
||||
rusqlite::params![message_id],
|
||||
)?;
|
||||
|
||||
self.refresh_session_board_meta()?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
@@ -2345,6 +2674,75 @@ impl StateStore {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
fn latest_task_handoff_activity(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Result<Option<(String, String)>> {
|
||||
let latest_handoff = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT from_session, to_session, content
|
||||
FROM messages
|
||||
WHERE msg_type = 'task_handoff'
|
||||
AND (from_session = ?1 OR to_session = ?1)
|
||||
ORDER BY id DESC
|
||||
LIMIT 1",
|
||||
rusqlite::params![session_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
Ok(latest_handoff.and_then(|(from_session, to_session, content)| {
|
||||
let context = extract_task_handoff_context(&content)?;
|
||||
let routing_suffix = routing_activity_suffix(&context);
|
||||
|
||||
if session_id == to_session {
|
||||
Some((
|
||||
"received".to_string(),
|
||||
format!(
|
||||
"Received from {}{}",
|
||||
short_session_ref(&from_session),
|
||||
routing_suffix
|
||||
.map(|value| format!(" | {value}"))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
))
|
||||
} else if session_id == from_session {
|
||||
let (kind, base) = match routing_suffix {
|
||||
Some("spawned") => {
|
||||
("spawned", format!("Spawned {}", short_session_ref(&to_session)))
|
||||
}
|
||||
Some("spawned fallback") => (
|
||||
"spawned_fallback",
|
||||
format!("Spawned fallback {}", short_session_ref(&to_session)),
|
||||
),
|
||||
_ => (
|
||||
"delegated",
|
||||
format!("Delegated to {}", short_session_ref(&to_session)),
|
||||
),
|
||||
};
|
||||
Some((
|
||||
kind.to_string(),
|
||||
format!(
|
||||
"{base}{}",
|
||||
routing_suffix
|
||||
.filter(|value| !value.starts_with("spawned"))
|
||||
.map(|value| format!(" | {value}"))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn insert_decision(
|
||||
&self,
|
||||
session_id: &str,
|
||||
@@ -3862,6 +4260,411 @@ fn file_activity_action_value(action: &FileActivityAction) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn board_lane_for_state(state: &SessionState) -> &'static str {
|
||||
match state {
|
||||
SessionState::Pending => "Inbox",
|
||||
SessionState::Running => "In Progress",
|
||||
SessionState::Idle => "Review",
|
||||
SessionState::Stale | SessionState::Failed => "Blocked",
|
||||
SessionState::Completed => "Done",
|
||||
SessionState::Stopped => "Stopped",
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_board_scope(session: &Session) -> (Option<String>, Option<String>, Option<String>) {
|
||||
let project = extract_labeled_scope(&session.task, &["project", "roadmap", "epic"]);
|
||||
let feature = extract_labeled_scope(&session.task, &["feature", "workflow", "flow"]);
|
||||
let issue = extract_issue_reference(&session.task);
|
||||
(project, feature, issue)
|
||||
}
|
||||
|
||||
fn derive_board_meta_map(sessions: &[Session]) -> HashMap<String, SessionBoardMeta> {
|
||||
let conflict_signals = derive_board_conflict_signals(sessions);
|
||||
let scopes = sessions
|
||||
.iter()
|
||||
.map(|session| (session.id.clone(), derive_board_scope(session)))
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
let mut row_specs = scopes
|
||||
.iter()
|
||||
.map(|(session_id, (project, feature, issue))| {
|
||||
let row_label = issue
|
||||
.clone()
|
||||
.or_else(|| feature.clone())
|
||||
.or_else(|| project.clone())
|
||||
.or_else(|| {
|
||||
sessions
|
||||
.iter()
|
||||
.find(|session| &session.id == session_id)
|
||||
.and_then(|session| session.worktree.as_ref())
|
||||
.map(|worktree| worktree.branch.clone())
|
||||
})
|
||||
.unwrap_or_else(|| "General".to_string());
|
||||
|
||||
let row_rank = if issue.is_some() {
|
||||
0
|
||||
} else if feature.is_some() {
|
||||
1
|
||||
} else if project.is_some() {
|
||||
2
|
||||
} else {
|
||||
3
|
||||
};
|
||||
|
||||
(session_id.clone(), row_label, row_rank)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
row_specs.sort_by(|left, right| {
|
||||
left.2
|
||||
.cmp(&right.2)
|
||||
.then_with(|| left.1.to_ascii_lowercase().cmp(&right.1.to_ascii_lowercase()))
|
||||
.then_with(|| left.0.cmp(&right.0))
|
||||
});
|
||||
|
||||
let mut row_indices = HashMap::new();
|
||||
let mut next_row_index = 0_i64;
|
||||
for (_, row_label, row_rank) in &row_specs {
|
||||
let key = (*row_rank, row_label.clone());
|
||||
if let std::collections::hash_map::Entry::Vacant(entry) = row_indices.entry(key) {
|
||||
entry.insert(next_row_index);
|
||||
next_row_index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut stack_counts: HashMap<(i64, i64), i64> = HashMap::new();
|
||||
let mut board_meta = HashMap::new();
|
||||
|
||||
for session in sessions {
|
||||
let (project, feature, issue) = scopes
|
||||
.get(&session.id)
|
||||
.cloned()
|
||||
.unwrap_or((None, None, None));
|
||||
let (_, row_label, row_rank) = row_specs
|
||||
.iter()
|
||||
.find(|(session_id, _, _)| session_id == &session.id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| (session.id.clone(), "General".to_string(), 4));
|
||||
let column_index = board_column_index(&session.state);
|
||||
let row_index = row_indices
|
||||
.get(&(row_rank, row_label.clone()))
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
let stack_index = {
|
||||
let entry = stack_counts.entry((column_index, row_index)).or_insert(0);
|
||||
let current = *entry;
|
||||
*entry += 1;
|
||||
current
|
||||
};
|
||||
|
||||
board_meta.insert(
|
||||
session.id.clone(),
|
||||
SessionBoardMeta {
|
||||
lane: board_lane_for_state(&session.state).to_string(),
|
||||
project,
|
||||
feature,
|
||||
issue,
|
||||
row_label: Some(row_label),
|
||||
previous_lane: None,
|
||||
previous_row_label: None,
|
||||
column_index,
|
||||
row_index,
|
||||
stack_index,
|
||||
progress_percent: derive_board_progress_percent(session),
|
||||
status_detail: derive_board_status_detail(session),
|
||||
movement_note: None,
|
||||
activity_kind: None,
|
||||
activity_note: None,
|
||||
handoff_backlog: 0,
|
||||
conflict_signal: conflict_signals.get(&session.id).cloned(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
board_meta
|
||||
}
|
||||
|
||||
fn board_column_index(state: &SessionState) -> i64 {
|
||||
match state {
|
||||
SessionState::Pending => 0,
|
||||
SessionState::Running => 1,
|
||||
SessionState::Idle => 2,
|
||||
SessionState::Stale | SessionState::Failed => 3,
|
||||
SessionState::Completed => 4,
|
||||
SessionState::Stopped => 5,
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_board_progress_percent(session: &Session) -> i64 {
|
||||
match session.state {
|
||||
SessionState::Pending => 10,
|
||||
SessionState::Running => {
|
||||
if session.metrics.files_changed > 0 {
|
||||
60
|
||||
} else if session.worktree.is_some() || session.metrics.tool_calls > 0 {
|
||||
45
|
||||
} else {
|
||||
25
|
||||
}
|
||||
}
|
||||
SessionState::Idle => 85,
|
||||
SessionState::Stale => 55,
|
||||
SessionState::Completed => 100,
|
||||
SessionState::Failed => 65,
|
||||
SessionState::Stopped => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn derive_board_status_detail(session: &Session) -> Option<String> {
|
||||
let detail = match session.state {
|
||||
SessionState::Pending => "Queued",
|
||||
SessionState::Running => {
|
||||
if session.metrics.files_changed > 0 {
|
||||
"Actively editing"
|
||||
} else if session.worktree.is_some() {
|
||||
"Scoping"
|
||||
} else {
|
||||
"Booting"
|
||||
}
|
||||
}
|
||||
SessionState::Idle => "Awaiting review",
|
||||
SessionState::Stale => "Needs heartbeat",
|
||||
SessionState::Completed => "Task complete",
|
||||
SessionState::Failed => "Blocked by failure",
|
||||
SessionState::Stopped => "Stopped",
|
||||
};
|
||||
|
||||
Some(detail.to_string())
|
||||
}
|
||||
|
||||
fn annotate_board_motion(current: &mut SessionBoardMeta, previous: &SessionBoardMeta) {
|
||||
if previous.lane != current.lane {
|
||||
current.previous_lane = Some(previous.lane.clone());
|
||||
current.previous_row_label = previous.row_label.clone();
|
||||
current.movement_note = Some(match current.lane.as_str() {
|
||||
"Blocked" => "Blocked".to_string(),
|
||||
"Done" => "Completed".to_string(),
|
||||
_ => format!("Moved {} -> {}", previous.lane, current.lane),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if previous.row_label != current.row_label {
|
||||
let from = previous
|
||||
.row_label
|
||||
.clone()
|
||||
.unwrap_or_else(|| "General".to_string());
|
||||
let to = current
|
||||
.row_label
|
||||
.clone()
|
||||
.unwrap_or_else(|| "General".to_string());
|
||||
current.previous_lane = Some(previous.lane.clone());
|
||||
current.previous_row_label = previous.row_label.clone();
|
||||
current.movement_note = Some(format!("Retargeted {from} -> {to}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_labeled_scope(task: &str, labels: &[&str]) -> Option<String> {
|
||||
let lowered = task.to_ascii_lowercase();
|
||||
|
||||
for label in labels {
|
||||
if let Some(index) = lowered.find(label) {
|
||||
let mut tail = task.get(index + label.len()..)?.trim_start_matches([' ', ':', '-', '#']);
|
||||
if tail.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((candidate, _)) = tail
|
||||
.split_once('|')
|
||||
.or_else(|| tail.split_once(';'))
|
||||
.or_else(|| tail.split_once(','))
|
||||
.or_else(|| tail.split_once('\n'))
|
||||
{
|
||||
tail = candidate;
|
||||
}
|
||||
|
||||
let words = tail
|
||||
.split_whitespace()
|
||||
.take(4)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.trim()
|
||||
.trim_matches(|ch: char| matches!(ch, '.' | ',' | ';' | ':' | '|'))
|
||||
.to_string();
|
||||
|
||||
if !words.is_empty() {
|
||||
return Some(words);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_issue_reference(task: &str) -> Option<String> {
|
||||
let tokens = task
|
||||
.split(|ch: char| ch.is_whitespace() || matches!(ch, ',' | ';' | ':' | '(' | ')'))
|
||||
.filter(|token| !token.is_empty());
|
||||
|
||||
for token in tokens {
|
||||
if let Some(stripped) = token.strip_prefix('#') {
|
||||
if !stripped.is_empty() && stripped.chars().all(|ch| ch.is_ascii_digit()) {
|
||||
return Some(format!("#{stripped}"));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((prefix, suffix)) = token.split_once('-') {
|
||||
if !prefix.is_empty()
|
||||
&& !suffix.is_empty()
|
||||
&& prefix.chars().all(|ch| ch.is_ascii_uppercase())
|
||||
&& suffix.chars().all(|ch| ch.is_ascii_digit())
|
||||
{
|
||||
return Some(token.trim_matches('.').to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn derive_board_conflict_signals(sessions: &[Session]) -> HashMap<String, String> {
|
||||
let active_sessions = sessions
|
||||
.iter()
|
||||
.filter(|session| {
|
||||
matches!(
|
||||
session.state,
|
||||
SessionState::Pending | SessionState::Running | SessionState::Idle | SessionState::Stale
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut sessions_by_branch: HashMap<String, Vec<&Session>> = HashMap::new();
|
||||
let mut sessions_by_task: HashMap<String, Vec<&Session>> = HashMap::new();
|
||||
let mut sessions_by_scope: HashMap<String, Vec<&Session>> = HashMap::new();
|
||||
|
||||
for session in active_sessions {
|
||||
if let Some(worktree) = session.worktree.as_ref() {
|
||||
sessions_by_branch
|
||||
.entry(worktree.branch.clone())
|
||||
.or_default()
|
||||
.push(session);
|
||||
}
|
||||
|
||||
sessions_by_task
|
||||
.entry(session.task.trim().to_ascii_lowercase())
|
||||
.or_default()
|
||||
.push(session);
|
||||
|
||||
let (project, feature, issue) = derive_board_scope(session);
|
||||
if let Some(scope) = issue.or(feature).or(project).filter(|scope| !scope.is_empty()) {
|
||||
sessions_by_scope.entry(scope).or_default().push(session);
|
||||
}
|
||||
}
|
||||
|
||||
let mut signals = HashMap::new();
|
||||
|
||||
for (branch, grouped_sessions) in sessions_by_branch {
|
||||
if grouped_sessions.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
for session in grouped_sessions {
|
||||
append_conflict_signal(&mut signals, &session.id, format!("Shared branch {branch}"));
|
||||
}
|
||||
}
|
||||
|
||||
for (task, grouped_sessions) in sessions_by_task {
|
||||
if grouped_sessions.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
for session in grouped_sessions {
|
||||
append_conflict_signal(
|
||||
&mut signals,
|
||||
&session.id,
|
||||
format!("Shared task {}", truncate_task_for_signal(&task)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (scope, grouped_sessions) in sessions_by_scope {
|
||||
if grouped_sessions.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
for session in grouped_sessions {
|
||||
append_conflict_signal(
|
||||
&mut signals,
|
||||
&session.id,
|
||||
format!("Shared scope {}", truncate_task_for_signal(&scope)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
signals
|
||||
}
|
||||
|
||||
fn append_conflict_signal(
|
||||
signals: &mut HashMap<String, String>,
|
||||
session_id: &str,
|
||||
next_signal: String,
|
||||
) {
|
||||
let entry = signals.entry(session_id.to_string()).or_default();
|
||||
if entry.is_empty() {
|
||||
*entry = next_signal;
|
||||
return;
|
||||
}
|
||||
|
||||
if !entry.split("; ").any(|existing| existing == next_signal) {
|
||||
entry.push_str("; ");
|
||||
entry.push_str(&next_signal);
|
||||
}
|
||||
}
|
||||
|
||||
fn short_session_ref(session_id: &str) -> String {
|
||||
if session_id.chars().count() <= 12 {
|
||||
session_id.to_string()
|
||||
} else {
|
||||
session_id.chars().take(8).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn routing_activity_suffix(context: &str) -> Option<&'static str> {
|
||||
let normalized = context.to_ascii_lowercase();
|
||||
if normalized.contains("reused idle delegate") {
|
||||
Some("reused idle")
|
||||
} else if normalized.contains("reused active delegate") {
|
||||
Some("reused active")
|
||||
} else if normalized.contains("spawned fallback delegate") {
|
||||
Some("spawned fallback")
|
||||
} else if normalized.contains("spawned new delegate") {
|
||||
Some("spawned")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_task_handoff_context(content: &str) -> Option<String> {
|
||||
if let Some(crate::comms::MessageType::TaskHandoff { context, .. }) = crate::comms::parse(content)
|
||||
{
|
||||
return Some(context);
|
||||
}
|
||||
|
||||
let value: serde_json::Value = serde_json::from_str(content).ok()?;
|
||||
value
|
||||
.get("context")
|
||||
.and_then(|context| context.as_str())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn truncate_task_for_signal(task: &str) -> String {
|
||||
const LIMIT: usize = 28;
|
||||
let trimmed = task.trim();
|
||||
let count = trimmed.chars().count();
|
||||
if count <= LIMIT {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("{}...", trimmed.chars().take(LIMIT - 3).collect::<String>())
|
||||
}
|
||||
}
|
||||
|
||||
fn map_conflict_incident(row: &rusqlite::Row<'_>) -> rusqlite::Result<ConflictIncident> {
|
||||
let created_at = parse_timestamp_column(row.get::<_, String>(11)?, 11)?;
|
||||
let updated_at = parse_timestamp_column(row.get::<_, String>(12)?, 12)?;
|
||||
|
||||
+419
-10
@@ -24,7 +24,7 @@ use crate::session::output::{
|
||||
use crate::session::store::{DaemonActivity, FileActivityOverlap, StateStore};
|
||||
use crate::session::{
|
||||
ContextObservationPriority, DecisionLogEntry, FileActivityEntry, Session, SessionGrouping,
|
||||
SessionHarnessInfo, SessionMessage, SessionState,
|
||||
SessionBoardMeta, SessionHarnessInfo, SessionMessage, SessionState,
|
||||
};
|
||||
use crate::worktree;
|
||||
|
||||
@@ -93,6 +93,7 @@ pub struct Dashboard {
|
||||
approval_queue_counts: HashMap<String, usize>,
|
||||
approval_queue_preview: Vec<SessionMessage>,
|
||||
handoff_backlog_counts: HashMap<String, usize>,
|
||||
board_meta_by_session: HashMap<String, SessionBoardMeta>,
|
||||
worktree_health_by_session: HashMap<String, worktree::WorktreeHealth>,
|
||||
global_handoff_backlog_leads: usize,
|
||||
global_handoff_backlog_messages: usize,
|
||||
@@ -179,6 +180,7 @@ enum Pane {
|
||||
Sessions,
|
||||
Output,
|
||||
Metrics,
|
||||
Board,
|
||||
Log,
|
||||
}
|
||||
|
||||
@@ -333,7 +335,7 @@ impl PaneAreas {
|
||||
match pane {
|
||||
Pane::Sessions => self.sessions = area,
|
||||
Pane::Output => self.output = Some(area),
|
||||
Pane::Metrics => self.metrics = Some(area),
|
||||
Pane::Metrics | Pane::Board => self.metrics = Some(area),
|
||||
Pane::Log => self.log = Some(area),
|
||||
}
|
||||
}
|
||||
@@ -553,6 +555,7 @@ impl Dashboard {
|
||||
approval_queue_counts: HashMap::new(),
|
||||
approval_queue_preview: Vec::new(),
|
||||
handoff_backlog_counts: HashMap::new(),
|
||||
board_meta_by_session: HashMap::new(),
|
||||
worktree_health_by_session: HashMap::new(),
|
||||
global_handoff_backlog_leads: 0,
|
||||
global_handoff_backlog_messages: 0,
|
||||
@@ -619,6 +622,7 @@ impl Dashboard {
|
||||
dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default();
|
||||
dashboard.sync_approval_queue();
|
||||
dashboard.sync_handoff_backlog_counts();
|
||||
dashboard.sync_board_meta();
|
||||
dashboard.sync_global_handoff_backlog();
|
||||
dashboard.sync_selected_output();
|
||||
dashboard.sync_selected_diff();
|
||||
@@ -1294,10 +1298,18 @@ impl Dashboard {
|
||||
}
|
||||
|
||||
fn render_metrics(&mut self, frame: &mut Frame, area: Rect) {
|
||||
let side_pane = if self.selected_pane == Pane::Board {
|
||||
Pane::Board
|
||||
} else {
|
||||
Pane::Metrics
|
||||
};
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Metrics ")
|
||||
.border_style(self.pane_border_style(Pane::Metrics));
|
||||
.title(match side_pane {
|
||||
Pane::Board => " Board ",
|
||||
_ => " Metrics ",
|
||||
})
|
||||
.border_style(self.pane_border_style(side_pane));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
@@ -1305,6 +1317,17 @@ impl Dashboard {
|
||||
return;
|
||||
}
|
||||
|
||||
if side_pane == Pane::Board {
|
||||
frame.render_widget(
|
||||
Paragraph::new(self.board_text())
|
||||
.scroll((self.metrics_scroll_offset as u16, 0))
|
||||
.wrap(Wrap { trim: true }),
|
||||
inner,
|
||||
);
|
||||
self.sync_metrics_scroll(inner.height as usize);
|
||||
return;
|
||||
}
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
@@ -1620,7 +1643,7 @@ impl Dashboard {
|
||||
return;
|
||||
};
|
||||
|
||||
if !self.visible_panes().contains(&target) {
|
||||
if !self.is_pane_visible(target) {
|
||||
self.set_operator_note(format!(
|
||||
"{} pane is not visible",
|
||||
target.title().to_lowercase()
|
||||
@@ -1702,6 +1725,7 @@ impl Dashboard {
|
||||
crossterm::event::KeyCode::Char('2') => self.focus_pane_number(2),
|
||||
crossterm::event::KeyCode::Char('3') => self.focus_pane_number(3),
|
||||
crossterm::event::KeyCode::Char('4') => self.focus_pane_number(4),
|
||||
crossterm::event::KeyCode::Char('5') => self.focus_pane_number(5),
|
||||
crossterm::event::KeyCode::Char('+') | crossterm::event::KeyCode::Char('=') => {
|
||||
self.increase_pane_size()
|
||||
}
|
||||
@@ -2017,7 +2041,7 @@ impl Dashboard {
|
||||
self.output_scroll_offset = self.output_scroll_offset.saturating_add(1);
|
||||
}
|
||||
}
|
||||
Pane::Metrics => {
|
||||
Pane::Metrics | Pane::Board => {
|
||||
let max_scroll = self.max_metrics_scroll();
|
||||
self.metrics_scroll_offset =
|
||||
self.metrics_scroll_offset.saturating_add(1).min(max_scroll);
|
||||
@@ -2057,7 +2081,7 @@ impl Dashboard {
|
||||
|
||||
self.output_scroll_offset = self.output_scroll_offset.saturating_sub(1);
|
||||
}
|
||||
Pane::Metrics => {
|
||||
Pane::Metrics | Pane::Board => {
|
||||
self.metrics_scroll_offset = self.metrics_scroll_offset.saturating_sub(1);
|
||||
}
|
||||
Pane::Log => {
|
||||
@@ -4073,6 +4097,7 @@ impl Dashboard {
|
||||
};
|
||||
self.sync_approval_queue();
|
||||
self.sync_handoff_backlog_counts();
|
||||
self.sync_board_meta();
|
||||
self.sync_worktree_health_by_session();
|
||||
self.sync_session_state_notifications();
|
||||
self.sync_approval_notifications();
|
||||
@@ -4478,7 +4503,7 @@ impl Dashboard {
|
||||
}
|
||||
|
||||
fn ensure_selected_pane_visible(&mut self) {
|
||||
if !self.visible_panes().contains(&self.selected_pane) {
|
||||
if !self.is_pane_visible(self.selected_pane) {
|
||||
self.selected_pane = Pane::Sessions;
|
||||
}
|
||||
}
|
||||
@@ -4581,6 +4606,16 @@ impl Dashboard {
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_board_meta(&mut self) {
|
||||
self.board_meta_by_session = match self.db.list_session_board_meta() {
|
||||
Ok(meta) => meta,
|
||||
Err(error) => {
|
||||
tracing::warn!("Failed to refresh board metadata: {error}");
|
||||
HashMap::new()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn sync_worktree_health_by_session(&mut self) {
|
||||
self.worktree_health_by_session.clear();
|
||||
for session in &self.sessions {
|
||||
@@ -6497,6 +6532,268 @@ impl Dashboard {
|
||||
}
|
||||
}
|
||||
|
||||
fn board_text(&self) -> String {
|
||||
if self.sessions.is_empty() {
|
||||
return "No sessions available.\n\nStart a session to populate the board.".to_string();
|
||||
}
|
||||
|
||||
let mut lines = Vec::new();
|
||||
lines.push(format!("Board snapshot | {} sessions", self.sessions.len()));
|
||||
|
||||
if let Some(session) = self.sessions.get(self.selected_session) {
|
||||
let meta = self.board_meta_by_session.get(&session.id);
|
||||
let branch = session_branch(session);
|
||||
lines.push(format!(
|
||||
"Focus {} {} | {} | {}{}",
|
||||
board_presence_marker(session),
|
||||
board_codename(session),
|
||||
meta.map(|meta| meta.lane.as_str())
|
||||
.unwrap_or_else(|| board_lane_label(&session.state)),
|
||||
format_session_id(&session.id),
|
||||
if branch == "-" {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" | {branch}")
|
||||
}
|
||||
));
|
||||
lines.push(format!("Task {}", truncate_for_dashboard(&session.task, 48)));
|
||||
if let Some(meta) = meta {
|
||||
lines.push(format!(
|
||||
"Progress {:>3}% {}",
|
||||
meta.progress_percent,
|
||||
board_progress_bar(meta.progress_percent)
|
||||
));
|
||||
if let Some(status_detail) = meta.status_detail.as_ref() {
|
||||
lines.push(format!("Status {status_detail}"));
|
||||
}
|
||||
if let Some(movement_note) = meta.movement_note.as_ref() {
|
||||
lines.push(format!("Event {movement_note}"));
|
||||
}
|
||||
if meta.handoff_backlog > 0 {
|
||||
lines.push(format!("Inbox {} handoff(s)", meta.handoff_backlog));
|
||||
}
|
||||
if let Some(activity_note) = meta.activity_note.as_ref() {
|
||||
lines.push(format!("Route {activity_note}"));
|
||||
}
|
||||
lines.push(format!(
|
||||
"Coords C{} R{} S{}",
|
||||
meta.column_index + 1,
|
||||
meta.row_index + 1,
|
||||
meta.stack_index + 1
|
||||
));
|
||||
if let Some(row_label) = meta.row_label.as_ref() {
|
||||
lines.push(format!("Row {row_label}"));
|
||||
}
|
||||
if let Some(project) = meta.project.as_ref() {
|
||||
lines.push(format!("Project {project}"));
|
||||
}
|
||||
if let Some(feature) = meta.feature.as_ref() {
|
||||
lines.push(format!("Feature {feature}"));
|
||||
}
|
||||
if let Some(issue) = meta.issue.as_ref() {
|
||||
lines.push(format!("Issue {issue}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let overlap_risks = self.board_overlap_risks();
|
||||
if overlap_risks.is_empty() {
|
||||
lines.push("Overlap risk clear".to_string());
|
||||
} else {
|
||||
lines.push("Overlap risk".to_string());
|
||||
for risk in overlap_risks {
|
||||
lines.push(format!("- {risk}"));
|
||||
}
|
||||
}
|
||||
|
||||
let lanes = ["Inbox", "In Progress", "Review", "Blocked", "Done", "Stopped"];
|
||||
for label in lanes {
|
||||
let mut lane_sessions = self
|
||||
.sessions
|
||||
.iter()
|
||||
.filter_map(|session| {
|
||||
let lane = self
|
||||
.board_meta_by_session
|
||||
.get(&session.id)
|
||||
.map(|meta| meta.lane.as_str())
|
||||
.unwrap_or_else(|| board_lane_label(&session.state));
|
||||
if lane == label {
|
||||
Some((session, self.board_meta_by_session.get(&session.id)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if lane_sessions.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut row_risks: HashMap<(i64, String), Vec<String>> = HashMap::new();
|
||||
let mut row_backlogs: HashMap<(i64, String), i64> = HashMap::new();
|
||||
for (_, meta) in &lane_sessions {
|
||||
let Some(meta) = meta else {
|
||||
continue;
|
||||
};
|
||||
let key = (
|
||||
meta.row_index,
|
||||
meta.row_label
|
||||
.clone()
|
||||
.unwrap_or_else(|| "General".to_string()),
|
||||
);
|
||||
if let Some(conflict_signal) = meta.conflict_signal.as_ref() {
|
||||
let entry = row_risks.entry(key.clone()).or_default();
|
||||
for risk in conflict_signal.split("; ") {
|
||||
if !entry.iter().any(|existing| existing == risk) {
|
||||
entry.push(risk.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if meta.handoff_backlog > 0 {
|
||||
*row_backlogs.entry(key).or_default() += meta.handoff_backlog;
|
||||
}
|
||||
}
|
||||
|
||||
lane_sessions.sort_by(|left, right| {
|
||||
let left_meta = left.1.cloned().unwrap_or_default();
|
||||
let right_meta = right.1.cloned().unwrap_or_default();
|
||||
left_meta
|
||||
.row_index
|
||||
.cmp(&right_meta.row_index)
|
||||
.then_with(|| left_meta.stack_index.cmp(&right_meta.stack_index))
|
||||
.then_with(|| left.0.id.cmp(&right.0.id))
|
||||
});
|
||||
|
||||
lines.push(String::new());
|
||||
lines.push(format!("{label} ({})", lane_sessions.len()));
|
||||
let mut current_row: Option<String> = None;
|
||||
for (session, meta) in lane_sessions.into_iter().take(6) {
|
||||
let meta = meta.cloned().unwrap_or_default();
|
||||
let row_label = meta
|
||||
.row_label
|
||||
.clone()
|
||||
.unwrap_or_else(|| "General".to_string());
|
||||
if current_row.as_ref() != Some(&row_label) {
|
||||
current_row = Some(row_label.clone());
|
||||
let row_key = (meta.row_index, row_label.clone());
|
||||
let row_conflict_summary = row_risks
|
||||
.get(&row_key)
|
||||
.filter(|risks| !risks.is_empty())
|
||||
.map(|risks| truncate_for_dashboard(&risks.join(" + "), 42));
|
||||
let row_backlog = row_backlogs.get(&row_key).copied().unwrap_or(0);
|
||||
let row_pressure_summary = if row_backlog > 0 {
|
||||
Some(format!("{} handoff(s)", row_backlog))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let row_marker = if row_conflict_summary.is_some() {
|
||||
"!"
|
||||
} else if row_pressure_summary.is_some() {
|
||||
"+"
|
||||
} else {
|
||||
"-"
|
||||
};
|
||||
lines.push(format!(
|
||||
" {} Row {} | {}{}{}",
|
||||
row_marker,
|
||||
meta.row_index + 1,
|
||||
row_label,
|
||||
row_conflict_summary
|
||||
.map(|summary| format!(" | {summary}"))
|
||||
.unwrap_or_default(),
|
||||
row_pressure_summary
|
||||
.map(|summary| format!(" | {summary}"))
|
||||
.unwrap_or_default()
|
||||
));
|
||||
}
|
||||
let branch = session_branch(session);
|
||||
let branch_suffix = if branch == "-" {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" | {branch}")
|
||||
};
|
||||
let activity_suffix = meta
|
||||
.activity_note
|
||||
.as_ref()
|
||||
.map(|note| format!(" | {}", truncate_for_dashboard(note, 26)))
|
||||
.unwrap_or_default();
|
||||
let backlog_suffix = if meta.handoff_backlog > 0 {
|
||||
format!(" | inbox {}", meta.handoff_backlog)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let kind_marker = board_activity_marker(&meta);
|
||||
lines.push(format!(
|
||||
" {}{} {} {} {} [{}] {:>3}% {} | {}{}{}{}",
|
||||
board_motion_marker(&meta),
|
||||
kind_marker,
|
||||
board_presence_marker(session),
|
||||
board_codename(session),
|
||||
format_session_id(&session.id),
|
||||
session.agent_type,
|
||||
meta.progress_percent,
|
||||
board_progress_bar(meta.progress_percent),
|
||||
truncate_for_dashboard(meta.status_detail.as_deref().unwrap_or(&session.task), 18),
|
||||
activity_suffix,
|
||||
backlog_suffix,
|
||||
branch_suffix
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn board_overlap_risks(&self) -> Vec<String> {
|
||||
let mut risks = self
|
||||
.board_meta_by_session
|
||||
.values()
|
||||
.filter_map(|meta| meta.conflict_signal.clone())
|
||||
.collect::<Vec<_>>();
|
||||
if risks.is_empty() {
|
||||
let mut duplicate_branches: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let mut duplicate_tasks: HashMap<String, Vec<String>> = HashMap::new();
|
||||
|
||||
for session in self.sessions.iter().filter(|session| {
|
||||
matches!(
|
||||
session.state,
|
||||
SessionState::Pending
|
||||
| SessionState::Running
|
||||
| SessionState::Idle
|
||||
| SessionState::Stale
|
||||
)
|
||||
}) {
|
||||
if let Some(worktree) = session.worktree.as_ref() {
|
||||
duplicate_branches
|
||||
.entry(worktree.branch.clone())
|
||||
.or_default()
|
||||
.push(format_session_id(&session.id));
|
||||
}
|
||||
duplicate_tasks
|
||||
.entry(session.task.trim().to_ascii_lowercase())
|
||||
.or_default()
|
||||
.push(format_session_id(&session.id));
|
||||
}
|
||||
|
||||
for (branch, sessions) in duplicate_branches {
|
||||
if sessions.len() >= 2 {
|
||||
risks.push(format!("Shared branch {branch}: {}", sessions.join(", ")));
|
||||
}
|
||||
}
|
||||
for (task, sessions) in duplicate_tasks {
|
||||
if sessions.len() >= 2 {
|
||||
risks.push(format!(
|
||||
"Shared task {}: {}",
|
||||
truncate_for_dashboard(&task, 32),
|
||||
sessions.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
risks.sort();
|
||||
risks.dedup();
|
||||
risks
|
||||
}
|
||||
|
||||
fn aggregate_cost_summary(&self) -> (String, Style) {
|
||||
let aggregate = self.aggregate_usage();
|
||||
let thresholds = self.cfg.effective_budget_alert_thresholds();
|
||||
@@ -6774,7 +7071,9 @@ impl Dashboard {
|
||||
}
|
||||
|
||||
fn visible_detail_panes(&self) -> Vec<Pane> {
|
||||
self.visible_panes()
|
||||
self.layout_panes()
|
||||
.into_iter()
|
||||
.filter(|pane| !self.collapsed_panes.contains(pane))
|
||||
.into_iter()
|
||||
.filter(|pane| *pane != Pane::Sessions)
|
||||
.collect()
|
||||
@@ -6819,6 +7118,19 @@ impl Dashboard {
|
||||
}
|
||||
}
|
||||
|
||||
fn board_pane_visible(&self) -> bool {
|
||||
self.cfg.pane_layout == PaneLayout::Grid
|
||||
&& !self.collapsed_panes.contains(&Pane::Metrics)
|
||||
&& self.layout_panes().contains(&Pane::Metrics)
|
||||
}
|
||||
|
||||
fn is_pane_visible(&self, pane: Pane) -> bool {
|
||||
match pane {
|
||||
Pane::Board => self.board_pane_visible(),
|
||||
_ => self.visible_panes().contains(&pane),
|
||||
}
|
||||
}
|
||||
|
||||
fn theme_palette(&self) -> ThemePalette {
|
||||
match self.cfg.theme {
|
||||
Theme::Dark => ThemePalette {
|
||||
@@ -6886,6 +7198,7 @@ impl Pane {
|
||||
Pane::Sessions => "Sessions",
|
||||
Pane::Output => "Output",
|
||||
Pane::Metrics => "Metrics",
|
||||
Pane::Board => "Board",
|
||||
Pane::Log => "Log",
|
||||
}
|
||||
}
|
||||
@@ -6896,6 +7209,7 @@ impl Pane {
|
||||
2 => Some(Self::Output),
|
||||
3 => Some(Self::Metrics),
|
||||
4 => Some(Self::Log),
|
||||
5 => Some(Self::Board),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -6905,7 +7219,8 @@ impl Pane {
|
||||
Self::Sessions => 1,
|
||||
Self::Output => 2,
|
||||
Self::Metrics => 3,
|
||||
Self::Log => 4,
|
||||
Self::Board => 4,
|
||||
Self::Log => 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6915,6 +7230,7 @@ fn pane_rect(pane_areas: &PaneAreas, pane: Pane) -> Option<Rect> {
|
||||
Pane::Sessions => Some(pane_areas.sessions),
|
||||
Pane::Output => pane_areas.output,
|
||||
Pane::Metrics => pane_areas.metrics,
|
||||
Pane::Board => pane_areas.metrics,
|
||||
Pane::Log => pane_areas.log,
|
||||
}
|
||||
}
|
||||
@@ -8247,6 +8563,17 @@ fn diff_addition_word_style() -> Style {
|
||||
.add_modifier(Modifier::BOLD)
|
||||
}
|
||||
|
||||
fn board_lane_label(state: &SessionState) -> &'static str {
|
||||
match state {
|
||||
SessionState::Pending => "Inbox",
|
||||
SessionState::Running => "In Progress",
|
||||
SessionState::Idle => "Review",
|
||||
SessionState::Stale | SessionState::Failed => "Blocked",
|
||||
SessionState::Completed => "Done",
|
||||
SessionState::Stopped => "Stopped",
|
||||
}
|
||||
}
|
||||
|
||||
fn session_state_label(state: &SessionState) -> &'static str {
|
||||
match state {
|
||||
SessionState::Pending => "Pending",
|
||||
@@ -8271,6 +8598,25 @@ fn session_state_color(state: &SessionState) -> Color {
|
||||
}
|
||||
}
|
||||
|
||||
fn board_codename(session: &Session) -> String {
|
||||
const ADJECTIVES: &[&str] = &[
|
||||
"Amber", "Cinder", "Moss", "Nova", "Sable", "Slate", "Swift", "Talon",
|
||||
];
|
||||
const NOUNS: &[&str] = &[
|
||||
"Fox", "Kite", "Lynx", "Otter", "Rook", "Sprite", "Wisp", "Wolf",
|
||||
];
|
||||
|
||||
let seed = session
|
||||
.id
|
||||
.bytes()
|
||||
.fold(0usize, |acc, byte| acc.wrapping_mul(33).wrapping_add(byte as usize));
|
||||
format!(
|
||||
"{} {}",
|
||||
ADJECTIVES[seed % ADJECTIVES.len()],
|
||||
NOUNS[(seed / ADJECTIVES.len()) % NOUNS.len()]
|
||||
)
|
||||
}
|
||||
|
||||
fn file_activity_summary(entry: &FileActivityEntry) -> String {
|
||||
let mut summary = format!(
|
||||
"{} {}",
|
||||
@@ -9048,6 +9394,44 @@ fn session_branch(session: &Session) -> String {
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
}
|
||||
|
||||
fn board_progress_bar(progress_percent: i64) -> String {
|
||||
let clamped = progress_percent.clamp(0, 100);
|
||||
let filled = ((clamped + 9) / 10) as usize;
|
||||
let empty = 10usize.saturating_sub(filled);
|
||||
format!("[{}{}]", "#".repeat(filled), ".".repeat(empty))
|
||||
}
|
||||
|
||||
fn board_presence_marker(session: &Session) -> String {
|
||||
let codename = board_codename(session);
|
||||
let initials = codename
|
||||
.split_whitespace()
|
||||
.filter_map(|part| part.chars().next())
|
||||
.take(2)
|
||||
.collect::<String>()
|
||||
.to_ascii_uppercase();
|
||||
format!("@{initials}")
|
||||
}
|
||||
|
||||
fn board_motion_marker(meta: &SessionBoardMeta) -> &'static str {
|
||||
match meta.movement_note.as_deref() {
|
||||
Some("Blocked") => "x",
|
||||
Some("Completed") => "*",
|
||||
Some(note) if note.starts_with("Moved ") => ">",
|
||||
Some(note) if note.starts_with("Retargeted ") => "~",
|
||||
_ => ".",
|
||||
}
|
||||
}
|
||||
|
||||
fn board_activity_marker(meta: &SessionBoardMeta) -> &'static str {
|
||||
match meta.activity_kind.as_deref() {
|
||||
Some("received") => "<",
|
||||
Some("delegated") => ">",
|
||||
Some("spawned") => "+",
|
||||
Some("spawned_fallback") => "#",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_duration(duration_secs: u64) -> String {
|
||||
let hours = duration_secs / 3600;
|
||||
let minutes = (duration_secs % 3600) / 60;
|
||||
@@ -14217,6 +14601,11 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
|
||||
#[test]
|
||||
fn pane_command_mode_sets_layout() {
|
||||
let tempdir = std::env::temp_dir().join(format!("ecc2-pane-command-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&tempdir).unwrap();
|
||||
let previous_home = std::env::var_os("HOME");
|
||||
std::env::set_var("HOME", &tempdir);
|
||||
|
||||
let mut dashboard = test_dashboard(Vec::new(), 0);
|
||||
dashboard.cfg.pane_layout = PaneLayout::Horizontal;
|
||||
|
||||
@@ -14231,10 +14620,22 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
.operator_note
|
||||
.as_deref()
|
||||
.is_some_and(|note| note.contains("pane layout set to grid | saved to ")));
|
||||
|
||||
if let Some(home) = previous_home {
|
||||
std::env::set_var("HOME", home);
|
||||
} else {
|
||||
std::env::remove_var("HOME");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(tempdir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_pane_layout_rotates_and_hides_log_when_leaving_grid() {
|
||||
let tempdir = std::env::temp_dir().join(format!("ecc2-cycle-pane-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&tempdir).unwrap();
|
||||
let previous_home = std::env::var_os("HOME");
|
||||
std::env::set_var("HOME", &tempdir);
|
||||
|
||||
let mut dashboard = test_dashboard(Vec::new(), 0);
|
||||
dashboard.cfg.pane_layout = PaneLayout::Grid;
|
||||
dashboard.cfg.linear_pane_size_percent = 44;
|
||||
@@ -14247,6 +14648,13 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
assert_eq!(dashboard.cfg.pane_layout, PaneLayout::Horizontal);
|
||||
assert_eq!(dashboard.pane_size_percent, 44);
|
||||
assert_eq!(dashboard.selected_pane, Pane::Sessions);
|
||||
|
||||
if let Some(home) = previous_home {
|
||||
std::env::set_var("HOME", home);
|
||||
} else {
|
||||
std::env::remove_var("HOME");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(tempdir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -14532,6 +14940,7 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
approval_queue_counts: HashMap::new(),
|
||||
approval_queue_preview: Vec::new(),
|
||||
handoff_backlog_counts: HashMap::new(),
|
||||
board_meta_by_session: HashMap::new(),
|
||||
worktree_health_by_session: HashMap::new(),
|
||||
global_handoff_backlog_leads: 0,
|
||||
global_handoff_backlog_messages: 0,
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
{
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "node \"<plugin-root>/scripts/hooks/ecc-statusline.js\"",
|
||||
"description": "ECC statusline: model | task | $cost tools files duration | dir | context bar"
|
||||
"command": "input=$(cat); user=$(whoami); cwd=$(echo \"$input\" | jq -r '.workspace.current_dir' | sed \"s|$HOME|~|g\"); model=$(echo \"$input\" | jq -r '.model.display_name'); time=$(date +%H:%M); remaining=$(echo \"$input\" | jq -r '.context_window.remaining_percentage // empty'); transcript=$(echo \"$input\" | jq -r '.transcript_path'); todo_count=$([ -f \"$transcript\" ] && grep -c '\"type\":\"todo\"' \"$transcript\" 2>/dev/null || echo 0); cd \"$(echo \"$input\" | jq -r '.workspace.current_dir')\" 2>/dev/null; branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo ''); status=''; [ -n \"$branch\" ] && { [ -n \"$(git status --porcelain 2>/dev/null)\" ] && status='*'; }; B='\\033[38;2;30;102;245m'; G='\\033[38;2;64;160;43m'; Y='\\033[38;2;223;142;29m'; M='\\033[38;2;136;57;239m'; C='\\033[38;2;23;146;153m'; R='\\033[0m'; T='\\033[38;2;76;79;105m'; printf \"${C}${user}${R}:${B}${cwd}${R}\"; [ -n \"$branch\" ] && printf \" ${G}${branch}${Y}${status}${R}\"; [ -n \"$remaining\" ] && printf \" ${M}ctx:${remaining}%%${R}\"; printf \" ${T}${model}${R} ${Y}${time}${R}\"; [ \"$todo_count\" -gt 0 ] && printf \" ${C}todos:${todo_count}${R}\"; echo",
|
||||
"description": "Custom status line showing: user:path branch* ctx:% model time todos:N"
|
||||
},
|
||||
"_comments": {
|
||||
"setup": "Replace <plugin-root> with your ECC installation path. For plugin installs, use the resolved path from CLAUDE_PLUGIN_ROOT.",
|
||||
"display": "Shows model name, current task, session cost, tool count, files modified, session duration, directory, and context usage bar with color thresholds.",
|
||||
"colors": {
|
||||
"green": "Context used < 50%",
|
||||
"yellow": "Context used < 65%",
|
||||
"orange": "Context used < 80%",
|
||||
"red_blink": "Context used >= 80%"
|
||||
"B": "Blue - directory path",
|
||||
"G": "Green - git branch",
|
||||
"Y": "Yellow - dirty status, time",
|
||||
"M": "Magenta - context remaining",
|
||||
"C": "Cyan - username, todos",
|
||||
"T": "Gray - model name"
|
||||
},
|
||||
"output_example": "Opus 4.6 | Fixing auth bug | $1.23 47t 5f 15m | myproject ███████░░░ 68%",
|
||||
"dependencies": "Reads bridge file from ecc-metrics-bridge.js PostToolUse hook. Both must be installed for full metrics display.",
|
||||
"output_example": "affoon:~/projects/myapp main* ctx:73% sonnet-4.6 14:30 todos:3",
|
||||
"usage": "Copy the statusLine object to your ~/.claude/settings.json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,30 +219,6 @@
|
||||
],
|
||||
"description": "Capture tool use results for continuous learning",
|
||||
"id": "post:observe:continuous-learning"
|
||||
},
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:ecc-metrics-bridge scripts/hooks/ecc-metrics-bridge.js minimal,standard,strict",
|
||||
"timeout": 10
|
||||
}
|
||||
],
|
||||
"description": "Maintain running session metrics aggregate for statusline and context monitor",
|
||||
"id": "post:ecc-metrics-bridge"
|
||||
},
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node -e \"const p=require('path');const r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [[\\\"ecc\\\"],[\\\"ecc@ecc\\\"],[\\\"marketplace\\\",\\\"ecc\\\"],[\\\"everything-claude-code\\\"],[\\\"everything-claude-code@everything-claude-code\\\"],[\\\"marketplace\\\",\\\"everything-claude-code\\\"]]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of [\\\"ecc\\\",\\\"everything-claude-code\\\"]){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:ecc-context-monitor scripts/hooks/ecc-context-monitor.js standard,strict",
|
||||
"timeout": 10
|
||||
}
|
||||
],
|
||||
"description": "Inject agent warnings on context exhaustion, high cost, scope creep, or tool loops",
|
||||
"id": "post:ecc-context-monitor"
|
||||
}
|
||||
],
|
||||
"PostToolUseFailure": [
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const { ensureDir, appendFile, getClaudeDir } = require('../lib/utils');
|
||||
const { estimateCost } = require('../lib/cost-estimate');
|
||||
const {
|
||||
ensureDir,
|
||||
appendFile,
|
||||
getClaudeDir,
|
||||
} = require('../lib/utils');
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
let raw = '';
|
||||
@@ -19,6 +22,23 @@ function toNumber(value) {
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function estimateCost(model, inputTokens, outputTokens) {
|
||||
// Approximate per-1M-token blended rates. Conservative defaults.
|
||||
const table = {
|
||||
'haiku': { in: 0.8, out: 4.0 },
|
||||
'sonnet': { in: 3.0, out: 15.0 },
|
||||
'opus': { in: 15.0, out: 75.0 },
|
||||
};
|
||||
|
||||
const normalized = String(model || '').toLowerCase();
|
||||
let rates = table.sonnet;
|
||||
if (normalized.includes('haiku')) rates = table.haiku;
|
||||
if (normalized.includes('opus')) rates = table.opus;
|
||||
|
||||
const cost = (inputTokens / 1_000_000) * rates.in + (outputTokens / 1_000_000) * rates.out;
|
||||
return Math.round(cost * 1e6) / 1e6;
|
||||
}
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (raw.length < MAX_STDIN) {
|
||||
@@ -46,7 +66,7 @@ process.stdin.on('end', () => {
|
||||
model,
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
estimated_cost_usd: estimateCost(model, inputTokens, outputTokens)
|
||||
estimated_cost_usd: estimateCost(model, inputTokens, outputTokens),
|
||||
};
|
||||
|
||||
appendFile(path.join(metricsDir, 'costs.jsonl'), `${JSON.stringify(row)}\n`);
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ECC Context Monitor — PostToolUse hook
|
||||
*
|
||||
* Reads bridge file from ecc-metrics-bridge.js and injects agent-facing
|
||||
* warnings when thresholds are crossed: context exhaustion, high cost,
|
||||
* scope creep, or tool loops.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { sanitizeSessionId, readBridge } = require('../lib/session-bridge');
|
||||
|
||||
const CONTEXT_WARNING_PCT = 35;
|
||||
const CONTEXT_CRITICAL_PCT = 25;
|
||||
const COST_NOTICE_USD = 5;
|
||||
const COST_WARNING_USD = 10;
|
||||
const COST_CRITICAL_USD = 50;
|
||||
const FILES_WARNING_COUNT = 20;
|
||||
const LOOP_THRESHOLD = 3;
|
||||
const STALE_SECONDS = 60;
|
||||
const DEBOUNCE_CALLS = 5;
|
||||
|
||||
/**
|
||||
* Get debounce state file path.
|
||||
* @param {string} sessionId
|
||||
* @returns {string}
|
||||
*/
|
||||
function getWarnPath(sessionId) {
|
||||
return path.join(os.tmpdir(), `ecc-ctx-warn-${sessionId}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read debounce state.
|
||||
* @param {string} sessionId
|
||||
* @returns {object}
|
||||
*/
|
||||
function readWarnState(sessionId) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(getWarnPath(sessionId), 'utf8'));
|
||||
} catch {
|
||||
return { callsSinceWarn: 0, lastSeverity: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write debounce state.
|
||||
* @param {string} sessionId
|
||||
* @param {object} state
|
||||
*/
|
||||
function writeWarnState(sessionId, state) {
|
||||
fs.writeFileSync(getWarnPath(sessionId), JSON.stringify(state), 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect tool loops from recent_tools ring buffer.
|
||||
* @param {Array} recentTools
|
||||
* @returns {{detected: boolean, tool: string, count: number}}
|
||||
*/
|
||||
function detectLoop(recentTools) {
|
||||
if (!Array.isArray(recentTools) || recentTools.length < LOOP_THRESHOLD) {
|
||||
return { detected: false, tool: '', count: 0 };
|
||||
}
|
||||
const counts = {};
|
||||
for (const entry of recentTools) {
|
||||
const key = `${entry.tool}:${entry.hash}`;
|
||||
counts[key] = (counts[key] || 0) + 1;
|
||||
}
|
||||
for (const [key, count] of Object.entries(counts)) {
|
||||
if (count >= LOOP_THRESHOLD) {
|
||||
return { detected: true, tool: key.split(':')[0], count };
|
||||
}
|
||||
}
|
||||
return { detected: false, tool: '', count: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate all warning conditions against bridge data.
|
||||
* Returns array of {severity, type, message} sorted by severity desc.
|
||||
*/
|
||||
function evaluateConditions(bridge) {
|
||||
const warnings = [];
|
||||
const remaining = bridge.context_remaining_pct;
|
||||
|
||||
// Context warnings (skip if no context data)
|
||||
if (remaining != null) {
|
||||
if (remaining <= CONTEXT_CRITICAL_PCT) {
|
||||
warnings.push({
|
||||
severity: 3,
|
||||
type: 'context',
|
||||
message:
|
||||
`CONTEXT CRITICAL: ${remaining}% remaining. Context nearly exhausted. ` +
|
||||
'Inform the user that context is low and ask how they want to proceed. ' +
|
||||
'Do NOT autonomously save state or write handoff files unless the user asks.'
|
||||
});
|
||||
} else if (remaining <= CONTEXT_WARNING_PCT) {
|
||||
warnings.push({
|
||||
severity: 2,
|
||||
type: 'context',
|
||||
message: `CONTEXT WARNING: ${remaining}% remaining. ` + 'Be aware that context is getting limited. Avoid starting new complex work.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Cost warnings
|
||||
const cost = bridge.total_cost_usd || 0;
|
||||
if (cost > COST_CRITICAL_USD) {
|
||||
warnings.push({
|
||||
severity: 3,
|
||||
type: 'cost',
|
||||
message: `COST CRITICAL: Session cost is $${cost.toFixed(2)}. ` + 'Stop and inform the user about high cost before continuing.'
|
||||
});
|
||||
} else if (cost > COST_WARNING_USD) {
|
||||
warnings.push({
|
||||
severity: 2,
|
||||
type: 'cost',
|
||||
message: `COST WARNING: Session cost is $${cost.toFixed(2)}. ` + 'Review whether the current approach justifies the expense.'
|
||||
});
|
||||
} else if (cost > COST_NOTICE_USD) {
|
||||
warnings.push({
|
||||
severity: 1,
|
||||
type: 'cost',
|
||||
message: `COST NOTICE: Session cost is $${cost.toFixed(2)}. ` + 'Consider whether the current approach is efficient.'
|
||||
});
|
||||
}
|
||||
|
||||
// File scope warning
|
||||
const fileCount = bridge.files_modified_count || 0;
|
||||
if (fileCount > FILES_WARNING_COUNT) {
|
||||
warnings.push({
|
||||
severity: 2,
|
||||
type: 'scope',
|
||||
message: `SCOPE WARNING: ${fileCount} files modified this session. ` + 'Consider whether changes are too scattered.'
|
||||
});
|
||||
}
|
||||
|
||||
// Loop detection
|
||||
const loop = detectLoop(bridge.recent_tools);
|
||||
if (loop.detected) {
|
||||
warnings.push({
|
||||
severity: 2,
|
||||
type: 'loop',
|
||||
message: `LOOP WARNING: Tool '${loop.tool}' called ${loop.count} times ` + 'with same parameters in last 5 calls. This may indicate a stuck loop.'
|
||||
});
|
||||
}
|
||||
|
||||
return warnings.sort((a, b) => b.severity - a.severity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map numeric severity to label.
|
||||
*/
|
||||
function severityLabel(n) {
|
||||
if (n >= 3) return 'critical';
|
||||
if (n >= 2) return 'warning';
|
||||
return 'notice';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} rawInput - Raw JSON string from stdin
|
||||
* @returns {string} JSON output with additionalContext or pass-through
|
||||
*/
|
||||
function run(rawInput) {
|
||||
try {
|
||||
const input = rawInput.trim() ? JSON.parse(rawInput) : {};
|
||||
|
||||
const sessionId = sanitizeSessionId(input.session_id) || sanitizeSessionId(process.env.ECC_SESSION_ID) || sanitizeSessionId(process.env.CLAUDE_SESSION_ID);
|
||||
|
||||
if (!sessionId) return rawInput;
|
||||
|
||||
const bridge = readBridge(sessionId);
|
||||
if (!bridge) return rawInput;
|
||||
|
||||
// Stale check for context warnings
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const lastTs = bridge.last_timestamp ? Math.floor(new Date(bridge.last_timestamp).getTime() / 1000) : 0;
|
||||
const isStale = lastTs > 0 && now - lastTs > STALE_SECONDS;
|
||||
|
||||
// If bridge is stale, null out context data (still check cost/scope/loop)
|
||||
const evalBridge = isStale ? { ...bridge, context_remaining_pct: null } : bridge;
|
||||
|
||||
const warnings = evaluateConditions(evalBridge);
|
||||
if (warnings.length === 0) return rawInput;
|
||||
|
||||
// Debounce logic
|
||||
const warnState = readWarnState(sessionId);
|
||||
warnState.callsSinceWarn = (warnState.callsSinceWarn || 0) + 1;
|
||||
|
||||
const topSeverity = severityLabel(warnings[0].severity);
|
||||
const severityEscalated = topSeverity === 'critical' && warnState.lastSeverity !== 'critical';
|
||||
|
||||
const isFirst = !warnState.lastSeverity;
|
||||
if (!isFirst && warnState.callsSinceWarn < DEBOUNCE_CALLS && !severityEscalated) {
|
||||
writeWarnState(sessionId, warnState);
|
||||
return rawInput;
|
||||
}
|
||||
|
||||
// Reset debounce, emit warning
|
||||
warnState.callsSinceWarn = 0;
|
||||
warnState.lastSeverity = topSeverity;
|
||||
writeWarnState(sessionId, warnState);
|
||||
|
||||
// Combine top 2 warnings
|
||||
const message = warnings
|
||||
.slice(0, 2)
|
||||
.map(w => w.message)
|
||||
.join('\n');
|
||||
|
||||
const output = {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PostToolUse',
|
||||
additionalContext: message
|
||||
}
|
||||
};
|
||||
|
||||
return JSON.stringify(output);
|
||||
} catch {
|
||||
// Never block tool execution
|
||||
return rawInput;
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
let data = '';
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (data.length < MAX_STDIN) data += chunk.substring(0, MAX_STDIN - data.length);
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
process.stdout.write(run(data));
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { run, evaluateConditions, detectLoop, severityLabel };
|
||||
@@ -1,185 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ECC Metrics Bridge — PostToolUse hook
|
||||
*
|
||||
* Maintains a running session aggregate in /tmp/ecc-metrics-{session}.json.
|
||||
* This bridge file is read by ecc-statusline.js and ecc-context-monitor.js,
|
||||
* avoiding the need to scan large JSONL logs on every invocation.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { estimateCost } = require('../lib/cost-estimate');
|
||||
const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge');
|
||||
const { getClaudeDir } = require('../lib/utils');
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
const MAX_FILES_TRACKED = 200;
|
||||
const RECENT_TOOLS_SIZE = 5;
|
||||
|
||||
function toNumber(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash tool call for loop detection.
|
||||
* Uses tool name + a key parameter (file_path for Edit/Write, first 80 chars of command for Bash).
|
||||
*/
|
||||
function hashToolCall(toolName, toolInput) {
|
||||
const name = String(toolName || '');
|
||||
let key = '';
|
||||
if (name === 'Bash') {
|
||||
key = String(toolInput?.command || '').slice(0, 80);
|
||||
} else {
|
||||
key = String(toolInput?.file_path || '');
|
||||
}
|
||||
return crypto.createHash('sha256').update(`${name}:${key}`).digest('hex').slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract modified file paths from tool input.
|
||||
*/
|
||||
function extractFilePaths(toolName, toolInput) {
|
||||
const paths = [];
|
||||
if (!toolInput || typeof toolInput !== 'object') return paths;
|
||||
|
||||
const fp = toolInput.file_path;
|
||||
if (fp && typeof fp === 'string') paths.push(fp);
|
||||
|
||||
const edits = toolInput.edits;
|
||||
if (Array.isArray(edits)) {
|
||||
for (const edit of edits) {
|
||||
if (edit?.file_path && typeof edit.file_path === 'string') {
|
||||
paths.push(edit.file_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read cumulative cost for a session from the tail of costs.jsonl.
|
||||
* Reads last 8KB to avoid scanning entire file.
|
||||
*/
|
||||
function readSessionCost(sessionId) {
|
||||
try {
|
||||
const costsPath = path.join(getClaudeDir(), 'metrics', 'costs.jsonl');
|
||||
const stat = fs.statSync(costsPath);
|
||||
const readSize = Math.min(stat.size, 8192);
|
||||
const fd = fs.openSync(costsPath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(readSize);
|
||||
fs.readSync(fd, buf, 0, readSize, Math.max(0, stat.size - readSize));
|
||||
const lines = buf.toString('utf8').split('\n').filter(Boolean);
|
||||
|
||||
let totalCost = 0;
|
||||
let totalIn = 0;
|
||||
let totalOut = 0;
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const row = JSON.parse(line);
|
||||
if (row.session_id === sessionId || row.session_id === 'default') {
|
||||
totalCost += toNumber(row.estimated_cost_usd);
|
||||
totalIn += toNumber(row.input_tokens);
|
||||
totalOut += toNumber(row.output_tokens);
|
||||
}
|
||||
} catch {
|
||||
/* skip malformed lines */
|
||||
}
|
||||
}
|
||||
return { totalCost, totalIn, totalOut };
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return { totalCost: 0, totalIn: 0, totalOut: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} rawInput - Raw JSON string from stdin
|
||||
* @returns {string} Pass-through
|
||||
*/
|
||||
function run(rawInput) {
|
||||
try {
|
||||
const input = rawInput.trim() ? JSON.parse(rawInput) : {};
|
||||
const toolName = String(input.tool_name || '');
|
||||
const toolInput = input.tool_input || {};
|
||||
|
||||
const sessionId = sanitizeSessionId(input.session_id) || sanitizeSessionId(process.env.ECC_SESSION_ID) || sanitizeSessionId(process.env.CLAUDE_SESSION_ID);
|
||||
|
||||
if (!sessionId) return rawInput;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const bridge = readBridge(sessionId) || {
|
||||
session_id: sessionId,
|
||||
total_cost_usd: 0,
|
||||
total_input_tokens: 0,
|
||||
total_output_tokens: 0,
|
||||
tool_count: 0,
|
||||
files_modified_count: 0,
|
||||
files_modified: [],
|
||||
recent_tools: [],
|
||||
first_timestamp: now,
|
||||
last_timestamp: now,
|
||||
context_remaining_pct: null
|
||||
};
|
||||
|
||||
// Increment tool count
|
||||
bridge.tool_count = (bridge.tool_count || 0) + 1;
|
||||
bridge.last_timestamp = now;
|
||||
if (!bridge.first_timestamp) bridge.first_timestamp = now;
|
||||
|
||||
// Track modified files (Write/Edit/MultiEdit only)
|
||||
const isWriteOp = /^(Write|Edit|MultiEdit)$/i.test(toolName);
|
||||
if (isWriteOp) {
|
||||
const newPaths = extractFilePaths(toolName, toolInput);
|
||||
const existing = new Set(bridge.files_modified || []);
|
||||
for (const p of newPaths) {
|
||||
if (existing.size < MAX_FILES_TRACKED && !existing.has(p)) {
|
||||
existing.add(p);
|
||||
}
|
||||
}
|
||||
bridge.files_modified = [...existing];
|
||||
bridge.files_modified_count = existing.size;
|
||||
}
|
||||
|
||||
// Ring buffer for loop detection
|
||||
const recent = bridge.recent_tools || [];
|
||||
recent.push({ tool: toolName, hash: hashToolCall(toolName, toolInput) });
|
||||
if (recent.length > RECENT_TOOLS_SIZE) recent.shift();
|
||||
bridge.recent_tools = recent;
|
||||
|
||||
// Update cost from costs.jsonl tail
|
||||
const envSessionId = String(process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || 'default');
|
||||
const costs = readSessionCost(envSessionId);
|
||||
bridge.total_cost_usd = Math.round(costs.totalCost * 1e6) / 1e6;
|
||||
bridge.total_input_tokens = costs.totalIn;
|
||||
bridge.total_output_tokens = costs.totalOut;
|
||||
|
||||
writeBridgeAtomic(sessionId, bridge);
|
||||
} catch {
|
||||
// Never block tool execution
|
||||
}
|
||||
|
||||
return rawInput;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
let data = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (data.length < MAX_STDIN) data += chunk.substring(0, MAX_STDIN - data.length);
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
process.stdout.write(run(data));
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { run, hashToolCall, extractFilePaths, readSessionCost };
|
||||
@@ -1,160 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ECC Statusline — statusLine command
|
||||
*
|
||||
* Displays: model | task | $cost Nt Nf Nm | dir ██░░ N%
|
||||
*
|
||||
* Registered in settings.json under "statusLine", not in hooks.json.
|
||||
* Reads bridge file from ecc-metrics-bridge.js and stdin from Claude Code runtime.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge');
|
||||
|
||||
const AUTO_COMPACT_BUFFER_PCT = 16.5;
|
||||
|
||||
/**
|
||||
* Format duration from ISO timestamp to now.
|
||||
* @param {string} isoTimestamp
|
||||
* @returns {string} e.g. "5s", "12m", "1h23m"
|
||||
*/
|
||||
function formatDuration(isoTimestamp) {
|
||||
if (!isoTimestamp) return '?';
|
||||
const elapsed = Math.floor((Date.now() - new Date(isoTimestamp).getTime()) / 1000);
|
||||
if (elapsed < 0) return '?';
|
||||
if (elapsed < 60) return `${elapsed}s`;
|
||||
const mins = Math.floor(elapsed / 60);
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
const remMins = mins % 60;
|
||||
return remMins > 0 ? `${hours}h${remMins}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context progress bar with ANSI colors.
|
||||
* @param {number} remaining - Raw remaining percentage from Claude Code
|
||||
* @returns {string} Colored bar string
|
||||
*/
|
||||
function buildContextBar(remaining) {
|
||||
if (remaining == null) return '';
|
||||
|
||||
const usableRemaining = Math.max(0, ((remaining - AUTO_COMPACT_BUFFER_PCT) / (100 - AUTO_COMPACT_BUFFER_PCT)) * 100);
|
||||
const used = Math.max(0, Math.min(100, Math.round(100 - usableRemaining)));
|
||||
|
||||
const filled = Math.floor(used / 10);
|
||||
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(10 - filled);
|
||||
|
||||
if (used < 50) return ` \x1b[32m${bar} ${used}%\x1b[0m`;
|
||||
if (used < 65) return ` \x1b[33m${bar} ${used}%\x1b[0m`;
|
||||
if (used < 80) return ` \x1b[38;5;208m${bar} ${used}%\x1b[0m`;
|
||||
return ` \x1b[5;31m${bar} ${used}%\x1b[0m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read current in-progress task from todos directory.
|
||||
* @param {string} sessionId
|
||||
* @returns {string} Task activeForm text or empty string
|
||||
*/
|
||||
function readCurrentTask(sessionId) {
|
||||
try {
|
||||
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
||||
const todosDir = path.join(claudeDir, 'todos');
|
||||
if (!fs.existsSync(todosDir)) return '';
|
||||
|
||||
const files = fs
|
||||
.readdirSync(todosDir)
|
||||
.filter(f => f.startsWith(sessionId) && f.includes('-agent-') && f.endsWith('.json'))
|
||||
.map(f => ({ name: f, mtime: fs.statSync(path.join(todosDir, f)).mtime }))
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
if (files.length === 0) return '';
|
||||
|
||||
const todos = JSON.parse(fs.readFileSync(path.join(todosDir, files[0].name), 'utf8'));
|
||||
const inProgress = todos.find(t => t.status === 'in_progress');
|
||||
return inProgress?.activeForm || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function runStatusline() {
|
||||
let input = '';
|
||||
const stdinTimeout = setTimeout(() => process.exit(0), 3000);
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => (input += chunk));
|
||||
process.stdin.on('end', () => {
|
||||
clearTimeout(stdinTimeout);
|
||||
try {
|
||||
const data = JSON.parse(input);
|
||||
const model = data.model?.display_name || 'Claude';
|
||||
const dir = data.workspace?.current_dir || process.cwd();
|
||||
const session = data.session_id || '';
|
||||
const remaining = data.context_window?.remaining_percentage;
|
||||
|
||||
const sessionId = sanitizeSessionId(session);
|
||||
const bridge = sessionId ? readBridge(sessionId) : null;
|
||||
|
||||
// Write context % back to bridge for context-monitor
|
||||
if (sessionId && bridge && remaining != null) {
|
||||
bridge.context_remaining_pct = remaining;
|
||||
try {
|
||||
writeBridgeAtomic(sessionId, bridge);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
// Current task
|
||||
const task = session ? readCurrentTask(session) : '';
|
||||
|
||||
// Metrics from bridge
|
||||
let metricsStr = '';
|
||||
if (bridge) {
|
||||
const parts = [];
|
||||
if (bridge.total_cost_usd > 0) {
|
||||
parts.push(`$${bridge.total_cost_usd.toFixed(2)}`);
|
||||
}
|
||||
if (bridge.tool_count > 0) {
|
||||
parts.push(`${bridge.tool_count}t`);
|
||||
}
|
||||
if (bridge.files_modified_count > 0) {
|
||||
parts.push(`${bridge.files_modified_count}f`);
|
||||
}
|
||||
const dur = formatDuration(bridge.first_timestamp);
|
||||
if (dur !== '?') {
|
||||
parts.push(dur);
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
metricsStr = `\x1b[36m${parts.join(' ')}\x1b[0m`;
|
||||
}
|
||||
}
|
||||
|
||||
// Context bar
|
||||
const ctx = buildContextBar(remaining);
|
||||
|
||||
// Build output
|
||||
const dirname = path.basename(dir);
|
||||
const segments = [`\x1b[2m${model}\x1b[0m`];
|
||||
|
||||
if (task) {
|
||||
segments.push(`\x1b[1m${task}\x1b[0m`);
|
||||
}
|
||||
if (metricsStr) {
|
||||
segments.push(metricsStr);
|
||||
}
|
||||
segments.push(`\x1b[2m${dirname}\x1b[0m`);
|
||||
|
||||
process.stdout.write(segments.join(' \x1b[2m\u2502\x1b[0m ') + ctx);
|
||||
} catch {
|
||||
// Silent fail
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { formatDuration, buildContextBar, readCurrentTask };
|
||||
|
||||
if (require.main === module) runStatusline();
|
||||
@@ -1,32 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Shared cost estimation for ECC hooks.
|
||||
*
|
||||
* Approximate per-1M-token blended rates (conservative defaults).
|
||||
*/
|
||||
|
||||
const RATE_TABLE = {
|
||||
haiku: { in: 0.8, out: 4.0 },
|
||||
sonnet: { in: 3.0, out: 15.0 },
|
||||
opus: { in: 15.0, out: 75.0 }
|
||||
};
|
||||
|
||||
/**
|
||||
* Estimate USD cost from token counts.
|
||||
* @param {string} model - Model name (may contain "haiku", "sonnet", or "opus")
|
||||
* @param {number} inputTokens
|
||||
* @param {number} outputTokens
|
||||
* @returns {number} Estimated cost in USD (rounded to 6 decimal places)
|
||||
*/
|
||||
function estimateCost(model, inputTokens, outputTokens) {
|
||||
const normalized = String(model || '').toLowerCase();
|
||||
let rates = RATE_TABLE.sonnet;
|
||||
if (normalized.includes('haiku')) rates = RATE_TABLE.haiku;
|
||||
if (normalized.includes('opus')) rates = RATE_TABLE.opus;
|
||||
|
||||
const cost = (inputTokens / 1_000_000) * rates.in + (outputTokens / 1_000_000) * rates.out;
|
||||
return Math.round(cost * 1e6) / 1e6;
|
||||
}
|
||||
|
||||
module.exports = { estimateCost, RATE_TABLE };
|
||||
@@ -1,81 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Shared session bridge utilities for ECC hooks.
|
||||
*
|
||||
* The bridge file is a small JSON aggregate in /tmp that allows
|
||||
* statusline, metrics-bridge, and context-monitor to share state
|
||||
* without scanning large JSONL logs on every invocation.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const MAX_SESSION_ID_LENGTH = 64;
|
||||
|
||||
/**
|
||||
* Sanitize a session ID for safe use in file paths.
|
||||
* Rejects path traversal, strips unsafe chars, limits length.
|
||||
* @param {string} raw
|
||||
* @returns {string|null} Safe session ID or null if invalid
|
||||
*/
|
||||
function sanitizeSessionId(raw) {
|
||||
if (!raw || typeof raw !== 'string') return null;
|
||||
if (/[/\\]|\.\./.test(raw)) return null;
|
||||
const safe = raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, MAX_SESSION_ID_LENGTH);
|
||||
return safe || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the bridge file path for a session.
|
||||
* @param {string} sessionId - Already-sanitized session ID
|
||||
* @returns {string}
|
||||
*/
|
||||
function getBridgePath(sessionId) {
|
||||
return path.join(os.tmpdir(), `ecc-metrics-${sessionId}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read bridge data. Returns null on any error.
|
||||
* @param {string} sessionId - Already-sanitized session ID
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function readBridge(sessionId) {
|
||||
try {
|
||||
const raw = fs.readFileSync(getBridgePath(sessionId), 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write bridge data atomically (write .tmp then rename).
|
||||
* @param {string} sessionId - Already-sanitized session ID
|
||||
* @param {object} data
|
||||
*/
|
||||
function writeBridgeAtomic(sessionId, data) {
|
||||
const target = getBridgePath(sessionId);
|
||||
const tmp = `${target}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(data), 'utf8');
|
||||
fs.renameSync(tmp, target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve session ID from environment variables.
|
||||
* @returns {string|null} Sanitized session ID or null
|
||||
*/
|
||||
function resolveSessionId() {
|
||||
const raw = process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || '';
|
||||
return sanitizeSessionId(raw);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sanitizeSessionId,
|
||||
getBridgePath,
|
||||
readBridge,
|
||||
writeBridgeAtomic,
|
||||
resolveSessionId,
|
||||
MAX_SESSION_ID_LENGTH
|
||||
};
|
||||
@@ -1,238 +0,0 @@
|
||||
/**
|
||||
* Tests for scripts/hooks/ecc-context-monitor.js
|
||||
*
|
||||
* Run with: node tests/hooks/ecc-context-monitor.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
const { run, evaluateConditions, detectLoop, severityLabel } = require('../../scripts/hooks/ecc-context-monitor');
|
||||
|
||||
// Test helper
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing ecc-context-monitor.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// evaluateConditions — context warnings
|
||||
console.log('evaluateConditions (context):');
|
||||
|
||||
if (
|
||||
test('remaining 20% triggers CRITICAL context warning', () => {
|
||||
const warnings = evaluateConditions({ context_remaining_pct: 20 });
|
||||
const ctx = warnings.find(w => w.type === 'context');
|
||||
assert.ok(ctx, 'Expected a context warning');
|
||||
assert.strictEqual(ctx.severity, 3);
|
||||
assert.ok(ctx.message.includes('CRITICAL'), 'Message should contain CRITICAL');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('remaining 30% triggers WARNING context warning', () => {
|
||||
const warnings = evaluateConditions({ context_remaining_pct: 30 });
|
||||
const ctx = warnings.find(w => w.type === 'context');
|
||||
assert.ok(ctx, 'Expected a context warning');
|
||||
assert.strictEqual(ctx.severity, 2);
|
||||
assert.ok(ctx.message.includes('WARNING'), 'Message should contain WARNING');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('remaining 50% triggers no context warning', () => {
|
||||
const warnings = evaluateConditions({ context_remaining_pct: 50 });
|
||||
const ctx = warnings.find(w => w.type === 'context');
|
||||
assert.strictEqual(ctx, undefined);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// evaluateConditions — cost warnings
|
||||
console.log('\nevaluateConditions (cost):');
|
||||
|
||||
if (
|
||||
test('cost $55 triggers CRITICAL cost warning', () => {
|
||||
const warnings = evaluateConditions({ total_cost_usd: 55 });
|
||||
const cost = warnings.find(w => w.type === 'cost');
|
||||
assert.ok(cost, 'Expected a cost warning');
|
||||
assert.strictEqual(cost.severity, 3);
|
||||
assert.ok(cost.message.includes('CRITICAL'), 'Message should contain CRITICAL');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('cost $12 triggers WARNING cost warning', () => {
|
||||
const warnings = evaluateConditions({ total_cost_usd: 12 });
|
||||
const cost = warnings.find(w => w.type === 'cost');
|
||||
assert.ok(cost, 'Expected a cost warning');
|
||||
assert.strictEqual(cost.severity, 2);
|
||||
assert.ok(cost.message.includes('WARNING'), 'Message should contain WARNING');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('cost $6 triggers NOTICE cost warning', () => {
|
||||
const warnings = evaluateConditions({ total_cost_usd: 6 });
|
||||
const cost = warnings.find(w => w.type === 'cost');
|
||||
assert.ok(cost, 'Expected a cost warning');
|
||||
assert.strictEqual(cost.severity, 1);
|
||||
assert.ok(cost.message.includes('NOTICE'), 'Message should contain NOTICE');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('cost $2 triggers no cost warning', () => {
|
||||
const warnings = evaluateConditions({ total_cost_usd: 2 });
|
||||
const cost = warnings.find(w => w.type === 'cost');
|
||||
assert.strictEqual(cost, undefined);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// evaluateConditions — scope warnings
|
||||
console.log('\nevaluateConditions (scope):');
|
||||
|
||||
if (
|
||||
test('25 files triggers scope WARNING', () => {
|
||||
const warnings = evaluateConditions({ files_modified_count: 25 });
|
||||
const scope = warnings.find(w => w.type === 'scope');
|
||||
assert.ok(scope, 'Expected a scope warning');
|
||||
assert.strictEqual(scope.severity, 2);
|
||||
assert.ok(scope.message.includes('SCOPE'), 'Message should contain SCOPE');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('10 files triggers no scope warning', () => {
|
||||
const warnings = evaluateConditions({ files_modified_count: 10 });
|
||||
const scope = warnings.find(w => w.type === 'scope');
|
||||
assert.strictEqual(scope, undefined);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// detectLoop tests
|
||||
console.log('\ndetectLoop:');
|
||||
|
||||
if (
|
||||
test('3 identical entries returns detected true', () => {
|
||||
const entries = [
|
||||
{ tool: 'Bash', hash: 'aabbccdd' },
|
||||
{ tool: 'Bash', hash: 'aabbccdd' },
|
||||
{ tool: 'Bash', hash: 'aabbccdd' }
|
||||
];
|
||||
const result = detectLoop(entries);
|
||||
assert.strictEqual(result.detected, true);
|
||||
assert.strictEqual(result.tool, 'Bash');
|
||||
assert.ok(result.count >= 3);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('all different entries returns detected false', () => {
|
||||
const entries = [
|
||||
{ tool: 'Bash', hash: '11111111' },
|
||||
{ tool: 'Edit', hash: '22222222' },
|
||||
{ tool: 'Write', hash: '33333333' }
|
||||
];
|
||||
const result = detectLoop(entries);
|
||||
assert.strictEqual(result.detected, false);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('empty array returns detected false', () => {
|
||||
const result = detectLoop([]);
|
||||
assert.strictEqual(result.detected, false);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// severityLabel tests
|
||||
console.log('\nseverityLabel:');
|
||||
|
||||
if (
|
||||
test('severity 3 returns critical', () => {
|
||||
assert.strictEqual(severityLabel(3), 'critical');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('severity 2 returns warning', () => {
|
||||
assert.strictEqual(severityLabel(2), 'warning');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('severity 1 returns notice', () => {
|
||||
assert.strictEqual(severityLabel(1), 'notice');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// run tests
|
||||
console.log('\nrun:');
|
||||
|
||||
if (
|
||||
test('empty input returns input unchanged', () => {
|
||||
const result = run('');
|
||||
assert.strictEqual(result, '');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('input without session_id returns input unchanged', () => {
|
||||
const input = JSON.stringify({ tool_name: 'Bash' });
|
||||
const result = run(input);
|
||||
assert.strictEqual(result, input);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// Summary
|
||||
console.log(`\nResults: ${passed} passed, ${failed} failed\n`);
|
||||
return { passed, failed };
|
||||
}
|
||||
|
||||
const { failed } = runTests();
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -1,166 +0,0 @@
|
||||
/**
|
||||
* Tests for scripts/hooks/ecc-metrics-bridge.js
|
||||
*
|
||||
* Run with: node tests/hooks/ecc-metrics-bridge.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
const { run, hashToolCall, extractFilePaths, readSessionCost } = require('../../scripts/hooks/ecc-metrics-bridge');
|
||||
|
||||
// Test helper
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing ecc-metrics-bridge.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// hashToolCall tests
|
||||
console.log('hashToolCall:');
|
||||
|
||||
if (
|
||||
test('returns 8-char hex string', () => {
|
||||
const hash = hashToolCall('Bash', { command: 'ls' });
|
||||
assert.strictEqual(hash.length, 8);
|
||||
assert.ok(/^[0-9a-f]{8}$/.test(hash), `Expected hex, got: ${hash}`);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('different Bash commands produce different hashes', () => {
|
||||
const h1 = hashToolCall('Bash', { command: 'ls' });
|
||||
const h2 = hashToolCall('Bash', { command: 'pwd' });
|
||||
assert.notStrictEqual(h1, h2);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('different Edit file_paths produce different hashes', () => {
|
||||
const h1 = hashToolCall('Edit', { file_path: 'a.js' });
|
||||
const h2 = hashToolCall('Edit', { file_path: 'b.js' });
|
||||
assert.notStrictEqual(h1, h2);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('same inputs produce same hash (deterministic)', () => {
|
||||
const h1 = hashToolCall('Write', { file_path: 'x.txt' });
|
||||
const h2 = hashToolCall('Write', { file_path: 'x.txt' });
|
||||
assert.strictEqual(h1, h2);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// extractFilePaths tests
|
||||
console.log('\nextractFilePaths:');
|
||||
|
||||
if (
|
||||
test('Edit with file_path returns [file_path]', () => {
|
||||
const paths = extractFilePaths('Edit', { file_path: 'a.js' });
|
||||
assert.deepStrictEqual(paths, ['a.js']);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('MultiEdit with edits array returns all file_paths', () => {
|
||||
const paths = extractFilePaths('MultiEdit', {
|
||||
edits: [{ file_path: 'a.js' }, { file_path: 'b.js' }]
|
||||
});
|
||||
assert.deepStrictEqual(paths, ['a.js', 'b.js']);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('Bash with command returns empty array', () => {
|
||||
const paths = extractFilePaths('Bash', { command: 'ls' });
|
||||
assert.deepStrictEqual(paths, []);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('null toolInput returns empty array', () => {
|
||||
const paths = extractFilePaths('Edit', null);
|
||||
assert.deepStrictEqual(paths, []);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// readSessionCost tests
|
||||
console.log('\nreadSessionCost:');
|
||||
|
||||
if (
|
||||
test('nonexistent session returns object with numeric fields', () => {
|
||||
const result = readSessionCost('nonexistent-session-cost-test-xyz-999');
|
||||
assert.strictEqual(typeof result.totalCost, 'number');
|
||||
assert.strictEqual(typeof result.totalIn, 'number');
|
||||
assert.strictEqual(typeof result.totalOut, 'number');
|
||||
assert.ok(result.totalCost >= 0, 'totalCost should be non-negative');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// run tests
|
||||
console.log('\nrun:');
|
||||
|
||||
if (
|
||||
test('empty input returns empty input without crashing', () => {
|
||||
const result = run('');
|
||||
assert.strictEqual(result, '');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('whitespace-only input returns input unchanged', () => {
|
||||
const result = run(' ');
|
||||
assert.strictEqual(result, ' ');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('input without session_id returns input unchanged', () => {
|
||||
const input = JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'ls' } });
|
||||
const result = run(input);
|
||||
assert.strictEqual(result, input);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// Summary
|
||||
console.log(`\nResults: ${passed} passed, ${failed} failed\n`);
|
||||
return { passed, failed };
|
||||
}
|
||||
|
||||
const { failed } = runTests();
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -1,180 +0,0 @@
|
||||
/**
|
||||
* Tests for scripts/hooks/ecc-statusline.js
|
||||
*
|
||||
* Run with: node tests/hooks/ecc-statusline.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
const { formatDuration, buildContextBar, readCurrentTask } = require('../../scripts/hooks/ecc-statusline');
|
||||
|
||||
// Test helper
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing ecc-statusline.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// formatDuration tests
|
||||
console.log('formatDuration:');
|
||||
|
||||
if (
|
||||
test('null returns "?"', () => {
|
||||
assert.strictEqual(formatDuration(null), '?');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('undefined returns "?"', () => {
|
||||
assert.strictEqual(formatDuration(undefined), '?');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('timestamp 30 seconds ago ends with "s"', () => {
|
||||
const ts = new Date(Date.now() - 30 * 1000).toISOString();
|
||||
const result = formatDuration(ts);
|
||||
assert.ok(result.endsWith('s'), `Expected ending in "s", got: ${result}`);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('timestamp 5 minutes ago ends with "m"', () => {
|
||||
const ts = new Date(Date.now() - 5 * 60 * 1000).toISOString();
|
||||
const result = formatDuration(ts);
|
||||
assert.ok(result.endsWith('m'), `Expected ending in "m", got: ${result}`);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('timestamp 90 minutes ago contains "h"', () => {
|
||||
const ts = new Date(Date.now() - 90 * 60 * 1000).toISOString();
|
||||
const result = formatDuration(ts);
|
||||
assert.ok(result.includes('h'), `Expected "h" in result, got: ${result}`);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('future timestamp returns "?"', () => {
|
||||
const ts = new Date(Date.now() + 60 * 1000).toISOString();
|
||||
const result = formatDuration(ts);
|
||||
assert.strictEqual(result, '?');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// buildContextBar tests
|
||||
console.log('\nbuildContextBar:');
|
||||
|
||||
if (
|
||||
test('null returns empty string', () => {
|
||||
assert.strictEqual(buildContextBar(null), '');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('undefined returns empty string', () => {
|
||||
assert.strictEqual(buildContextBar(undefined), '');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('80% remaining contains green ANSI code', () => {
|
||||
const bar = buildContextBar(80);
|
||||
assert.ok(bar.includes('\x1b[32m'), `Expected green ANSI in: ${JSON.stringify(bar)}`);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('50% remaining contains yellow ANSI code', () => {
|
||||
const bar = buildContextBar(50);
|
||||
assert.ok(bar.includes('\x1b[33m'), `Expected yellow ANSI in: ${JSON.stringify(bar)}`);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('20% remaining contains red blink ANSI code', () => {
|
||||
const bar = buildContextBar(20);
|
||||
assert.ok(bar.includes('\x1b[5;31m'), `Expected red blink ANSI in: ${JSON.stringify(bar)}`);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('context bar contains block characters', () => {
|
||||
const bar = buildContextBar(60);
|
||||
assert.ok(bar.includes('\u2588') || bar.includes('\u2591'), 'Expected block characters in bar');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('context bar contains percentage', () => {
|
||||
const bar = buildContextBar(70);
|
||||
assert.ok(bar.includes('%'), 'Expected percentage in bar');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// readCurrentTask tests
|
||||
console.log('\nreadCurrentTask:');
|
||||
|
||||
if (
|
||||
test('nonexistent session returns empty string', () => {
|
||||
const result = readCurrentTask('nonexistent-session-xyz-999');
|
||||
assert.strictEqual(result, '');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('empty string session returns empty string', () => {
|
||||
const result = readCurrentTask('');
|
||||
assert.strictEqual(result, '');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// Summary
|
||||
console.log(`\nResults: ${passed} passed, ${failed} failed\n`);
|
||||
return { passed, failed };
|
||||
}
|
||||
|
||||
const { failed } = runTests();
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* Tests for scripts/lib/cost-estimate.js
|
||||
*
|
||||
* Run with: node tests/lib/cost-estimate.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
const { estimateCost, RATE_TABLE } = require('../../scripts/lib/cost-estimate');
|
||||
|
||||
// Test helper
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing cost-estimate.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// RATE_TABLE structure
|
||||
console.log('RATE_TABLE:');
|
||||
|
||||
if (
|
||||
test('RATE_TABLE has haiku, sonnet, opus keys', () => {
|
||||
assert.ok(RATE_TABLE.haiku, 'Missing haiku');
|
||||
assert.ok(RATE_TABLE.sonnet, 'Missing sonnet');
|
||||
assert.ok(RATE_TABLE.opus, 'Missing opus');
|
||||
assert.strictEqual(typeof RATE_TABLE.haiku.in, 'number');
|
||||
assert.strictEqual(typeof RATE_TABLE.haiku.out, 'number');
|
||||
assert.strictEqual(typeof RATE_TABLE.sonnet.in, 'number');
|
||||
assert.strictEqual(typeof RATE_TABLE.sonnet.out, 'number');
|
||||
assert.strictEqual(typeof RATE_TABLE.opus.in, 'number');
|
||||
assert.strictEqual(typeof RATE_TABLE.opus.out, 'number');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// estimateCost tests
|
||||
console.log('\nestimateCost:');
|
||||
|
||||
if (
|
||||
test('opus 1M/1M tokens returns 90', () => {
|
||||
const cost = estimateCost('opus', 1_000_000, 1_000_000);
|
||||
assert.strictEqual(cost, 90);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('sonnet 1M/1M tokens returns 18', () => {
|
||||
const cost = estimateCost('sonnet', 1_000_000, 1_000_000);
|
||||
assert.strictEqual(cost, 18);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('haiku 1M/1M tokens returns 4.8', () => {
|
||||
const cost = estimateCost('haiku', 1_000_000, 1_000_000);
|
||||
assert.strictEqual(cost, 4.8);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('null model with 0 tokens returns 0', () => {
|
||||
const cost = estimateCost(null, 0, 0);
|
||||
assert.strictEqual(cost, 0);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('full model name claude-opus-4-6 uses opus rates', () => {
|
||||
const cost = estimateCost('claude-opus-4-6', 500, 200);
|
||||
// (500 / 1_000_000) * 15 + (200 / 1_000_000) * 75 = 0.0075 + 0.015 = 0.0225
|
||||
const expected = Math.round(0.0225 * 1e6) / 1e6;
|
||||
assert.strictEqual(cost, expected);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('unknown model falls back to sonnet rates', () => {
|
||||
const cost = estimateCost('unknown-model', 1_000_000, 1_000_000);
|
||||
assert.strictEqual(cost, 18);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// Summary
|
||||
console.log(`\nResults: ${passed} passed, ${failed} failed\n`);
|
||||
return { passed, failed };
|
||||
}
|
||||
|
||||
const { failed } = runTests();
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* Tests for scripts/lib/session-bridge.js
|
||||
*
|
||||
* Run with: node tests/lib/session-bridge.test.js
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
|
||||
const { sanitizeSessionId, getBridgePath, readBridge, writeBridgeAtomic, resolveSessionId, MAX_SESSION_ID_LENGTH } = require('../../scripts/lib/session-bridge');
|
||||
|
||||
// Test helper
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` \u2713 ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` \u2717 ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runTests() {
|
||||
console.log('\n=== Testing session-bridge.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
// sanitizeSessionId tests
|
||||
console.log('sanitizeSessionId:');
|
||||
|
||||
if (
|
||||
test('valid ID passes through', () => {
|
||||
assert.strictEqual(sanitizeSessionId('abc-123'), 'abc-123');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('path traversal returns null', () => {
|
||||
assert.strictEqual(sanitizeSessionId('../etc/passwd'), null);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('forward slash returns null', () => {
|
||||
assert.strictEqual(sanitizeSessionId('/tmp/evil'), null);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('backslash returns null', () => {
|
||||
assert.strictEqual(sanitizeSessionId('a\\b'), null);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('null input returns null', () => {
|
||||
assert.strictEqual(sanitizeSessionId(null), null);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('empty string returns null', () => {
|
||||
assert.strictEqual(sanitizeSessionId(''), null);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('long string is truncated to MAX_SESSION_ID_LENGTH', () => {
|
||||
const longId = 'a'.repeat(100);
|
||||
const result = sanitizeSessionId(longId);
|
||||
assert.ok(result, 'Should not return null for valid chars');
|
||||
assert.strictEqual(result.length, MAX_SESSION_ID_LENGTH);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// getBridgePath tests
|
||||
console.log('\ngetBridgePath:');
|
||||
|
||||
if (
|
||||
test('returns path containing ecc-metrics-', () => {
|
||||
const p = getBridgePath('test-session');
|
||||
assert.ok(p.includes('ecc-metrics-'), `Expected ecc-metrics- in path, got: ${p}`);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// writeBridgeAtomic + readBridge roundtrip
|
||||
console.log('\nwriteBridgeAtomic / readBridge:');
|
||||
|
||||
if (
|
||||
test('roundtrip write then read returns same data', () => {
|
||||
const testId = `test-bridge-${Date.now()}`;
|
||||
const data = { session_id: testId, tool_count: 42 };
|
||||
try {
|
||||
writeBridgeAtomic(testId, data);
|
||||
const result = readBridge(testId);
|
||||
assert.deepStrictEqual(result, data);
|
||||
} finally {
|
||||
// Clean up
|
||||
try {
|
||||
fs.unlinkSync(getBridgePath(testId));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('readBridge with nonexistent session returns null', () => {
|
||||
const result = readBridge('nonexistent-session-id-999');
|
||||
assert.strictEqual(result, null);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// resolveSessionId tests
|
||||
console.log('\nresolveSessionId:');
|
||||
|
||||
if (
|
||||
test('resolveSessionId uses ECC_SESSION_ID env var', () => {
|
||||
const original = process.env.ECC_SESSION_ID;
|
||||
try {
|
||||
process.env.ECC_SESSION_ID = 'env-session-42';
|
||||
const result = resolveSessionId();
|
||||
assert.strictEqual(result, 'env-session-42');
|
||||
} finally {
|
||||
if (original === undefined) {
|
||||
delete process.env.ECC_SESSION_ID;
|
||||
} else {
|
||||
process.env.ECC_SESSION_ID = original;
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('MAX_SESSION_ID_LENGTH is 64', () => {
|
||||
assert.strictEqual(MAX_SESSION_ID_LENGTH, 64);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// Summary
|
||||
console.log(`\nResults: ${passed} passed, ${failed} failed\n`);
|
||||
return { passed, failed };
|
||||
}
|
||||
|
||||
const { failed } = runTests();
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
Reference in New Issue
Block a user