aboutsummaryrefslogtreecommitdiff
path: root/crates/dinggerd
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-11-05 19:21:43 +0200
committerIgor Soarez <igor@soarez.org>2026-11-05 19:21:43 +0200
commit5842b093cc502b46076423e4f8efdbbc568f86aa (patch)
tree35b5a2ab971a2b9abb3188e56e0416033b4f13ea /crates/dinggerd
parent3b7b7ed091bae3b0609c38b2d445201cc4b7a026 (diff)
pi: a failed run says so: a 'failed' alert with the error, and the error in red until seen
Diffstat (limited to 'crates/dinggerd')
-rw-r--r--crates/dinggerd/assets/pi/pi-agent-state.ts68
-rw-r--r--crates/dinggerd/src/hooks.rs44
-rw-r--r--crates/dinggerd/src/main.rs8
-rw-r--r--crates/dinggerd/src/notify.rs7
-rw-r--r--crates/dinggerd/src/tmux.rs8
-rw-r--r--crates/dinggerd/src/watcher.rs41
6 files changed, 149 insertions, 27 deletions
diff --git a/crates/dinggerd/assets/pi/pi-agent-state.ts b/crates/dinggerd/assets/pi/pi-agent-state.ts
index 62f29db..ceac45b 100644
--- a/crates/dinggerd/assets/pi/pi-agent-state.ts
+++ b/crates/dinggerd/assets/pi/pi-agent-state.ts
@@ -167,7 +167,34 @@ function requestId(): string {
return `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
}
-async function report(state: AgentState | undefined, message?: string, aborted = false): Promise<void> {
+/** How a run ended, sent with the idle report that ends it. */
+type RunEnd = { aborted?: boolean; error?: string };
+
+/**
+ * A provider error as one short readable line: the message inside a JSON
+ * body ("401: invalid x-api-key"), else the first line.
+ */
+export function readableError(text: string): string {
+ const first = text.split("\n").find((line) => line.trim()) ?? "";
+ let line = first;
+ const json = first.indexOf("{");
+ if (json >= 0) {
+ try {
+ const body = JSON.parse(first.slice(json));
+ const message = body?.error?.message ?? body?.message ?? body?.detail ?? body?.error;
+ if (typeof message === "string" && message.trim()) {
+ const status = first.slice(0, json).replace(/[\s:]+$/, "");
+ line = status ? `${status}: ${message}` : message;
+ }
+ } catch {
+ // Not JSON after all.
+ }
+ }
+ line = line.replace(/\s+/g, " ").trim();
+ return line.length > 100 ? `${line.slice(0, 99)}…` : line || "error";
+}
+
+async function report(state: AgentState | undefined, message?: string, end: RunEnd = {}): Promise<void> {
await sendRequest({
id: requestId(),
method: "pane.report_agent",
@@ -177,8 +204,9 @@ async function report(state: AgentState | undefined, message?: string, aborted =
agent: "pi",
...(state ? { state } : {}),
message,
- // The run this idle report ends was cancelled: not done.
- ...(aborted ? { aborted: true } : {}),
+ // The run this idle report ends was cancelled (not done), or failed.
+ ...(end.aborted ? { aborted: true } : {}),
+ ...(end.error ? { error: end.error } : {}),
importance,
extension: extensionVersion,
...(controlListening ? { control: controlPath } : {}),
@@ -191,10 +219,10 @@ async function report(state: AgentState | undefined, message?: string, aborted =
}
let sendInFlight = false;
-let queuedState: { state: AgentState; message?: string; aborted: boolean } | undefined;
+let queuedState: { state: AgentState; message?: string; end: RunEnd } | undefined;
-function queueState(state: AgentState, message?: string, aborted = false): void {
- queuedState = { state, message, aborted };
+function queueState(state: AgentState, message?: string, end: RunEnd = {}): void {
+ queuedState = { state, message, end };
if (!sendInFlight) {
void drainStateQueue();
}
@@ -210,7 +238,7 @@ async function drainStateQueue(): Promise<void> {
while (queuedState) {
const next = queuedState;
queuedState = undefined;
- await report(next.state, next.message, next.aborted);
+ await report(next.state, next.message, next.end);
}
} finally {
sendInFlight = false;
@@ -241,16 +269,30 @@ export default function (pi) {
return { state: "idle" as const, message: undefined };
}
- function publishState(force = false, aborted = false) {
+ function publishState(force = false, end: RunEnd = {}) {
const next = desiredState();
if (!force && next.state === lastState && next.message === lastMessage) {
return;
}
lastState = next.state;
lastMessage = next.message;
- queueState(next.state, next.message, aborted && next.state === "idle");
+ queueState(next.state, next.message, next.state === "idle" ? end : {});
}
+ // How the current run is ending: pi says before it settles (not for a
+ // cancelled run), and the last reply carries the error.
+ let runOutcome: string | undefined;
+ let replyError: string | undefined;
+ pi.on("agent_before_settle", (event) => {
+ runOutcome = event?.outcome;
+ });
+ pi.on("message_end", (event) => {
+ const reply = event?.message;
+ if (reply?.role === "assistant") {
+ replyError = reply.stopReason === "error" ? (reply.errorMessage ?? "") : undefined;
+ }
+ });
+
// A dialog that opens during a run (an extension asking before it goes
// on): the agent waits on you, so it is blocked until the dialog closes.
// One opened while idle comes from what you just did, and changes nothing.
@@ -529,6 +571,8 @@ export default function (pi) {
pi.on("agent_start", (_event, ctx) => {
latestCtx = ctx;
agentActive = true;
+ runOutcome = undefined;
+ replyError = undefined;
updateSessionFile(ctx);
publishState();
// The prompt left the input box.
@@ -553,8 +597,10 @@ export default function (pi) {
agentActive = false;
// The last reply's usage: message_end comes before pi counts it.
checkContext(ctx);
- // A cancelled run (Escape) is idle, not done.
- publishState(false, event?.aborted === true);
+ // A cancelled run (Escape) is idle, not done; a failed one says why.
+ const aborted = event?.aborted === true;
+ const error = !aborted && runOutcome === "error" ? readableError(replyError ?? "") : undefined;
+ publishState(false, { aborted, error });
// Escape puts queued messages back into the input box.
checkDraftSoon();
});
diff --git a/crates/dinggerd/src/hooks.rs b/crates/dinggerd/src/hooks.rs
index 709f62b..aeb3285 100644
--- a/crates/dinggerd/src/hooks.rs
+++ b/crates/dinggerd/src/hooks.rs
@@ -29,6 +29,8 @@
//!
//! An idle report may say the run was `aborted` (cancelled, as with
//! Escape): the agent is idle, not done, and nobody is told it finished.
+//! Or that it failed, with the `error` as one readable line: the agent is
+//! done, and the alert says it failed and why.
//!
//! It also carries the `draft` in the agent's input box ("" for none), for
//! marking a draft you left behind; never logged. And `context`: how full
@@ -81,6 +83,8 @@ struct PaneReports {
draft: Option<String>,
/// How full its context window is: whole percent ("" when unknown).
context: Option<String>,
+ /// The error that ended its last failed run, as one line.
+ error: Option<String>,
/// When the last report arrived.
at: Option<Instant>,
}
@@ -183,6 +187,8 @@ struct HookParams {
context: Option<String>,
#[serde(default)]
aborted: Option<bool>,
+ #[serde(default)]
+ error: Option<String>,
}
fn handle_stream(stream: UnixStream) {
@@ -252,6 +258,7 @@ fn handle_stream(stream: UnixStream) {
draft: params.draft,
context: params.context,
aborted: params.aborted == Some(true),
+ error: params.error,
};
record(params.pane_id, state, reported);
wake();
@@ -269,6 +276,8 @@ struct Reported {
context: Option<String>,
/// The run this report ends was cancelled.
aborted: bool,
+ /// The error the run this report ends failed with.
+ error: Option<String>,
}
/// A reported state, and whether it ends a run the user cancelled.
@@ -277,11 +286,13 @@ pub struct Observation {
pub state: AgentState,
/// Idle after a cancelled run: not done, and no "finished" alert.
pub cancelled: bool,
+ /// Idle after a failed run: done, with a "failed" alert (see [`error`]).
+ pub failed: bool,
}
impl Observation {
pub fn plain(state: AgentState) -> Self {
- Observation { state, cancelled: false }
+ Observation { state, cancelled: false, failed: false }
}
}
@@ -311,7 +322,12 @@ fn record(pane_id: String, state: Option<AgentState>, reported: Reported) {
pane.at = Some(Instant::now());
if let Some(state) = state {
pane.last = Some(state);
- pane.pending.push(Observation { state, cancelled: reported.aborted && state == AgentState::Idle });
+ let idle = state == AgentState::Idle;
+ let error = reported.error.as_deref().map(draft_line).filter(|error| idle && !error.is_empty());
+ pane.pending.push(Observation { state, cancelled: reported.aborted && idle, failed: error.is_some() });
+ if error.is_some() {
+ pane.error = error;
+ }
}
if let Some(session) = option_value(reported.session) {
pane.session = Some(session);
@@ -342,6 +358,11 @@ fn context_percent(value: &str) -> String {
}
}
+/// The error the pane's agent's last failed run ended with.
+pub fn error(pane_id: &str) -> Option<String> {
+ reports().get(pane_id).and_then(|pane| pane.error.clone())
+}
+
/// How full the pane's agent's context window is ("" when unknown), if its
/// extension reports it.
pub fn context(pane_id: &str) -> Option<String> {
@@ -497,7 +518,7 @@ mod tests {
take_observations("%t7"),
Some(vec![
Observation::plain(AgentState::Working),
- Observation { state: AgentState::Idle, cancelled: true },
+ Observation { state: AgentState::Idle, cancelled: true, failed: false },
]),
"only an idle report ends a run"
);
@@ -507,6 +528,23 @@ mod tests {
}
#[test]
+ fn a_failed_run_ends_in_a_failed_idle_observation_with_its_error() {
+ let failed = |error: &str| Reported { error: Some(error.into()), ..Default::default() };
+ record("%t8".into(), Some(AgentState::Working), failed("not the end of a run"));
+ record("%t8".into(), Some(AgentState::Idle), failed("401: invalid\nx-api-key"));
+ assert_eq!(
+ take_observations("%t8"),
+ Some(vec![
+ Observation::plain(AgentState::Working),
+ Observation { state: AgentState::Idle, cancelled: false, failed: true },
+ ])
+ );
+ assert_eq!(super::error("%t8").as_deref(), Some("401: invalid x-api-key"), "one line");
+ record("%t8".into(), Some(AgentState::Idle), failed(""));
+ assert_eq!(take_observations("%t8"), Some(vec![Observation::plain(AgentState::Idle)]), "no error, no failure");
+ }
+
+ #[test]
fn context_use_is_kept_as_whole_percent_until_reported_again() {
let context = |text: &str| Reported { context: Some(text.into()), ..Default::default() };
assert_eq!(super::context("%t6"), None, "an extension that does not report it");
diff --git a/crates/dinggerd/src/main.rs b/crates/dinggerd/src/main.rs
index 935da7d..0ace3a9 100644
--- a/crates/dinggerd/src/main.rs
+++ b/crates/dinggerd/src/main.rs
@@ -480,7 +480,13 @@ fn status() -> Result<(), String> {
};
let cost = if pane.written_cost.is_empty() { String::new() } else { format!("${}", pane.written_cost) };
let context = if pane.written_context.is_empty() { String::new() } else { format!("{}%", pane.written_context) };
- println!("{state:<8} {:<width$} {agent:<7} {cost:>8} {context:>4} {draft}{}", place(pane), name.trim());
+ // A failed run: done, and why.
+ let (state, why) = if pane.written_error.is_empty() || state != "done" {
+ (state, String::new())
+ } else {
+ ("failed", format!(" · {}", pane.written_error))
+ };
+ println!("{state:<8} {:<width$} {agent:<7} {cost:>8} {context:>4} {draft}{}{why}", place(pane), name.trim());
}
let global = |name: &str| tmux::show_global(name).unwrap_or_default();
let spent = [("today", "@dingger_cost_today"), ("this week", "@dingger_cost_week"), ("this month", "@dingger_cost_month")]
diff --git a/crates/dinggerd/src/notify.rs b/crates/dinggerd/src/notify.rs
index 11b37b0..ef2c721 100644
--- a/crates/dinggerd/src/notify.rs
+++ b/crates/dinggerd/src/notify.rs
@@ -8,6 +8,8 @@ use tracing::{info, warn};
pub enum NotificationKind {
NeedsAttention,
Finished,
+ /// A run that ended in an error: done, but you have to act.
+ Failed,
}
impl NotificationKind {
@@ -15,6 +17,7 @@ impl NotificationKind {
match self {
Self::NeedsAttention => "needs attention",
Self::Finished => "finished",
+ Self::Failed => "failed",
}
}
@@ -22,7 +25,7 @@ impl NotificationKind {
/// for completions.
fn sound_bytes(&self) -> &'static [u8] {
match self {
- Self::NeedsAttention => include_bytes!("../assets/request.mp3"),
+ Self::NeedsAttention | Self::Failed => include_bytes!("../assets/request.mp3"),
Self::Finished => include_bytes!("../assets/done.mp3"),
}
}
@@ -34,7 +37,7 @@ pub fn play_sound(kind: &NotificationKind) {
// Test seam: play through a command instead (tests/e2e.sh).
if let Ok(command) = std::env::var("DINGGER_SOUND") {
let name = match kind {
- NotificationKind::NeedsAttention => "request",
+ NotificationKind::NeedsAttention | NotificationKind::Failed => "request",
NotificationKind::Finished => "done",
};
let _ = Command::new("sh").args(["-c", &command, name]).stdin(Stdio::null()).spawn();
diff --git a/crates/dinggerd/src/tmux.rs b/crates/dinggerd/src/tmux.rs
index 80adbb5..7845381 100644
--- a/crates/dinggerd/src/tmux.rs
+++ b/crates/dinggerd/src/tmux.rs
@@ -38,6 +38,8 @@ pub struct Pane {
pub written_cost: String,
/// How full the agent's context window is, in whole percent.
pub written_context: String,
+ /// Why its last run failed, while it is done and unseen.
+ pub written_error: String,
}
@@ -124,14 +126,15 @@ pub fn list_panes() -> Result<Vec<Pane>, String> {
"#{@dingger_control}\t",
"#{@dingger_draft}\t",
"#{@dingger_cost}\t",
- "#{@dingger_context}",
+ "#{@dingger_context}\t",
+ "#{@dingger_error}",
);
let out = run_tmux(&["list-panes", "-a", "-F", format])?;
let mut panes: Vec<Pane> = Vec::new();
let mut process_tree = None;
for line in out.lines() {
let fields: Vec<&str> = line.split('\t').collect();
- if fields.len() < 25 {
+ if fields.len() < 26 {
continue;
}
let attended = fields[4] == "1" && fields[5] != "0";
@@ -179,6 +182,7 @@ pub fn list_panes() -> Result<Vec<Pane>, String> {
written_draft: fields[22].to_string(),
written_cost: fields[23].to_string(),
written_context: fields[24].to_string(),
+ written_error: fields[25].to_string(),
});
}
Ok(panes)
diff --git a/crates/dinggerd/src/watcher.rs b/crates/dinggerd/src/watcher.rs
index aee8d5e..9bad1fe 100644
--- a/crates/dinggerd/src/watcher.rs
+++ b/crates/dinggerd/src/watcher.rs
@@ -29,6 +29,7 @@
//! - `@dingger_draft` the draft in the agent's input box, as one line
//! - `@dingger_cost` what the agent's session spent today (see `cost`)
//! - `@dingger_context` how full its context window is, in whole percent
+//! - `@dingger_error` why its run failed, while it is done (unseen)
use std::collections::HashMap;
@@ -375,7 +376,15 @@ impl Watcher {
// Apply every observation in order: a turn that started and
// finished between two ticks is still a completion.
let mut alerts = Vec::new();
+ // Why its last run failed: kept while it is done.
+ let mut error = pane.written_error.clone();
for observed in observations {
+ if observed.state != AgentState::Idle {
+ error.clear();
+ }
+ if observed.failed {
+ error = hooks::error(&pane.pane_id).unwrap_or_else(|| "error".into());
+ }
let mut next = advance(track, observed.state, pane.attended, acked, now);
// A cancelled run did not finish: idle, as if seen.
if observed.cancelled && next.state == AgentState::Idle {
@@ -392,7 +401,12 @@ impl Watcher {
// Notify on transitions observed by this process, not
// when resuming from options written before a restart.
if !restored && !observed.cancelled {
- alerts.extend(notification_kind_for_transition(track.state, next.state));
+ alerts.extend(notification_kind_for_transition(track.state, next.state).map(|kind| {
+ match kind {
+ NotificationKind::Finished if observed.failed => NotificationKind::Failed,
+ kind => kind,
+ }
+ }));
}
}
track = next;
@@ -408,14 +422,18 @@ impl Watcher {
track = next;
}
self.tracks.insert(pane.pane_id.clone(), track);
+ // Seen, a new run, or gone: the failure is no longer news.
+ if track.status() != Status::Done {
+ error.clear();
+ }
// Update what the user sees first, then alert.
if acked {
let _ = tmux::unset_pane_option(&pane.pane_id, "@dingger_ack");
}
- self.write_pane_options(pane, track);
+ self.write_pane_options(pane, track, &error);
for kind in alerts {
- self.notify(pane, kind);
+ self.notify(pane, kind, &error);
}
}
@@ -465,7 +483,7 @@ impl Watcher {
/// Write the pane's options that changed, in one tmux command list:
/// tmux applies it as a unit, so a daemon killed mid-write never leaves
/// a display state that disagrees with the hook state it came from.
- fn write_pane_options(&self, pane: &tmux::Pane, track: PaneTrack) {
+ fn write_pane_options(&self, pane: &tmux::Pane, track: PaneTrack, error: &str) {
let mut changes: Vec<(&str, Option<String>)> = Vec::new();
let mut change = |name: &'static str, written: &str, value: String| {
if written != value {
@@ -483,6 +501,7 @@ impl Watcher {
}
change("@agent_state", &pane.written_state, track.status().label().to_string());
change("@dingger_since", &pane.written_since, track.since.map(|s| s.to_string()).unwrap_or_default());
+ change("@dingger_error", &pane.written_error, error.to_string());
change("@agent", &pane.written_agent, pane.agent.map(detect_core::agent_label).unwrap_or("").to_string());
let name = crate::names::display_name(pane.agent, &pane.pane_title, &pane.cwd, host_name())
.unwrap_or_default();
@@ -544,16 +563,22 @@ impl Watcher {
}
}
- fn notify(&self, pane: &tmux::Pane, kind: NotificationKind) {
+ fn notify(&self, pane: &tmux::Pane, kind: NotificationKind, error: &str) {
// herdr: a blocked pane plays the alert even when the user is looking
- // at it; a finished pane stays silent when they already are.
+ // at it; a finished (or failed) pane stays silent when they already
+ // are.
let watching = pane.attended;
- if matches!(kind, NotificationKind::Finished) && watching {
+ if matches!(kind, NotificationKind::Finished | NotificationKind::Failed) && watching {
return;
}
let agent = pane.agent.map(detect_core::agent_label).unwrap_or("agent");
if self.notifications && !watching {
- send_notification(agent, &kind, &format!("{} ({agent})", pane.display_target));
+ let mut context = format!("{} ({agent})", pane.display_target);
+ if matches!(kind, NotificationKind::Failed) && !error.is_empty() {
+ context.push_str(": ");
+ context.push_str(error);
+ }
+ send_notification(agent, &kind, &context);
}
// A low agent makes no sound; it still notifies and waits in line.
if self.sounds && importance_of(pane) != Importance::Low {