From 185af91b4d525db6c192a050b53615772fb5fe11 Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Mon, 26 Oct 2026 16:32:18 +0200 Subject: importance: mark an agent high, normal, or low within a status, more important agents are listed first, and so jumped to first; status stays first. a high agent shows a bold ! before its name, a low agent's name is lightly dimmed with "low" on its second line, and it makes no sound (it still notifies and waits in line). in pi, /importance high|normal|low (completes the levels; none once one is typed out, so Enter runs it). the extension saves it in the session as a custom entry (dingger-importance), so /reload, restarts, /resume and restore bring it back; /new starts at normal. it shows a level other than normal in pi's footer, emits dingger:importance { level } for other extensions, and sends it with every report. for other agents, `dingger importance [level] [--pane]`; it refuses a pi that reports itself, whose saved level would win. the daemon stays the one writer of pane state: it writes @dingger_importance (unset for normal), which goes with the agent and survives a daemon restart. `dingger status` marks high and low. tests: e2e step 21 (order, the option, no sound for low, the menu bar, the command, the extension through a harness that now matches pi's extension API), a DINGGER_SOUND seam so sounds are observable, and a status-line check that read the counts once (flaky) now polls. pi-live: /importance in real pi 1.4, its footer and session entry, and the level after a restore; every pi there now uses the test's agent directory (a restored pi read the user's). negative controls fail each part. --- README.md | 21 +++- crates/dinggerd/assets/pi/pi-agent-state.ts | 72 +++++++++++++- crates/dinggerd/src/hooks.rs | 46 +++++++-- crates/dinggerd/src/importance.rs | 65 +++++++++++++ crates/dinggerd/src/main.rs | 67 ++++++++++++- crates/dinggerd/src/notify.rs | 9 ++ crates/dinggerd/src/tmux.rs | 8 +- crates/dinggerd/src/watcher.rs | 65 ++++++++++--- menubar/main.swift | 17 +++- menubar/model.swift | 53 ++++++++-- menubar/panel.swift | 2 +- menubar/search.swift | 4 +- tests/e2e.sh | 146 ++++++++++++++++++++++++++-- tests/pi-live.sh | 15 ++- 14 files changed, 540 insertions(+), 50 deletions(-) create mode 100644 crates/dinggerd/src/importance.rs diff --git a/README.md b/README.md index a0c4097..13a7b9c 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,18 @@ than a poll is still a completion. skipping the window you already look at. - **Notifications**: "needs attention" (blocked) and "finished" (done), with herdr's sounds; silent for completions you are already watching. +- **Importance**: mark an agent high, normal (the default), or low. Within a + status, more important agents are listed first, so a jump goes to them + first; status still comes first (a blocked low agent before a done high + one). A high agent shows a bold `!` before its name; a low agent's name is + lightly dimmed, its second line says "low", and it makes no sound (it + still notifies, and waits in line like any other). In pi, type + `/importance high|normal|low` (no argument: the current level); pi saves + it with the session, so it comes back after `/reload`, a restart, or a + restore, and its footer shows a level other than normal. For other agents, + run `dingger importance high|normal|low` in the agent's pane (in Claude + Code: `! dingger importance high`), or pass `--pane`; it lasts until the + agent exits. ## Where state comes from @@ -108,7 +120,13 @@ than a poll is still a completion. manifests, for agents without hooks. All per-pane state lives in tmux pane options (`@agent_state`, -`@dingger_since`, `@dingger_hook`), so restarting dingger loses nothing. +`@dingger_since`, `@dingger_hook`, `@dingger_importance`), so restarting +dingger loses nothing. + +The pi extension's reports carry the agent's importance. Other pi +extensions can follow it on pi's event bus: `dingger:importance` with +`{ level }`, at session start and on every change; it is saved in the +session as a custom entry of type `dingger-importance`. ## Usage @@ -116,6 +134,7 @@ All per-pane state lives in tmux pane options (`@agent_state`, dingger watch # the daemon (run by launchd, see below) dingger status # agents in priority order dingger focus [--pane ID] # jump to an agent (default: the top waiter) +dingger importance [LEVEL] # an agent's importance: high, normal, low dingger install pi # install the pi state-reporting extension dingger install claude # report Claude Code sessions, for restore dingger install zsh # record typed command lines, for restore diff --git a/crates/dinggerd/assets/pi/pi-agent-state.ts b/crates/dinggerd/assets/pi/pi-agent-state.ts index ff893e7..4c21afe 100644 --- a/crates/dinggerd/assets/pi/pi-agent-state.ts +++ b/crates/dinggerd/assets/pi/pi-agent-state.ts @@ -69,7 +69,34 @@ function updateSessionFile(ctx: any): void { } } -function report(state: AgentState, message?: string): Promise { +// The agent's importance (/importance): it orders agents within a status +// in dingger's list and jump, and a low agent makes no sound. Saved in the +// session (a custom entry, never sent to the model), so it comes back with +// the conversation: on /resume, `pi --session`, and dingger's restore. +// Other extensions can follow it on the event bus: `dingger:importance`, +// `{ level }`, at session start and on every change. +type Importance = "low" | "normal" | "high"; +const importanceLevels: Importance[] = ["high", "normal", "low"]; +const importanceEntry = "dingger-importance"; +let importance: Importance = "normal"; + +function savedImportance(ctx: any): Importance { + try { + const entries = ctx?.sessionManager?.getEntries?.() ?? []; + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry?.type === "custom" && entry.customType === importanceEntry) { + const level = entry.data?.level; + return importanceLevels.includes(level) ? level : "normal"; + } + } + } catch { + // An unreadable session: the default. + } + return "normal"; +} + +function report(state: AgentState | undefined, message?: string): Promise { return sendRequest({ id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`, method: "pane.report_agent", @@ -77,8 +104,9 @@ function report(state: AgentState, message?: string): Promise { pane_id: paneId!, source, agent: "pi", - state, + ...(state ? { state } : {}), message, + importance, seq: Date.now() * 1000, ...(sessionFile ? { agent_session_path: sessionFile } : {}), }, @@ -161,6 +189,43 @@ export default function (pi) { publishState(); }); + function showImportance(ctx: any) { + ctx?.ui?.setStatus?.("dingger-importance", importance === "normal" ? undefined : `importance: ${importance}`); + pi.events.emit("dingger:importance", { level: importance }); + } + + pi.registerCommand("importance", { + description: "Set this agent's importance in dingger: high, normal, or low", + // None once a level is typed out, so Enter runs the command at once. + getArgumentCompletions: (prefix: string) => + importanceLevels.includes(prefix.trim() as Importance) + ? null + : importanceLevels + .filter((level) => level.startsWith(prefix.trim())) + .map((level) => ({ value: level, label: level })), + handler: async (args: string, ctx: any) => { + const wanted = args.trim(); + if (!wanted) { + ctx.ui.notify(`importance: ${importance}`, "info"); + return; + } + if (!importanceLevels.includes(wanted as Importance)) { + ctx.ui.notify("importance is high, normal, or low", "error"); + return; + } + if (wanted !== importance) { + importance = wanted as Importance; + pi.appendEntry(importanceEntry, { level: importance }); + showImportance(ctx); + // Without a state: the state stays as last reported. + if (enabled()) { + void report(undefined); + } + } + ctx.ui.notify(`importance: ${importance}`, "info"); + }, + }); + pi.on("session_start", async (event, ctx) => { // TUI only: RPC/JSON/print modes are headless. if (ctx?.mode !== "tui") { @@ -169,6 +234,9 @@ export default function (pi) { // A reload can replace this extension mid-run without emitting another agent_start. agentActive = ctx?.isIdle?.() === false; updateSessionFile(ctx); + // Each session has its own: /new starts at normal, /resume brings its own back. + importance = savedImportance(ctx); + showImportance(ctx); publishState(true); }); diff --git a/crates/dinggerd/src/hooks.rs b/crates/dinggerd/src/hooks.rs index c467c98..8b24659 100644 --- a/crates/dinggerd/src/hooks.rs +++ b/crates/dinggerd/src/hooks.rs @@ -10,6 +10,9 @@ //! or `agent_session_id`, herdr's names), which restore uses to resume it. //! A report without a state only records the session (Claude's //! SessionStart hook, `dingger hook claude`); state stays with the screen. +//! A report may also carry the agent's `importance` (`low`, `normal`, +//! `high`): pi's extension sends it with every report, `dingger importance` +//! alone. //! //! Reports are state-change *events*: the watcher applies every one in //! order, so a turn that starts and finishes between two polls is still a @@ -29,6 +32,8 @@ use std::sync::{Condvar, Mutex, MutexGuard, OnceLock}; use std::time::{Duration, Instant}; use detect_core::AgentState; + +use crate::importance::Importance; use serde::Deserialize; use tracing::{debug, info, warn}; @@ -47,6 +52,8 @@ struct PaneReports { pending: Vec, /// The agent's latest session reference. session: Option, + /// The agent's importance, if reported. + importance: Option, /// When the last report arrived. at: Option, } @@ -111,6 +118,8 @@ struct HookParams { agent_session_path: Option, #[serde(default)] agent_session_id: Option, + #[serde(default)] + importance: Option, } fn handle_stream(stream: UnixStream) { @@ -137,16 +146,23 @@ fn handle_stream(stream: UnixStream) { continue; } }; + let importance = params.importance.as_deref().and_then(|value| { + let level = Importance::parse(value); + if level.is_none() { + debug!(importance = value, "unknown importance"); + } + level + }); match state { - Some(state) => info!(pane = %params.pane_id, ?state, "hook report"), - None => info!(pane = %params.pane_id, "hook session report"), + Some(state) => info!(pane = %params.pane_id, ?state, ?importance, "hook report"), + None => info!(pane = %params.pane_id, ?importance, "hook report without state"), } - record(params.pane_id, state, params.agent_session_path.or(params.agent_session_id)); + record(params.pane_id, state, params.agent_session_path.or(params.agent_session_id), importance); wake(); } } -fn record(pane_id: String, state: Option, session: Option) { +fn record(pane_id: String, state: Option, session: Option, importance: Option) { let mut reports = reports(); let pane = reports.entry(pane_id).or_default(); pane.at = Some(Instant::now()); @@ -157,6 +173,9 @@ fn record(pane_id: String, state: Option, session: Option) { if let Some(session) = session.filter(|s| !s.is_empty() && !s.chars().any(char::is_control)) { pane.session = Some(session); } + if importance.is_some() { + pane.importance = importance; + } } /// Start listening; reports accumulate for the watcher to consume. @@ -204,6 +223,11 @@ pub fn session(pane_id: &str) -> Option { reports().get(pane_id).and_then(|pane| pane.session.clone()) } +/// The pane's agent importance, if reported. +pub fn importance(pane_id: &str) -> Option { + reports().get(pane_id).and_then(|pane| pane.importance) +} + /// Drop a pane's reports: the agent was not running when the panes were /// listed at `listed`. A report since then is kept: the agent started /// after the listing (Claude reports its session once, as it starts). @@ -225,7 +249,7 @@ mod tests { #[test] fn a_session_report_records_only_the_session() { - record("%t1".into(), None, Some("/p/s.jsonl".into())); + record("%t1".into(), None, Some("/p/s.jsonl".into()), None); assert_eq!(session("%t1").as_deref(), Some("/p/s.jsonl")); assert_eq!(take_observations("%t1"), None, "state stays with the screen"); assert_eq!(last_state("%t1"), None); @@ -234,10 +258,20 @@ mod tests { #[test] fn a_report_after_the_pane_listing_is_not_dropped() { let listed = Instant::now(); - record("%t2".into(), None, Some("/p/s.jsonl".into())); + record("%t2".into(), None, Some("/p/s.jsonl".into()), None); drop_reports("%t2", listed); assert!(session("%t2").is_some(), "the agent started after the listing"); drop_reports("%t2", Instant::now()); assert_eq!(session("%t2"), None, "listed since, without the agent: dropped"); } + + #[test] + fn importance_is_kept_until_reported_again() { + record("%t3".into(), Some(AgentState::Working), None, Some(Importance::High)); + record("%t3".into(), Some(AgentState::Idle), None, None); + assert_eq!(importance("%t3"), Some(Importance::High), "a report without it keeps it"); + record("%t3".into(), None, None, Some(Importance::Normal)); + assert_eq!(importance("%t3"), Some(Importance::Normal)); + assert_eq!(last_state("%t3"), Some(AgentState::Idle), "a report without state keeps the state"); + } } diff --git a/crates/dinggerd/src/importance.rs b/crates/dinggerd/src/importance.rs new file mode 100644 index 0000000..2480d3e --- /dev/null +++ b/crates/dinggerd/src/importance.rs @@ -0,0 +1,65 @@ +//! An agent's importance, set by the user: high, normal (the default), or +//! low. It orders agents within a status (the list, and so the jump), and +//! a low agent makes no sound. +//! +//! It belongs to the agent's conversation: pi's extension saves it in the +//! session and reports it (`/importance`); other agents get it from +//! `dingger importance`. The daemon writes it to the pane option +//! `@dingger_importance` (unset for normal), which goes with the agent. + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] +pub enum Importance { + Low, + #[default] + Normal, + High, +} + +impl Importance { + pub fn parse(value: &str) -> Option { + match value.trim() { + "low" => Some(Importance::Low), + "normal" => Some(Importance::Normal), + "high" => Some(Importance::High), + _ => None, + } + } + + /// A pane option's value: unset (empty) or anything unknown is normal. + pub fn from_option(value: &str) -> Importance { + Importance::parse(value).unwrap_or_default() + } + + pub fn label(self) -> &'static str { + match self { + Importance::Low => "low", + Importance::Normal => "normal", + Importance::High => "high", + } + } + + /// The pane option's value: normal leaves it unset. + pub fn option_value(self) -> &'static str { + match self { + Importance::Normal => "", + other => other.label(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn levels_order_and_round_trip_through_the_option() { + assert!(Importance::High > Importance::Normal && Importance::Normal > Importance::Low); + for level in [Importance::Low, Importance::Normal, Importance::High] { + assert_eq!(Importance::from_option(level.option_value()), level); + assert_eq!(Importance::parse(level.label()), Some(level)); + } + assert_eq!(Importance::from_option(""), Importance::Normal, "unset is normal"); + assert_eq!(Importance::from_option("urgent"), Importance::Normal); + assert_eq!(Importance::parse("urgent"), None); + } +} diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs index b97471c..c50fb80 100644 --- a/crates/dinggerd/src/main.rs +++ b/crates/dinggerd/src/main.rs @@ -1,6 +1,7 @@ //! dingger: agent state watching for tmux panes. mod hooks; +mod importance; mod integrations; mod manifest_update; mod names; @@ -71,6 +72,16 @@ enum Command { }, /// Wake the watcher so focus changes show up immediately (tmux hooks). Poke, + /// Set an agent's importance: it orders agents within a status (the + /// list, and the jump), and a low agent makes no sound. In pi, use + /// /importance instead: pi saves it with the session. + Importance { + /// `high`, `normal`, or `low`; without it, print the current one. + level: Option, + /// The agent's pane (default: the pane this runs in). + #[arg(long)] + pane: Option, + }, /// Restore tmux sessions saved before tmux last stopped (a crash, /// shutdown, or kill-server): windows, panes, layouts, directories. /// Only adds what is missing; a bare session of the same name (what a @@ -177,6 +188,12 @@ fn main() { std::process::exit(1); } } + Command::Importance { level, pane } => { + if let Err(err) = set_importance(level.as_deref(), pane) { + eprintln!("dingger: {err}"); + std::process::exit(1); + } + } Command::Poke => { // Best effort: a stopped watcher just means nothing to wake. let _ = hooks::poke(); @@ -284,6 +301,46 @@ fn raise(client: &terminals::Client) { } } +/// `dingger importance`: report the pane's agent importance to the daemon, +/// which writes it (the daemon is the one writer of pane state). +fn set_importance(level: Option<&str>, pane: Option) -> Result<(), String> { + let target = pane + .or_else(|| std::env::var("TMUX_PANE").ok()) + .ok_or("not in a tmux pane; pass --pane")?; + let info = tmux::run(&[ + "display-message", + "-p", + "-t", + &target, + "#{pane_id}\t#{@agent}\t#{@dingger_hook}\t#{@dingger_importance}", + ]) + .map_err(|_| format!("no pane {target}"))?; + let fields: Vec<&str> = info.trim_end_matches('\n').split('\t').collect(); + let [pane_id, agent, hook, written] = fields[..] else { + return Err(format!("no pane {target}")); + }; + if agent.is_empty() { + return Err(format!("no agent in pane {target}")); + } + let Some(level) = level else { + println!("{}", importance::Importance::from_option(written).label()); + return Ok(()); + }; + let level = importance::Importance::parse(level).ok_or("importance is high, normal, or low")?; + // pi's extension reports the importance saved in the session; a value + // set here would last only until its next report. + if agent == "pi" && !hook.is_empty() { + return Err("this pi reports to dingger: use /importance in it (pi saves it with the session)".into()); + } + let message = serde_json::json!({ + "method": "pane.report_agent", + "params": { "pane_id": pane_id, "importance": level.label() }, + }); + hooks::send(&message.to_string())?; + println!("importance: {}", level.label()); + Ok(()) +} + /// Every agent pane in herdr's priority order (blocked, done, working, /// idle), oldest-waiting first within a status. fn status() -> Result<(), String> { @@ -297,7 +354,8 @@ fn status() -> Result<(), String> { } panes.sort_by_key(|pane| { let status = watcher::Status::from_label(&pane.written_state); - (watcher::list_order(status, pane.written_since.parse().ok()), pane.display_target.clone()) + let importance = importance::Importance::from_option(&pane.written_importance); + (watcher::list_order(status, importance, pane.written_since.parse().ok()), pane.display_target.clone()) }); let place = |pane: &tmux::Pane| pane.display_target.replacen(':', " · ", 1); let width = panes.iter().map(|pane| place(pane).chars().count()).max().unwrap_or(5).max(5); @@ -305,7 +363,12 @@ fn status() -> Result<(), String> { for pane in &panes { let state = if pane.written_state.is_empty() { "-" } else { &pane.written_state }; let agent = pane.agent.map(detect_core::agent_label).unwrap_or("-"); - println!("{state:<8} {: format!("! {}", pane.written_name), + importance::Importance::Low => format!("{} (low)", pane.written_name), + importance::Importance::Normal => pane.written_name.clone(), + }; + println!("{state:<8} {: "request", + NotificationKind::Finished => "done", + }; + let _ = Command::new("sh").args(["-c", &command, name]).stdin(Stdio::null()).spawn(); + return; + } // Sounds are embedded; afplay needs a file, so stage the bytes in the // system temp dir. let path = std::env::temp_dir().join(format!( diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs index f69acd1..9fcccf3 100644 --- a/crates/dinggerd/src/tmux.rs +++ b/crates/dinggerd/src/tmux.rs @@ -28,6 +28,8 @@ pub struct Pane { pub written_hook: String, pub written_name: String, pub written_session: String, + /// `@dingger_importance`: unset for normal. + pub written_importance: String, } @@ -108,14 +110,15 @@ pub fn list_panes() -> Result, String> { "#{pane_index}\t", "#{pane_current_path}\t", "#{@agent_name}\t", - "#{@agent_session}", + "#{@agent_session}\t", + "#{@dingger_importance}", ); let out = run_tmux(&["list-panes", "-a", "-F", format])?; let mut panes: Vec = Vec::new(); let mut process_tree = None; for line in out.lines() { let fields: Vec<&str> = line.split('\t').collect(); - if fields.len() < 19 { + if fields.len() < 20 { continue; } let attended = fields[4] == "1" && fields[5] != "0"; @@ -157,6 +160,7 @@ pub fn list_panes() -> Result, String> { written_hook: fields[12].to_string(), written_name: fields[17].to_string(), written_session: fields[18].to_string(), + written_importance: fields[19].to_string(), }); } Ok(panes) diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs index 9198f60..f9c987e 100644 --- a/crates/dinggerd/src/watcher.rs +++ b/crates/dinggerd/src/watcher.rs @@ -31,6 +31,7 @@ use detect_core::{detect_agent_with_osc, AgentState}; use tracing::{debug, info, warn}; use crate::hooks; +use crate::importance::Importance; use crate::notify::{play_sound, send_notification, NotificationKind}; use crate::tmux; @@ -172,15 +173,29 @@ pub fn advance( } /// The order agents are listed in: herdr's status priority; within a -/// status, done and blocked oldest first (the queue: nothing waits -/// forever), working and idle most recent first; then by place. -pub fn list_order(status: Status, since: Option) -> (std::cmp::Reverse, i128) { +/// status, the more important first; then done and blocked oldest first +/// (the queue: nothing waits forever), working and idle most recent first; +/// then by place. +pub fn list_order( + status: Status, + importance: Importance, + since: Option, +) -> (std::cmp::Reverse, std::cmp::Reverse, i128) { let since = since.map(i128::from); let key = match status { Status::Done | Status::Blocked => since.unwrap_or(i128::MAX), _ => since.map(|since| -since).unwrap_or(i128::MAX), }; - (std::cmp::Reverse(status.priority()), key) + (std::cmp::Reverse(status.priority()), std::cmp::Reverse(importance), key) +} + +/// A pane's importance: as its agent last reported it, else as written +/// (it survives a daemon restart); normal without an agent. +pub fn importance_of(pane: &tmux::Pane) -> Importance { + if pane.agent.is_none() { + return Importance::Normal; + } + hooks::importance(&pane.pane_id).unwrap_or_else(|| Importance::from_option(&pane.written_importance)) } pub struct Watcher { @@ -330,6 +345,14 @@ impl Watcher { (None, _) => change("@agent_session", &pane.written_session, String::new()), _ => {} } + // The importance goes with the agent too. + match (pane.agent, hooks::importance(&pane.pane_id)) { + (Some(_), Some(level)) => { + change("@dingger_importance", &pane.written_importance, level.option_value().to_string()) + } + (None, _) => change("@dingger_importance", &pane.written_importance, String::new()), + _ => {} + } if let Err(err) = tmux::set_pane_options(&pane.pane_id, &changes) { warn!(target = %pane.display_target, %err, "failed to write pane options"); } @@ -346,7 +369,8 @@ impl Watcher { if self.notifications && !watching { send_notification(agent, &kind, &format!("{} ({agent})", pane.display_target)); } - if self.sounds { + // A low agent makes no sound; it still notifies and waits in line. + if self.sounds && importance_of(pane) != Importance::Low { play_sound(&kind); } } @@ -355,7 +379,7 @@ impl Watcher { /// status line, menu bar, and focus. The queue is ordered like the menu: /// herdr's priority (blocked before done), oldest first within it. fn publish(&mut self, panes: &[tmux::Pane]) { - let mut queue: Vec<(std::cmp::Reverse, u64, &str)> = Vec::new(); + let mut queue = Vec::new(); let (mut blocked, mut done, mut working) = (0, 0, 0); for pane in panes { let Some(track) = self.tracks.get(&pane.pane_id) else { @@ -367,16 +391,15 @@ impl Watcher { Status::Working => working += 1, Status::Idle | Status::Unknown => {} } - // The queue: what needs the user, oldest first. + // The queue: what needs the user, in list order. if matches!(track.status(), Status::Blocked | Status::Done) { - let since = track.since.unwrap_or(u64::MAX); - queue.push((std::cmp::Reverse(track.status().priority()), since, pane.pane_id.as_str())); + queue.push((list_order(track.status(), importance_of(pane), track.since), pane.pane_id.as_str())); } } queue.sort(); let queue = queue .iter() - .map(|(_, _, pane_id)| *pane_id) + .map(|(_, pane_id)| *pane_id) .collect::>() .join(","); @@ -587,7 +610,7 @@ mod tests { ("blocked", Status::Blocked, Some(99)), ("working new", Status::Working, Some(70)), ]; - rows.sort_by_key(|(_, status, since)| list_order(*status, *since)); + rows.sort_by_key(|(_, status, since)| list_order(*status, Importance::Normal, *since)); let names: Vec<_> = rows.iter().map(|(name, ..)| *name).collect(); assert_eq!( names, @@ -595,6 +618,26 @@ mod tests { ); } + #[test] + fn importance_orders_within_a_status_before_time() { + use Importance::*; + let mut rows = [ + ("done old", Status::Done, Normal, Some(10)), + ("done new high", Status::Done, High, Some(90)), + ("done older low", Status::Done, Low, Some(5)), + ("blocked low", Status::Blocked, Low, Some(99)), + ("working high", Status::Working, High, Some(1)), + ("working new", Status::Working, Normal, Some(80)), + ]; + rows.sort_by_key(|(_, status, importance, since)| list_order(*status, *importance, *since)); + let names: Vec<_> = rows.iter().map(|(name, ..)| *name).collect(); + assert_eq!( + names, + ["blocked low", "done new high", "done old", "done older low", "working high", "working new"], + "status first, then importance, then time" + ); + } + #[test] fn agent_exit_is_unknown() { let done = step(step(PaneTrack::NEW, Working, false), Idle, false); diff --git a/menubar/main.swift b/menubar/main.swift index 17b9fcc..c37386e 100644 --- a/menubar/main.swift +++ b/menubar/main.swift @@ -397,6 +397,21 @@ if arguments.contains("--selftest") { since: $0.2, path: "~") }.sorted(by: listedBefore).map(\.name) let expected = ["blocked", "done old", "done new", "working new", "working old", "idle new", "idle old", "idle unknown"] if listed != expected { failures.append("order: waiting oldest first, the rest most recent first: \(listed)") } + let byImportance: [(String, Status, Importance, Int)] = [ + ("done old", .done, .normal, 10), ("done new high", .done, .high, 90), ("done older low", .done, .low, 5), + ("blocked low", .blocked, .low, 99), ("working high", .working, .high, 1), ("working new", .working, .normal, 80), + ] + let importanceListed = byImportance.map { Row(paneId: $0.0, agent: "pi", name: $0.0, status: $0.1, session: "s", + window: "w", since: $0.3, path: "~", importance: $0.2) } + .sorted(by: listedBefore).map(\.name) + let importanceExpected = ["blocked low", "done new high", "done old", "done older low", "working high", "working new"] + if importanceListed != importanceExpected { + failures.append("order: status first, then importance, then time: \(importanceListed)") + } + let high = Row(paneId: "%1", agent: "pi", name: "Fix it", status: .done, session: "s", window: "w", since: 1, importance: .high) + if rowTitle(high).string != "✓ ! Fix it" || rowTitlePrefix(high) != "✓ ! " { + failures.append("title: a high agent is marked: \(rowTitle(high).string)") + } failures.forEach { print("FAIL \($0)") } print(failures.isEmpty ? "selftest ok" : "selftest: \(failures.count) failed") exit(failures.isEmpty ? 0 : 1) @@ -405,7 +420,7 @@ if arguments.contains("--print") { let rows = fetchRows() print("title: \(plainTitle(for: rows))") for row in rows { - print("\(row.status.symbol) \(row.status.rawValue)\t\(row.label)\t\(row.subtitle)") + print("\(row.status.symbol) \(row.status.rawValue)\t\(row.markedLabel)\t\(row.subtitle)") } exit(0) } diff --git a/menubar/model.swift b/menubar/model.swift index 8bed858..760ff31 100644 --- a/menubar/model.swift +++ b/menubar/model.swift @@ -65,6 +65,20 @@ enum Status: String { } } +/// The agent's importance (`@dingger_importance`, unset for normal): it +/// orders agents within a status, and a low agent makes no sound. +enum Importance: Int { + case low = 0, normal = 1, high = 2 + + init(option: String) { + switch option { + case "high": self = .high + case "low": self = .low + default: self = .normal + } + } +} + struct Row: Equatable { let paneId: String let agent: String @@ -76,6 +90,7 @@ struct Row: Equatable { let since: Int /// The pane's working directory, home as "~". var path: String = "" + var importance: Importance = .normal var location: String { "\(session) · \(window)" } var waiting: Bool { status == .blocked || status == .done } @@ -86,9 +101,14 @@ struct Row: Equatable { return name.count > 80 ? String(name.prefix(79)) + "…" : name } - /// Dim second line: where it runs, which harness, and how long it waits. + /// The title's text as plain text: `! ` marks a high agent. + var markedLabel: String { (importance == .high ? "! " : "") + label } + + /// Dim second line: where it runs, which harness, whether it is low, + /// and how long it waits. var subtitle: String { var parts = [location, agent] + if importance == .low { parts.append("low") } if waiting, since != Int.max { parts.append("\(status.rawValue) \(elapsed(sinceMs: since))") } @@ -219,13 +239,15 @@ func runDinggerInLoginShell(_ args: [String]) -> (ok: Bool, output: String) { private let home = NSHomeDirectory() -/// Every agent pane: herdr's status priority; within a status, done and -/// blocked oldest first (the queue), working and idle most recent first -/// (`@dingger_since`: when the pane entered its status); then by place. +/// Every agent pane: herdr's status priority; within a status, the more +/// important first, then done and blocked oldest first (the queue), +/// working and idle most recent first (`@dingger_since`: when the pane +/// entered its status); then by place. func fetchRows() -> [Row] { let format = [ "#{pane_id}", "#{@agent}", "#{@agent_state}", "#{@dingger_since}", "#{@agent_name}", "#{session_name}", "#{window_name}", "#{window_panes}", "#{pane_index}", "#{pane_current_path}", + "#{@dingger_importance}", ].joined(separator: "\t") guard let out = tmux(["list-panes", "-a", "-F", format]) else { return [] } // A pane in several sessions (a session group, a linked window) is @@ -233,13 +255,14 @@ func fetchRows() -> [Row] { var listed = Set() let rows: [Row] = out.split(separator: "\n").compactMap { line in let f = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init) - guard f.count >= 10, !f[1].isEmpty, listed.insert(f[0]).inserted else { return nil } + guard f.count >= 11, !f[1].isEmpty, listed.insert(f[0]).inserted else { return nil } let status = Status(rawValue: f[2]) ?? .unknown guard status != .unknown else { return nil } let window = (Int(f[7]) ?? 1) > 1 ? "\(f[6]).\(f[8])" : f[6] return Row(paneId: f[0], agent: f[1], name: f[4], status: status, session: f[5], window: window, since: Int(f[3]) ?? Int.max, - path: f[9].hasPrefix(home) ? "~" + f[9].dropFirst(home.count) : f[9]) + path: f[9].hasPrefix(home) ? "~" + f[9].dropFirst(home.count) : f[9], + importance: Importance(option: f[10])) } return rows.sorted(by: listedBefore) } @@ -247,6 +270,7 @@ func fetchRows() -> [Row] { /// The list order (see `fetchRows`). func listedBefore(_ a: Row, _ b: Row) -> Bool { if a.status.priority != b.status.priority { return a.status.priority > b.status.priority } + if a.importance != b.importance { return a.importance.rawValue > b.importance.rawValue } if a.since != b.since { // Int.max: never stamped; last either way. if a.since == Int.max || b.since == Int.max { return a.since < b.since } @@ -291,8 +315,15 @@ func plainTitle(for rows: [Row]) -> String { title(for: rows).string } -/// Row title: the colored status symbol, then the agent's name. Unnamed -/// agents show their harness, dimmed, so it never competes with real names. +/// The title's characters before the name: the status symbol, and `!` for a +/// high agent. +func rowTitlePrefix(_ row: Row) -> String { + "\(row.status.symbol) " + (row.importance == .high ? "! " : "") +} + +/// Row title: the colored status symbol, a bold `!` for a high agent, then +/// the agent's name. Unnamed agents show their harness, dimmed, so it never +/// competes with real names; a low agent's name is lightly dimmed. func rowTitle(_ row: Row, primary: NSColor = .labelColor, secondary: NSColor = .secondaryLabelColor) -> NSAttributedString { let font = NSFont.menuFont(ofSize: 0) @@ -300,7 +331,11 @@ func rowTitle(_ row: Row, primary: NSColor = .labelColor, let text = NSMutableAttributedString() text.append(NSAttributedString( string: "\(row.status.symbol) ", attributes: [.font: mono, .foregroundColor: row.status.color])) - let labelColor = row.name.isEmpty ? secondary : primary + if row.importance == .high { + text.append(NSAttributedString( + string: "! ", attributes: [.font: NSFont.boldSystemFont(ofSize: font.pointSize), .foregroundColor: primary])) + } + let labelColor = row.name.isEmpty ? secondary : row.importance == .low ? primary.withAlphaComponent(0.7) : primary text.append(NSAttributedString( string: row.label, attributes: [.font: font, .foregroundColor: labelColor])) return text diff --git a/menubar/panel.swift b/menubar/panel.swift index 6ab85f2..8edab70 100644 --- a/menubar/panel.swift +++ b/menubar/panel.swift @@ -153,7 +153,7 @@ struct PanelContent { } for (index, row) in rows.enumerated() { if index == separatorBefore { lines.append("---") } - lines.append("\(row.status.symbol) \(row.status.rawValue)\t\(row.label)\t\(row.subtitle)") + lines.append("\(row.status.symbol) \(row.status.rawValue)\t\(row.markedLabel)\t\(row.subtitle)") } if overflow > 0 { lines.append("+\(overflow) more waiting") } if showsSummary { diff --git a/menubar/search.swift b/menubar/search.swift index 21db063..2bc044a 100644 --- a/menubar/search.swift +++ b/menubar/search.swift @@ -171,7 +171,7 @@ struct SearchState: Equatable { for match in visible { let row = match.row let marker = row.paneId == selected ? "›" : " " - lines.append("\(marker) \(row.status.symbol) \(row.status.rawValue)\t\(row.label)\t\(row.subtitle)") + lines.append("\(marker) \(row.status.symbol) \(row.status.rawValue)\t\(row.markedLabel)\t\(row.subtitle)") } lines.append(footer) return lines.joined(separator: "\n") @@ -192,7 +192,7 @@ private func highlightedTitle(_ match: SearchMatch) -> NSAttributedString { let text = NSMutableAttributedString( attributedString: rowTitle(match.row, primary: mochaText, secondary: mochaSubtext)) guard !match.labelPositions.isEmpty else { return text } - let prefix = "\(match.row.status.symbol) ".utf16.count + let prefix = rowTitlePrefix(match.row).utf16.count let font = NSFont.boldSystemFont(ofSize: NSFont.menuFont(ofSize: 0).pointSize) let label = Array(match.row.label) var utf16Offset = prefix diff --git a/tests/e2e.sh b/tests/e2e.sh index f9fcf18..20ce5dc 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -43,11 +43,13 @@ cleanup() { trap cleanup EXIT # Notifications are on, delivered by a fake notifier that takes 3s like the -# real one can; the UI must not wait for it. +# real one can; the UI must not wait for it. Sounds are on, played by a fake +# player that logs them. NOTES="$DIR/notifications" +SOUNDS="$DIR/sounds" start_daemon() { - DINGGER_NOTIFIER="sleep 3; echo \"\$0|\$1\" >> $NOTES" RUST_LOG=debug \ - "$DINGGER" watch --interval-secs 30 --no-sounds >>"$DIR/daemon.log" 2>&1 & + DINGGER_NOTIFIER="sleep 3; echo \"\$0|\$1\" >> $NOTES" DINGGER_SOUND="echo \"\$0\" >> $SOUNDS" RUST_LOG=debug \ + "$DINGGER" watch --interval-secs 30 >>"$DIR/daemon.log" 2>&1 & DAEMON_PID=$! for _ in $(seq 50); do [ -S "$HOOK" ] && return; sleep 0.1; done echo "daemon did not start"; cat "$DIR/daemon.log"; exit 1 @@ -223,7 +225,12 @@ hook "$A1" working; hook "$A1" idle expect "A2 blocked" "opt $A2 @agent_state" blocked expect "A1 done" "opt $A1 @agent_state" done t source-file "$ROOT/packaging/tmux/dingger.tmux" -segment=$(t display -p -c "$CLIENT" '#{T:status-right}' | sed 's/#\[[^]]*\]//g') +# The counts are published right after the state: poll, like expect. +for _ in $(seq 40); do + segment=$(t display -p -c "$CLIENT" '#{T:status-right}' | sed 's/#\[[^]]*\]//g') + case "$segment" in *"×1 ✓1") break ;; esac + sleep 0.1 +done case "$segment" in *"×1"*"✓1"*) pass "segment shows ×1 ✓1 ($segment)";; *) fail "segment: [$segment]";; esac expect "mouse binding installed" "t list-keys -T root | grep MouseDown1StatusRight | grep -c 'dingger focus'" 1 expect "focus hook installed: learns the terminal window" "t show-hooks -g client-focus-in | grep -c 'dingger terminal focused'" 1 @@ -272,13 +279,43 @@ fi # --- 12. the shipped pi extension reports for its own pane ------------------- echo "12. pi extension" cat >"$DIR/ext-harness.mjs" <<'JS' -// Loads the shipped extension with a fake pi and fires lifecycle events. +// Loads the shipped extension with a fake pi and fires lifecycle events +// ("agent_start"), runs commands ("/importance high"), and asks for argument +// completions ("complete:importance h"). Custom session entries persist in +// $SESSION_ENTRIES, so a later run reads them back as the session; what the +// extension shows and emits goes to $EXT_LOG. +import fs from "node:fs"; const ext = await import(process.env.EXTENSION); -const on = {}; -ext.default({ on: (name, fn) => (on[name] = fn), events: { on: () => {} } }); -const ctx = { mode: "tui", isIdle: () => true }; -for (const event of process.argv.slice(2)) { - await on[event]?.({ type: event, reason: "reload" }, ctx); +const on = {}, commands = {}; +const store = process.env.SESSION_ENTRIES; +const entries = store && fs.existsSync(store) ? JSON.parse(fs.readFileSync(store, "utf8")) : []; +const log = (line) => process.env.EXT_LOG && fs.appendFileSync(process.env.EXT_LOG, line + "\n"); +ext.default({ + on: (name, fn) => (on[name] = fn), + registerCommand: (name, command) => (commands[name] = command), + appendEntry: (customType, data) => { + entries.push({ type: "custom", customType, data }); + if (store) fs.writeFileSync(store, JSON.stringify(entries)); + }, + events: { on: () => {}, emit: (name, data) => log(`event ${name} ${JSON.stringify(data)}`) }, +}); +const ctx = { + mode: "tui", + isIdle: () => true, + sessionManager: { getEntries: () => entries, getSessionFile: () => undefined }, + ui: { setStatus: (key, text) => log(`status ${key} ${text ?? ""}`), notify: (text) => log(`notify ${text}`) }, +}; +for (const arg of process.argv.slice(2)) { + if (arg.startsWith("/")) { + const [name, ...rest] = arg.slice(1).split(" "); + await commands[name]?.handler(rest.join(" "), ctx); + } else if (arg.startsWith("complete:")) { + const [name, ...rest] = arg.slice("complete:".length).split(" "); + const items = await commands[name]?.getArgumentCompletions?.(rest.join(" ")); + log(`complete ${(items ?? []).map((item) => item.value).join(" ")}`); + } else { + await on[arg]?.({ type: arg, reason: "reload" }, ctx); + } await new Promise((r) => setTimeout(r, 300)); } JS @@ -793,6 +830,95 @@ renv "$DINGGER" focus --client "$TTY1" --pane "$(r display -p -t '=grp:agent' '# expect "a window on a session group stays on its own session" "client_session $TTY1" grp-b expect "showing the agent" "r display -p -c $TTY1 '#{window_name}'" agent +# --- 21. importance -------------------------------------------------------- +# Fresh agents on the first server (its daemon polls every 30 s: updates +# arrive by reports and pokes). Nobody looks at session imp. +echo "21. importance" +report() { # pane state importance (either may be empty) + python3 - "$HOOK" "$1" "$2" "$3" <<'PY' +import json, socket, sys +params = {"pane_id": sys.argv[2], "agent": "pi"} +if sys.argv[3]: params["state"] = sys.argv[3] +if sys.argv[4]: params["importance"] = sys.argv[4] +s = socket.socket(socket.AF_UNIX) +s.connect(sys.argv[1]) +s.sendall((json.dumps({"method": "pane.report_agent", "params": params}) + "\n").encode()) +s.close() +PY +} +t new-session -d -s imp -n i1 -c "$DIR" -x 120 -y 40 "$DIR/bin/pi" +t new-window -d -t imp: -n i2 -c "$DIR" "$DIR/bin/pi" +t new-window -d -t imp: -n i3 -c "$DIR" "$DIR/bin/pi" +t new-window -d -t imp: -n i4 -c "$DIR" "$DIR/bin/pi" +I1=$(t display -p -t imp:i1 '#{pane_id}'); I2=$(t display -p -t imp:i2 '#{pane_id}') +I3=$(t display -p -t imp:i3 '#{pane_id}'); I4=$(t display -p -t imp:i4 '#{pane_id}') +iqueue() { gopt @dingger_queue | tr ',' '\n' | grep -xE "$I1|$I2|$I3" | tr '\n' ' '; } +for p in "$I1" "$I2" "$I3"; do report "$p" working ""; expect "imp: $p working" "opt $p @agent_state" working; done +for p in "$I1" "$I2" "$I3"; do report "$p" idle ""; expect "imp: $p done" "opt $p @agent_state" done; sleep 0.05; done +expect "same importance: oldest done first" "iqueue" "$I1 $I2 $I3 " +report "$I3" "" high +expect "a high agent's pane option" "opt $I3 @dingger_importance" high +expect "importance before time: the high agent first" "iqueue" "$I3 $I1 $I2 " +expect "and its status unchanged (a report without state)" "opt $I3 @agent_state" done +report "$I1" "" low +expect "a low agent last" "iqueue" "$I3 $I2 $I1 " +report "$I3" "" normal +expect "normal leaves the option unset" "opt $I3 @dingger_importance" "" +expect "and the order by time again" "iqueue" "$I2 $I3 $I1 " +report "$I3" "" high +expect "imp: high again" "iqueue" "$I3 $I2 $I1 " +report "$I1" blocked "" +expect "imp: a low agent blocked" "opt $I1 @agent_state" blocked +expect "status before importance: the blocked low agent first" "iqueue" "$I1 $I3 $I2 " +report "$I1" working "" +expect "imp: working again" "opt $I1 @agent_state" working +# Let every alert so far land (the fake notifier takes 3 s). +sleep 3.5; : >"$SOUNDS"; : >"$NOTES" +report "$I1" working ""; report "$I1" idle "" +expect "a low agent finishing" "opt $I1 @agent_state" done +sleep 0.5 +expect "makes no sound" "cat $SOUNDS" "" +expect "but still notifies" "grep -c '^pi finished|imp:i1' $NOTES" 1 60 +report "$I2" working ""; report "$I2" idle "" +expect "a normal agent finishing sounds" "cat $SOUNDS" done +mb() { DINGGER_TMUX_SOCKET="$SOCK" DINGGER_BIN="$DINGGER" "$MENUBAR" "$@"; } +expect "menu: a high agent is marked" "mb --print | grep -F 'imp · i3' | cut -f2" "! pi" +expect "menu: a low agent says so" "mb --print | grep -F 'imp · i1' | cut -f3" "imp · i1 · pi · low · done now" +expect "menu: high, normal, low among the done" \ + "mb --print | grep -F 'imp · ' | grep '^✓' | cut -f3 | cut -d' ' -f3 | tr '\n' ' '" "i3 i2 i1 " +expect "dingger importance shows it" "$DINGGER importance --pane $I1" low +expect "a pi that reports itself is set in pi" \ + "$DINGGER importance high --pane $I1 2>&1" "dingger: this pi reports to dingger: use /importance in it (pi saves it with the session)" +expect "dingger importance needs an agent" "$DINGGER importance high --pane \$(t display -p -t viewer: '#{pane_id}') 2>&1 | cut -c1-24" "dingger: no agent in pan" +$DINGGER poke; expect "imp: the plain pi is identified" "opt $I4 @agent" pi +expect "dingger importance sets another agent's" "$DINGGER importance high --pane $I4" "importance: high" +expect "the daemon writes it" "opt $I4 @dingger_importance" high +t respawn-pane -k -t "$I4" "sleep 100000"; $DINGGER poke +expect "it goes with the agent" "opt $I4 @dingger_importance" "" +kill "$DAEMON_PID"; sleep 0.3; start_daemon; $DINGGER poke +sleep 1 +expect "a daemon restart keeps it" "opt $I3 @dingger_importance" high +expect "and the order" "iqueue" "$I3 $I2 $I1 " +if command -v node >/dev/null; then + ENTRIES="$DIR/entries.json"; EXTLOG="$DIR/ext.log" + iext() { SESSION_ENTRIES="$ENTRIES" EXT_LOG="$EXTLOG" ext "$@"; } + iext "$I2" session_start "/importance high" + expect "/importance high reaches dingger" "opt $I2 @dingger_importance" high + expect "saved in the session" "grep -c '\"customType\":\"dingger-importance\",\"data\":{\"level\":\"high\"}' $ENTRIES" 1 + expect "shown in pi's footer" "grep -c '^status dingger-importance importance: high$' $EXTLOG" 1 + expect "announced to other extensions" "grep -c '^event dingger:importance {\"level\":\"high\"}$' $EXTLOG" 1 + report "$I2" "" normal + expect "imp: set back by hand" "opt $I2 @dingger_importance" "" + iext "$I2" session_start + expect "a new pi on the session reports it again" "opt $I2 @dingger_importance" high + # pi starts a session before any command runs. + : >"$EXTLOG"; iext "$I2" session_start "/importance urgent" "complete:importance h" "/importance normal" + expect "an unknown level is refused" "grep -c '^notify importance is high, normal, or low$' $EXTLOG" 1 + expect "levels complete" "grep '^complete' $EXTLOG" "complete high" + expect "normal reaches dingger" "opt $I2 @dingger_importance" "" + expect "and leaves the footer" "grep -c '^status dingger-importance $' $EXTLOG" 1 +fi + # Every daemon this run started must stop with it. kill "$DAEMON_PID" "$DAEMON2_PID" 2>/dev/null expect "the test daemons stop" "ps -o pid= -p $DAEMON_PID,$DAEMON2_PID | wc -l | tr -d ' '" 0 diff --git a/tests/pi-live.sh b/tests/pi-live.sh index de35bc5..3f4b4b8 100755 --- a/tests/pi-live.sh +++ b/tests/pi-live.sh @@ -7,8 +7,9 @@ # tests/pi-live.sh [dingger binary] # # Covers: identification, the extension's state and session reports, a turn -# (working, then done), /reload, an extension's blocked event, screen -# detection for a pi without the extension, restore (`pi --session`), exit. +# (working, then done), /reload, an extension's blocked event, /importance, +# screen detection for a pi without the extension, restore (`pi --session`, +# importance included), exit. set -u PI_BIN=$(cd "$(dirname "$1")" && pwd)/$(basename "$1") ROOT=$(cd "$(dirname "$0")/.." && pwd) @@ -65,7 +66,9 @@ ln -s "$PI_BIN" "$D/bin/pi" # Plain shells: a login shell would read the user's profile, which puts the # user's own pi first on PATH. printf 'set -g default-shell /bin/sh\nset -g default-command /bin/sh\n' >"$D/tmux.conf" -export PATH="$D/bin:$PATH" PI_OFFLINE=1 PI_SKIP_VERSION_CHECK=1 PI_TELEMETRY=0 +# Every pi here uses the test's agent directory, including one a restore +# starts (`pi --session`, without the command line's own variables). +export PATH="$D/bin:$PATH" PI_CODING_AGENT_DIR="$D/agent" PI_OFFLINE=1 PI_SKIP_VERSION_CHECK=1 PI_TELEMETRY=0 export DINGGER_TMUX_SOCKET="$R" DINGGER_AGENT_SOCKET="$D/hook.sock" DINGGER_STATE_DIR="$D/state" DINGGER_TMUX_CONFIG="$D/tmux.conf" t -f "$D/tmux.conf" new-session -d -s main -n agent -x 140 -y 40 -c "$D/work" t new-window -d -t main: -n plain -c "$D/work2" @@ -104,6 +107,11 @@ type_line agent "/ask" sleep 1.5 note "during a pi confirm dialog dingger shows: $(st agent) (pi gives extensions no dialog event)" t send-keys -t main:agent Escape +sleep 0.5 +type_line agent "/importance high" +expect "/importance high: the pane option" "t show -pv -t main:agent @dingger_importance 2>/dev/null" high 50 +expect "and pi's footer (its last line)" "t capture-pane -p -t main:agent | grep -v '^ *$' | tail -1 | sed 's/^ *//'" "importance: high" 30 +expect "saved in the session file" "grep -c '\"customType\":\"dingger-importance\"' \"\$(t show -pv -t main:agent @agent_session)\"" 1 30 echo "2. screen detection (pi without the extension)" t send-keys -t main:plain "PI_CODING_AGENT_DIR=$D/agent-plain pi $NO_MCP" Enter @@ -125,6 +133,7 @@ expect "restore resumes it by its session file" "grep -c -- \"resume .*pi --sess expect "resumed 1 of 1 agents" "grep -c '^resumed 1 of 1 agents' $D/restore.out" 1 expect "it runs again, reporting the same session" "t show -pv -t main:agent @agent_session 2>/dev/null" "$SESSION" 150 expect "with its conversation" "t capture-pane -p -t main:agent -S - | grep -c 'slow 3 hello'" 1 50 +expect "and its importance" "t show -pv -t main:agent @dingger_importance 2>/dev/null" high 50 echo "4. exit" t send-keys -t main:agent C-d -- cgit v1.2.3