diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-16 02:26:14 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-16 02:26:14 +0300 |
| commit | 72770305d388fd5ec247493e05a89b686c6d0026 (patch) | |
| tree | 44d95c9c69042ab07e27ace37853e190b6f00ba0 | |
| parent | 58c379f1f07adb4113e5f9ee673768b2e4b5be07 (diff) | |
feat: add hook-owned custom agent status labels
| -rw-r--r-- | .pi/docs/SOCKET_API.md | 17 | ||||
| -rw-r--r-- | src/api/mod.rs | 1 | ||||
| -rw-r--r-- | src/api/schema.rs | 10 | ||||
| -rw-r--r-- | src/app/actions.rs | 11 | ||||
| -rw-r--r-- | src/app/api.rs | 12 | ||||
| -rw-r--r-- | src/app/api_helpers.rs | 9 | ||||
| -rw-r--r-- | src/app/creation.rs | 1 | ||||
| -rw-r--r-- | src/cli.rs | 121 | ||||
| -rw-r--r-- | src/events.rs | 1 | ||||
| -rw-r--r-- | src/pane/state.rs | 42 | ||||
| -rw-r--r-- | src/server/headless.rs | 1 | ||||
| -rw-r--r-- | src/ui/mobile.rs | 4 | ||||
| -rw-r--r-- | src/ui/sidebar.rs | 8 | ||||
| -rw-r--r-- | src/workspace/aggregate.rs | 2 |
14 files changed, 228 insertions, 12 deletions
diff --git a/.pi/docs/SOCKET_API.md b/.pi/docs/SOCKET_API.md index 51046860..783d6e97 100644 --- a/.pi/docs/SOCKET_API.md +++ b/.pi/docs/SOCKET_API.md @@ -148,6 +148,7 @@ for backward compatibility, requests also accept the older positional forms like "label": "reviewer", "agent": "pi", "agent_status": "working", + "custom_status": "indexing", "revision": 0 } ``` @@ -160,6 +161,8 @@ for backward compatibility, requests also accept the older positional forms like - when a hook or plugin reports a custom agent through `pane.report_agent`, this can be any non-empty label like `hermes` - when no agent identity is known, it is omitted +`custom_status` is an optional hook-owned display label. It is present only while hook authority is active. It does not change `agent_status`, `done` semantics, waits, notifications, or workspace/tab rollup priority. + `pane_read` responses contain objects like: ```json @@ -624,7 +627,8 @@ params: "source": "custom:hermes", "agent": "hermes", "state": "working", - "message": "running tools" + "message": "running tools", + "custom_status": "indexing" } ``` @@ -637,6 +641,9 @@ notes: - while this authority is active, the reported `agent` and `state` override heuristic display for that pane - process detection still owns pane liveness and fallback when hook authority is cleared or released - `message` is optional metadata for the reporting integration +- `custom_status` is an optional short display label such as `scheduled`, `indexing`, or `stuck` +- `custom_status` is visual-only; use `state` for semantic behavior like working or blocked +- omitting `custom_status` clears any previous custom status from this reporting source returns `ok`. @@ -916,12 +923,13 @@ example pushed `pane.agent_status_changed` event: "pane_id": "1-1", "workspace_id": "1", "agent_status": "done", - "agent": "pi" + "agent": "pi", + "custom_status": "scheduled" } } ``` -`agent` in pushed events follows the same rules as `pane_info.agent`: it may be a built-in detected name, a custom hook-reported label, or omitted. +`agent` in pushed events follows the same rules as `pane_info.agent`: it may be a built-in detected name, a custom hook-reported label, or omitted. `custom_status` may be included when the pane has hook-owned custom status metadata at the time of the semantic status transition. ## cli wrappers these commands provide the shell-facing control surface. most command groups talk to the local socket; `status client` only inspects the local executable. @@ -971,6 +979,7 @@ herdr pane split <pane_id> --direction right|down [--cwd PATH] [--focus] [--no-f herdr pane close <pane_id> herdr pane send-text <pane_id> <text> herdr pane send-keys <pane_id> <key> [key ...] +herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N] herdr pane run <pane_id> <command> ``` @@ -998,7 +1007,7 @@ herdr wait agent-status <pane_id> --status <idle|working|blocked|done|unknown> [ - `pane read` prints **text**, not json - `pane read --format ansi` and `pane read --ansi` print a rendered ANSI snapshot with colors/styles preserved - `pane read --source recent-unwrapped` returns recent terminal text with soft wraps joined back together -- `pane send-text`, `pane send-keys`, and `pane run` print nothing on success +- `pane send-text`, `pane send-keys`, `pane report-agent`, and `pane run` print nothing on success - list/get/create/split/wait commands print json on success - `pane run` is a convenience wrapper for `pane.send_input` with the command text followed by a real `Enter` keypress - `wait agent-status` is a cli convenience built on top of event subscriptions diff --git a/src/api/mod.rs b/src/api/mod.rs index a3bc674f..f125b156 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -941,6 +941,7 @@ impl ActiveAgentStatusChangedSubscription { workspace_id: pane.workspace_id, agent_status: current_status, agent: pane.agent, + custom_status: pane.custom_status, }), }) } diff --git a/src/api/schema.rs b/src/api/schema.rs index d2bf3ff6..bed8415a 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -211,6 +211,8 @@ pub struct PaneReportAgentParams { #[serde(default, skip_serializing_if = "Option::is_none")] pub message: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_status: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] pub seq: Option<u64>, } @@ -546,6 +548,8 @@ pub struct PaneInfo { #[serde(default, skip_serializing_if = "Option::is_none")] pub agent: Option<String>, pub agent_status: AgentStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_status: Option<String>, pub revision: u64, } @@ -612,6 +616,8 @@ pub struct PaneAgentStatusChangedEvent { pub agent_status: AgentStatus, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_status: Option<String>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -676,6 +682,8 @@ pub enum EventData { pane_id: String, workspace_id: String, agent_status: AgentStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + custom_status: Option<String>, }, } @@ -734,6 +742,7 @@ mod tests { agent: "pi".into(), state: PaneAgentState::Working, message: Some("thinking".into()), + custom_status: Some("indexing".into()), seq: Some(42), }), }; @@ -1026,6 +1035,7 @@ mod tests { label: None, agent: None, agent_status: AgentStatus::Unknown, + custom_status: None, revision: 0, }, }, diff --git a/src/app/actions.rs b/src/app/actions.rs index 21e17bf0..870044a8 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -683,10 +683,18 @@ impl AppState { agent_label, state, message, + custom_status, seq, } => self .update_pane_state(pane_id, |pane| { - pane.set_hook_authority(source, agent_label, state, message, seq) + pane.set_hook_authority_with_custom_status( + source, + agent_label, + state, + message, + custom_status, + seq, + ) }) .into_iter() .collect(), @@ -1365,6 +1373,7 @@ mod tests { agent_label: "hermes".into(), state: AgentState::Blocked, message: None, + custom_status: None, seq: None, }); diff --git a/src/app/api.rs b/src/app/api.rs index 1fb11118..8c929e9a 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -3,8 +3,8 @@ use std::time::{Duration, Instant}; use super::{ api_helpers::{ - detect_state_from_api, encode_api_keys, encode_api_text, normalize_reported_agent_label, - pane_agent_status, + detect_state_from_api, encode_api_keys, encode_api_text, normalize_custom_status, + normalize_reported_agent_label, pane_agent_status, }, App, Mode, OverlayPaneState, ToastKind, }; @@ -193,12 +193,19 @@ impl App { .and_then(|ws| ws.pane_state(update.pane_id)) .map(|pane| pane_agent_status(pane.state, pane.seen)) .unwrap_or_else(|| pane_agent_status(update.state, true)); + let custom_status = self + .state + .workspaces + .get(update.ws_idx) + .and_then(|ws| ws.pane_state(update.pane_id)) + .and_then(|pane| pane.effective_custom_status().map(str::to_string)); self.emit_event(crate::api::schema::EventEnvelope { event: crate::api::schema::EventKind::PaneAgentStatusChanged, data: crate::api::schema::EventData::PaneAgentStatusChanged { pane_id, workspace_id, agent_status, + custom_status, }, }); } @@ -1067,6 +1074,7 @@ impl App { agent_label, state: detect_state_from_api(params.state), message: params.message, + custom_status: normalize_custom_status(params.custom_status), seq: params.seq, }); SuccessResponse { diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs index 011e5ccb..56907ece 100644 --- a/src/app/api_helpers.rs +++ b/src/app/api_helpers.rs @@ -90,3 +90,12 @@ pub(super) fn normalize_reported_agent_label(agent: &str) -> Option<String> { } Some(trimmed.to_string()) } + +pub(super) fn normalize_custom_status(status: Option<String>) -> Option<String> { + let trimmed = status?.trim().to_string(); + let mut normalized = String::new(); + for ch in trimmed.chars().filter(|ch| !ch.is_control()).take(32) { + normalized.push(ch); + } + (!normalized.trim().is_empty()).then(|| normalized.trim().to_string()) +} diff --git a/src/app/creation.rs b/src/app/creation.rs index 51dac1e8..a2758d7a 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -248,6 +248,7 @@ impl App { label: pane.manual_label.clone(), agent: pane.effective_agent_label().map(str::to_string), agent_status: pane_agent_status(pane.state, pane.seen), + custom_status: pane.effective_custom_status().map(str::to_string), revision: 0, }) } @@ -6,11 +6,12 @@ use serde::Serialize; use crate::api; use crate::api::schema::{ - AgentStatus, EmptyParams, IntegrationTarget, Method, OutputMatch, PaneListParams, - PaneReadParams, PaneRenameParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, - PaneSplitParams, PaneTarget, PaneWaitForOutputParams, PingParams, ReadFormat, ReadSource, - Request, SplitDirection, Subscription, TabCreateParams, TabListParams, TabRenameParams, - TabTarget, WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceTarget, + AgentStatus, EmptyParams, IntegrationTarget, Method, OutputMatch, PaneAgentState, + PaneListParams, PaneReadParams, PaneRenameParams, PaneReportAgentParams, PaneSendInputParams, + PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneTarget, PaneWaitForOutputParams, + PingParams, ReadFormat, ReadSource, Request, SplitDirection, Subscription, TabCreateParams, + TabListParams, TabRenameParams, TabTarget, WorkspaceCreateParams, WorkspaceRenameParams, + WorkspaceTarget, }; pub enum CommandOutcome { @@ -279,6 +280,7 @@ fn run_pane_command(args: &[String]) -> std::io::Result<i32> { "close" => pane_close(&args[1..]), "send-text" => pane_send_text(&args[1..]), "send-keys" => pane_send_keys(&args[1..]), + "report-agent" => pane_report_agent(&args[1..]), "run" => pane_run(&args[1..]), "help" | "--help" | "-h" => { print_pane_help(); @@ -1009,6 +1011,102 @@ fn pane_run(args: &[String]) -> std::io::Result<i32> { })) } +fn pane_report_agent(args: &[String]) -> std::io::Result<i32> { + let Some(raw_pane_id) = args.first() else { + eprintln!("usage: herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N]"); + return Ok(2); + }; + + let pane_id = normalize_pane_id(raw_pane_id); + let mut source = None; + let mut agent = None; + let mut state = None; + let mut message = None; + let mut custom_status = None; + let mut seq = None; + + let mut index = 1; + while index < args.len() { + match args[index].as_str() { + "--source" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --source"); + return Ok(2); + }; + source = Some(value.clone()); + index += 2; + } + "--agent" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --agent"); + return Ok(2); + }; + agent = Some(value.clone()); + index += 2; + } + "--state" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --state"); + return Ok(2); + }; + state = Some(parse_pane_agent_state(value)?); + index += 2; + } + "--message" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --message"); + return Ok(2); + }; + message = Some(value.clone()); + index += 2; + } + "--custom-status" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --custom-status"); + return Ok(2); + }; + custom_status = Some(value.clone()); + index += 2; + } + "--seq" => { + let Some(value) = args.get(index + 1) else { + eprintln!("missing value for --seq"); + return Ok(2); + }; + seq = Some(parse_u64_flag("--seq", value)?); + index += 2; + } + other => { + eprintln!("unknown option: {other}"); + return Ok(2); + } + } + } + + let Some(source) = source else { + eprintln!("missing required --source"); + return Ok(2); + }; + let Some(agent) = agent else { + eprintln!("missing required --agent"); + return Ok(2); + }; + let Some(state) = state else { + eprintln!("missing required --state"); + return Ok(2); + }; + + send_ok_request(Method::PaneReportAgent(PaneReportAgentParams { + pane_id, + source, + agent, + state, + message, + custom_status, + seq, + })) +} + fn integration_status(args: &[String]) -> std::io::Result<i32> { let outdated_only = match args { [] => false, @@ -1408,6 +1506,18 @@ fn parse_agent_status(value: &str) -> std::io::Result<AgentStatus> { } } +fn parse_pane_agent_state(value: &str) -> std::io::Result<PaneAgentState> { + match value { + "idle" => Ok(PaneAgentState::Idle), + "working" => Ok(PaneAgentState::Working), + "blocked" => Ok(PaneAgentState::Blocked), + "unknown" => Ok(PaneAgentState::Unknown), + _ => Err(std::io::Error::other(format!( + "invalid pane agent state: {value} (expected idle, working, blocked, or unknown)" + ))), + } +} + fn parse_u32_flag(flag: &str, value: &str) -> std::io::Result<u32> { value .parse::<u32>() @@ -1530,6 +1640,7 @@ fn print_pane_help() { eprintln!(" herdr pane close <pane_id>"); eprintln!(" herdr pane send-text <pane_id> <text>"); eprintln!(" herdr pane send-keys <pane_id> <key> [key ...]"); + eprintln!(" herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N]"); eprintln!(" herdr pane run <pane_id> <command>"); } diff --git a/src/events.rs b/src/events.rs index 8d1ff47a..6016fe1b 100644 --- a/src/events.rs +++ b/src/events.rs @@ -25,6 +25,7 @@ pub enum AppEvent { agent_label: String, state: AgentState, message: Option<String>, + custom_status: Option<String>, seq: Option<u64>, }, /// Hook authority was explicitly cleared for a pane. diff --git a/src/pane/state.rs b/src/pane/state.rs index f9b7a149..2a2f789e 100644 --- a/src/pane/state.rs +++ b/src/pane/state.rs @@ -10,6 +10,7 @@ pub struct HookAuthority { pub agent_label: String, pub state: AgentState, pub message: Option<String>, + pub custom_status: Option<String>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -71,6 +72,7 @@ impl PaneState { self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) } + #[cfg(test)] pub fn set_hook_authority( &mut self, source: String, @@ -79,6 +81,18 @@ impl PaneState { message: Option<String>, seq: Option<u64>, ) -> Option<EffectiveStateChange> { + self.set_hook_authority_with_custom_status(source, agent_label, state, message, None, seq) + } + + pub fn set_hook_authority_with_custom_status( + &mut self, + source: String, + agent_label: String, + state: AgentState, + message: Option<String>, + custom_status: Option<String>, + seq: Option<u64>, + ) -> Option<EffectiveStateChange> { if !self.accept_hook_report(&source, seq) { return None; } @@ -91,6 +105,7 @@ impl PaneState { agent_label, state, message, + custom_status, }); self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state) } @@ -186,6 +201,12 @@ impl PaneState { self.detected_agent } + pub fn effective_custom_status(&self) -> Option<&str> { + self.hook_authority + .as_ref() + .and_then(|authority| authority.custom_status.as_deref()) + } + pub fn set_manual_label(&mut self, label: String) { let label = label.trim().to_string(); self.manual_label = (!label.is_empty()).then_some(label); @@ -459,6 +480,27 @@ mod tests { } #[test] + fn custom_status_is_hook_authority_metadata() { + let mut pane = PaneState::new(); + pane.set_detected_state(Some(Agent::Claude), AgentState::Idle); + pane.set_hook_authority_with_custom_status( + "herdr:claude".into(), + "claude".into(), + AgentState::Idle, + None, + Some("scheduled".into()), + None, + ); + + assert_eq!(pane.state, AgentState::Idle); + assert_eq!(pane.effective_custom_status(), Some("scheduled")); + + pane.clear_hook_authority(Some("herdr:claude"), None); + + assert_eq!(pane.effective_custom_status(), None); + } + + #[test] fn stale_hook_report_sequence_is_ignored_for_same_source() { let mut pane = PaneState::new(); pane.set_hook_authority( diff --git a/src/server/headless.rs b/src/server/headless.rs index 29c8de6e..779bb522 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -3069,6 +3069,7 @@ mod tests { agent: "pi".into(), state: api::schema::PaneAgentState::Idle, message: None, + custom_status: None, seq: Some(19), }), }, diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs index 9a09f3e0..9e8c57e9 100644 --- a/src/ui/mobile.rs +++ b/src/ui/mobile.rs @@ -599,6 +599,9 @@ fn mobile_agent_detail(entry: &AgentPanelEntry) -> String { if let Some(agent_label) = entry.agent_label.as_deref() { parts.push(agent_label.to_string()); } + if let Some(custom_status) = entry.custom_status.as_deref() { + parts.push(custom_status.to_string()); + } format!(" {}", parts.join(" · ")) } @@ -909,6 +912,7 @@ mod tests { agent_label: agent_label.map(str::to_string), state: AgentState::Idle, seen: true, + custom_status: None, } } diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index f10c4241..29f3525d 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -24,6 +24,7 @@ pub(crate) struct AgentPanelEntry { pub agent_label: Option<String>, pub state: AgentState, pub seen: bool, + pub custom_status: Option<String>, } fn sidebar_section_heights(total_h: u16, split_ratio: f32) -> (u16, u16) { @@ -126,6 +127,7 @@ pub(crate) fn agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> { agent_label: None, state: detail.state, seen: detail.seen, + custom_status: detail.custom_status, }) .collect() } @@ -147,6 +149,7 @@ pub(crate) fn agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> { agent_label: Some(detail.agent_label), state: detail.state, seen: detail.seen, + custom_status: detail.custom_status, }) }) .collect(), @@ -836,6 +839,10 @@ fn render_agent_detail(app: &AppState, frame: &mut Frame, area: Rect) { status_spans.push(Span::styled(" · ", agent_style)); status_spans.push(Span::styled(agent_label, agent_style)); } + if let Some(custom_status) = &detail.custom_status { + status_spans.push(Span::styled(" · ", agent_style)); + status_spans.push(Span::styled(custom_status.clone(), agent_style)); + } frame.render_widget( Paragraph::new(Line::from(status_spans)).style(row_style), Rect::new(body.x, row_y, body.width, 1), @@ -934,6 +941,7 @@ mod tests { agent_label: Some("claude".into()), state: AgentState::Idle, seen: true, + custom_status: None, }; let label = format_agent_panel_primary_label(&entry, 18); diff --git a/src/workspace/aggregate.rs b/src/workspace/aggregate.rs index bd476667..0507781e 100644 --- a/src/workspace/aggregate.rs +++ b/src/workspace/aggregate.rs @@ -14,6 +14,7 @@ pub struct PaneDetail { pub agent: Option<Agent>, pub state: AgentState, pub seen: bool, + pub custom_status: Option<String>, } impl Tab { @@ -39,6 +40,7 @@ impl Tab { agent: pane.effective_known_agent(), state: pane.state, seen: pane.seen, + custom_status: pane.effective_custom_status().map(str::to_string), }) }) .collect() |
