aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-11-05 13:12:08 +0200
committerIgor Soarez <igor@soarez.org>2026-11-05 13:12:08 +0200
commit3b7b7ed091bae3b0609c38b2d445201cc4b7a026 (patch)
tree1d098a7401e08ddb66696d749d0717a2c5bf4102
parent9518a5442576268a04e7dbfcddeb2457add2f189 (diff)
pi: a dialog during a run is blocked, and a cancelled run is idle, not done
-rw-r--r--README.md9
-rw-r--r--ROADMAP.md22
-rw-r--r--crates/dinggerd/assets/pi/pi-agent-state.ts47
-rw-r--r--crates/dinggerd/src/hooks.rs48
-rw-r--r--crates/dinggerd/src/watcher.rs21
-rwxr-xr-xtests/e2e.sh29
-rwxr-xr-xtests/pi-live.sh31
7 files changed, 170 insertions, 37 deletions
diff --git a/README.md b/README.md
index acc64dc..e383d8f 100644
--- a/README.md
+++ b/README.md
@@ -164,6 +164,11 @@ than a poll is still a completion.
extension that reports working/blocked/idle over
`~/.local/state/dingger/agent.sock`. Running pi agents load it when
reloaded (see "Keeping pi agents on the current extension" below).
+ Blocked covers a dialog that opens during a run (an extension asking
+ before it goes on), until you answer it; one you open while the agent
+ is idle changes nothing. A run you cancel (Escape) ends idle, not done,
+ with no "finished" notification, even when it settles after you look
+ away.
`dingger install claude` adds a SessionStart hook to
`~/.claude/settings.json` (keeping everything else; the original is
backed up to `settings.json.bak-dingger`) that reports only Claude's
@@ -279,7 +284,9 @@ touches your tmux server.
daemon of its own, with a fake local model (`tests/fake-model.py`) and its
own pi agent directory: no network, no credentials, your pi untouched. It
covers identification, the extension's state and session reports, a turn,
-`/reload`, an extension's blocked event, screen detection without the
+`/reload`, an extension's blocked event, dialogs (blocked during a run,
+nothing while idle), a cancelled run (idle, not done), screen detection
+without the
extension, restore, reloading an agent on an old extension (and refusing
while busy or with a draft), drafts (reported, cleared, kept across a
crash and put back by restore), cost (priced replies, counted once across
diff --git a/ROADMAP.md b/ROADMAP.md
index a8bd1be..c47d994 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -14,8 +14,10 @@ In priority order.
(with the error's first line) and `idle`, with the session name. tmux
drops these, but `tmux pipe-pane -O` sees them when pi runs with
`PI_PROGRAM_STATUS=1`. A first try showed every report, including a
- confirm dialog's `state=blocked:kind=permission:msg=<base64 title>`. It
- would fix item 4 and could replace the extension's state reports. It is
+ confirm dialog's `state=blocked:kind=permission:msg=<base64 title>`.
+ Items 4 and 5 no longer need it (pi 1.4's extension events cover
+ both); it could still replace the extension's state reports, and
+ carries errors for item 6. It is
not specific to pi, so any agent that adopts the protocol would work
too. Open questions:
- A pipe for each agent pane is one process each, and tmux allows only
@@ -23,15 +25,13 @@ In priority order.
- A pipe started after pi misses the reports sent before it.
- How to set `PI_PROGRAM_STATUS=1` for pi: the tmux global environment,
or the shell profile.
-4. **Blocked on a pi dialog.** While a pi confirm or select dialog waits
- for you, dingger shows the agent as done, not blocked: pi tells
- extensions nothing about its dialogs. Item 3 fixes this. Without it,
- screen detection could match the dialog's key hint line ("enter select
- · escape cancel").
-5. **Cancelled runs are not done.** Since pi 1.1, `agent_settled` says
- whether the run was `aborted`. A run you cancel and then leave shows
- as done, and dingger notifies you. The extension could report idle
- for it, as pi's own reports do.
+4. ~~**Blocked on a pi dialog.**~~ Done (see the README): pi 1.4 tells
+ extensions when a dialog opens and closes (`ui_prompt_start`/`_end`).
+ A dialog during a run is blocked; one opened while idle is not. Next,
+ with item 9: the dialog's title in the notification and panel.
+5. ~~**Cancelled runs are not done.**~~ Done (see the README): the
+ extension reports `aborted` with the idle that ends a cancelled run,
+ and the daemon keeps it idle, with no "finished" alert.
6. **Failed runs.** pi 1.1 tells a run that ended with an error from one
that finished, and gives the error's first line. dingger shows both as
done. Design question: herdr has no such status; should a failure get
diff --git a/crates/dinggerd/assets/pi/pi-agent-state.ts b/crates/dinggerd/assets/pi/pi-agent-state.ts
index fc70638..62f29db 100644
--- a/crates/dinggerd/assets/pi/pi-agent-state.ts
+++ b/crates/dinggerd/assets/pi/pi-agent-state.ts
@@ -167,7 +167,7 @@ function requestId(): string {
return `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
}
-async function report(state: AgentState | undefined, message?: string): Promise<void> {
+async function report(state: AgentState | undefined, message?: string, aborted = false): Promise<void> {
await sendRequest({
id: requestId(),
method: "pane.report_agent",
@@ -177,6 +177,8 @@ async function report(state: AgentState | undefined, message?: string): Promise<
agent: "pi",
...(state ? { state } : {}),
message,
+ // The run this idle report ends was cancelled: not done.
+ ...(aborted ? { aborted: true } : {}),
importance,
extension: extensionVersion,
...(controlListening ? { control: controlPath } : {}),
@@ -189,10 +191,10 @@ async function report(state: AgentState | undefined, message?: string): Promise<
}
let sendInFlight = false;
-let queuedState: { state: AgentState; message?: string } | undefined;
+let queuedState: { state: AgentState; message?: string; aborted: boolean } | undefined;
-function queueState(state: AgentState, message?: string): void {
- queuedState = { state, message };
+function queueState(state: AgentState, message?: string, aborted = false): void {
+ queuedState = { state, message, aborted };
if (!sendInFlight) {
void drainStateQueue();
}
@@ -208,7 +210,7 @@ async function drainStateQueue(): Promise<void> {
while (queuedState) {
const next = queuedState;
queuedState = undefined;
- await report(next.state, next.message);
+ await report(next.state, next.message, next.aborted);
}
} finally {
sendInFlight = false;
@@ -239,16 +241,42 @@ export default function (pi) {
return { state: "idle" as const, message: undefined };
}
- function publishState(force = false) {
+ function publishState(force = false, aborted = false) {
const next = desiredState();
if (!force && next.state === lastState && next.message === lastMessage) {
return;
}
lastState = next.state;
lastMessage = next.message;
- queueState(next.state, next.message);
+ queueState(next.state, next.message, aborted && next.state === "idle");
}
+ // 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.
+ let dialogsBlocking = 0;
+ pi.on("ui_prompt_start", (event, ctx) => {
+ latestCtx = ctx;
+ if (!agentActive) {
+ return;
+ }
+ dialogsBlocking += 1;
+ blockedCount += 1;
+ blockedMessage = event?.title;
+ publishState();
+ });
+ pi.on("ui_prompt_end", () => {
+ if (dialogsBlocking === 0) {
+ return;
+ }
+ dialogsBlocking -= 1;
+ blockedCount = Math.max(0, blockedCount - 1);
+ if (blockedCount === 0) {
+ blockedMessage = undefined;
+ }
+ publishState();
+ });
+
pi.events.on("blocked", (data) => {
if (!data?.active) {
blockedCount = Math.max(0, blockedCount - 1);
@@ -516,7 +544,7 @@ export default function (pi) {
});
}
- pi.on("agent_settled", (_event, ctx) => {
+ pi.on("agent_settled", (event, ctx) => {
latestCtx = ctx;
if (ctx?.isIdle?.() !== true) {
return;
@@ -525,7 +553,8 @@ export default function (pi) {
agentActive = false;
// The last reply's usage: message_end comes before pi counts it.
checkContext(ctx);
- publishState();
+ // A cancelled run (Escape) is idle, not done.
+ publishState(false, event?.aborted === true);
// 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 82fa7bd..709f62b 100644
--- a/crates/dinggerd/src/hooks.rs
+++ b/crates/dinggerd/src/hooks.rs
@@ -27,6 +27,9 @@
//! `{"method":"agents.reload_outdated"}`, and the watcher reloads each agent
//! on an old extension once it is idle and off screen.
//!
+//! 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.
+//!
//! 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
//! the agent's context window is, in percent, as pi's footer shows it (""
@@ -66,7 +69,7 @@ struct PaneReports {
/// Last reported state: authoritative until the next report.
last: Option<AgentState>,
/// Reports not yet applied by the watcher, oldest first.
- pending: Vec<AgentState>,
+ pending: Vec<Observation>,
/// The agent's latest session reference.
session: Option<String>,
/// The agent's importance, if reported.
@@ -178,6 +181,8 @@ struct HookParams {
draft: Option<String>,
#[serde(default)]
context: Option<String>,
+ #[serde(default)]
+ aborted: Option<bool>,
}
fn handle_stream(stream: UnixStream) {
@@ -246,6 +251,7 @@ fn handle_stream(stream: UnixStream) {
control: params.control,
draft: params.draft,
context: params.context,
+ aborted: params.aborted == Some(true),
};
record(params.pane_id, state, reported);
wake();
@@ -261,6 +267,22 @@ struct Reported {
control: Option<String>,
draft: Option<String>,
context: Option<String>,
+ /// The run this report ends was cancelled.
+ aborted: bool,
+}
+
+/// A reported state, and whether it ends a run the user cancelled.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Observation {
+ pub state: AgentState,
+ /// Idle after a cancelled run: not done, and no "finished" alert.
+ pub cancelled: bool,
+}
+
+impl Observation {
+ pub fn plain(state: AgentState) -> Self {
+ Observation { state, cancelled: false }
+ }
}
/// The longest draft kept: enough to find an agent by it.
@@ -289,7 +311,7 @@ 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(state);
+ pane.pending.push(Observation { state, cancelled: reported.aborted && state == AgentState::Idle });
}
if let Some(session) = option_value(reported.session) {
pane.session = Some(session);
@@ -352,11 +374,11 @@ pub fn spawn_listener() -> Result<(), String> {
/// The pane's hook observations for this tick: every report since the last
/// tick, in order; otherwise the last reported state; `None` if the pane
/// never reported to this process.
-pub fn take_observations(pane_id: &str) -> Option<Vec<AgentState>> {
+pub fn take_observations(pane_id: &str) -> Option<Vec<Observation>> {
let mut reports = reports();
let pane = reports.get_mut(pane_id)?;
if pane.pending.is_empty() {
- return pane.last.map(|state| vec![state]);
+ return pane.last.map(|state| vec![Observation::plain(state)]);
}
Some(std::mem::take(&mut pane.pending))
}
@@ -467,6 +489,24 @@ mod tests {
}
#[test]
+ fn a_cancelled_run_ends_in_a_cancelled_idle_observation() {
+ let aborted = || Reported { aborted: true, ..Default::default() };
+ record("%t7".into(), Some(AgentState::Working), aborted());
+ record("%t7".into(), Some(AgentState::Idle), aborted());
+ assert_eq!(
+ take_observations("%t7"),
+ Some(vec![
+ Observation::plain(AgentState::Working),
+ Observation { state: AgentState::Idle, cancelled: true },
+ ]),
+ "only an idle report ends a run"
+ );
+ assert_eq!(take_observations("%t7"), Some(vec![Observation::plain(AgentState::Idle)]), "seen once");
+ record("%t7".into(), Some(AgentState::Idle), Reported::default());
+ assert_eq!(take_observations("%t7"), Some(vec![Observation::plain(AgentState::Idle)]));
+ }
+
+ #[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/watcher.rs b/crates/dinggerd/src/watcher.rs
index 77b9259..aee8d5e 100644
--- a/crates/dinggerd/src/watcher.rs
+++ b/crates/dinggerd/src/watcher.rs
@@ -35,7 +35,7 @@ use std::collections::HashMap;
use detect_core::{detect_agent_with_osc, Agent, AgentState};
use tracing::{debug, info, warn};
-use crate::hooks;
+use crate::hooks::{self, Observation};
use crate::importance::Importance;
use crate::notify::{play_sound, send_notification, NotificationKind};
use crate::tmux;
@@ -376,7 +376,12 @@ impl Watcher {
// finished between two ticks is still a completion.
let mut alerts = Vec::new();
for observed in observations {
- let next = advance(track, observed, pane.attended, acked, now);
+ 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 {
+ next.seen = true;
+ next.held = false;
+ }
if next.state != track.state {
info!(
target = %pane.display_target,
@@ -386,7 +391,7 @@ impl Watcher {
);
// Notify on transitions observed by this process, not
// when resuming from options written before a restart.
- if !restored {
+ if !restored && !observed.cancelled {
alerts.extend(notification_kind_for_transition(track.state, next.state));
}
}
@@ -615,14 +620,14 @@ impl Watcher {
/// The pane's raw agent states this tick, in order, or `None` when the
/// screen is a viewer (transcript, picker) that says nothing about state.
-fn observe(pane: &tmux::Pane, listed: std::time::Instant) -> Option<Vec<AgentState>> {
+fn observe(pane: &tmux::Pane, listed: std::time::Instant) -> Option<Vec<Observation>> {
if pane.dead {
- return Some(vec![AgentState::Idle]);
+ return Some(vec![Observation::plain(AgentState::Idle)]);
}
let Some(agent) = pane.agent else {
// The agent is gone; its last hook report must not outlive it.
hooks::drop_reports(&pane.pane_id, listed);
- return Some(vec![AgentState::Unknown]);
+ return Some(vec![Observation::plain(AgentState::Unknown)]);
};
// Agent-reported state is authoritative; it survives restarts through
// the `@dingger_hook` option until the extension reports again.
@@ -630,7 +635,7 @@ fn observe(pane: &tmux::Pane, listed: std::time::Instant) -> Option<Vec<AgentSta
return Some(states);
}
if let Some(state) = parse_state(&pane.written_hook) {
- return Some(vec![state]);
+ return Some(vec![Observation::plain(state)]);
}
let screen = match tmux::capture_pane(&pane.pane_id) {
Ok(screen) => screen,
@@ -642,7 +647,7 @@ fn observe(pane: &tmux::Pane, listed: std::time::Instant) -> Option<Vec<AgentSta
// tmux pane_title carries the last OSC title the application set; tmux
// does not re-expose OSC progress sequences.
let detection = detect_agent_with_osc(Some(agent), &screen, &pane.pane_title, "");
- (!detection.skip_state_update).then(|| vec![detection.state])
+ (!detection.skip_state_update).then(|| vec![Observation::plain(detection.state)])
}
/// The host name tmux uses as a pane's default title.
diff --git a/tests/e2e.sh b/tests/e2e.sh
index b1709bc..52367d2 100755
--- a/tests/e2e.sh
+++ b/tests/e2e.sh
@@ -1178,6 +1178,35 @@ expect "the agent exits" "$DINGGER poke; opt $X1 @agent" "" 80
expect "its context goes with it" "opt $X1 @dingger_context" "" 50
t kill-session -t ctx
+# --- 27. a cancelled run is not done ------------------------------------------
+echo "27. cancelled runs"
+abort_report() { # pane
+ python3 - "$HOOK" "$1" <<'PY'
+import json, socket, sys
+params = {"pane_id": sys.argv[2], "agent": "pi", "state": "idle", "aborted": True}
+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 can -n c1 -c "$DIR" "$DIR/bin/pi"
+t new-window -d -t can: -n c2 -c "$DIR" "$DIR/bin/pi"
+K1=$(t display -p -t can:c1 '#{pane_id}'); K2=$(t display -p -t can:c2 '#{pane_id}')
+report "$K1" working ""; report "$K2" working ""
+expect "both working" "opt $K1 @agent_state; opt $K2 @agent_state" "working
+working" 50
+sleep 3.5; : >"$SOUNDS"; : >"$NOTES"
+# Nobody watches either: one finishes, the other is cancelled.
+report "$K1" idle ""; abort_report "$K2"
+expect "finished: done" "opt $K1 @agent_state" done 50
+expect "cancelled: idle" "opt $K2 @agent_state" idle 50
+expect "the finished one notifies" "grep -c 'pi finished|can:c1 (pi)' $NOTES" 1 60
+expect "the cancelled one does not" "grep -c 'can:c2' $NOTES" 0
+expect "one sound, the finished one's" "cat $SOUNDS" done
+expect "the menu lists it idle" "mb --print | grep -F 'can · c2' | cut -f1" "○ idle"
+t kill-session -t can
+
# 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 8e01e74..53115ec 100755
--- a/tests/pi-live.sh
+++ b/tests/pi-live.sh
@@ -63,6 +63,10 @@ export default function (pi) {
pi.registerCommand("block", { description: "test: emit blocked", handler: async () => { pi.events.emit("blocked", { active: true, label: "test block" }); } });
pi.registerCommand("unblock", { description: "test: clear blocked", handler: async () => { pi.events.emit("blocked", { active: false }); } });
pi.registerCommand("ask", { description: "test: a confirm dialog", handler: async (_args, ctx) => { await ctx.ui.confirm("Test dialog", "Proceed?"); } });
+ // A dialog during a run, the way an extension asks before it goes on.
+ let askDuring = false;
+ pi.on("input", (event) => { askDuring = /ask during the run/.test(event.text); });
+ pi.on("turn_start", async (_event, ctx) => { if (askDuring) { askDuring = false; await ctx.ui.confirm("Go on?", "A question during the run"); } });
}
EOF
mkdir -p "$D/bin" "$D/work" "$D/work2"
@@ -121,11 +125,28 @@ type_line agent "/block"
expect "an extension's blocked event: blocked" "st agent" blocked 50
type_line agent "/unblock"
expect "cleared: back to done" "st agent" done 50
+# A dialog opened while idle comes from what you just did: nothing changes.
+BEFORE=$(st agent)
type_line agent "/ask"
-sleep 1.5
-note "during a pi confirm dialog dingger shows: $(st agent) (pi gives extensions no dialog event)"
+expect "a dialog opened while idle: on screen" "t capture-pane -p -t main:agent | grep -c 'Test dialog'" 1 30
+sleep 1
+expect "changes nothing" "st agent" "$BEFORE"
t send-keys -t main:agent Escape
sleep 0.5
+# A dialog during a run: the agent waits on you.
+type_line agent "slow 3 ask during the run"
+expect "a dialog during a run: blocked" "st agent" blocked 50
+expect "on screen" "t capture-pane -p -t main:agent | grep -c 'Go on?'" 1 30
+t send-keys -t main:agent Enter
+expect "answered: working again" "st agent" working 30
+expect "then done" "st agent" done 80
+# A run cancelled (Escape) while nobody watches: idle, not done.
+type_line agent "slow 8 cancel me"
+expect "a run to cancel: working" "st agent" working 50
+t send-keys -t main:agent Escape
+expect "cancelled: idle, not done" "st agent" idle 50
+sleep 1.5
+expect "and stays idle" "st agent" idle
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 "pi says so" "t capture-pane -p -t main:agent | grep -c 'importance: high'" 1 30
@@ -204,7 +225,8 @@ expect "and done" "st agent" done 60
expect "a message sent is no draft" "draft" "" 30
expect "nor kept" "[ -e \"\$(draft_file)\" ] && echo left || echo gone" gone 30
# What it cost: pi prices each reply; dingger counts them.
-replies() { grep -c '"role":"assistant"' "$1"; }
+# Priced replies: a cancelled run's reply is not.
+replies() { grep '"role":"assistant"' "$1" | grep -vc '"stopReason":"aborted"'; }
SESSION_FILE=$(t show -pv -t main:agent @agent_session)
N=$(replies "$SESSION_FILE")
[ "$N" -ge 3 ] && pass "$N replies so far" || fail "only $N replies"
@@ -278,7 +300,8 @@ expect "the layout becomes the restore candidate" "python3 -c 'import json,sys;
expect "restore resumes it by its session file" "grep -c -- \"resume .*pi --session\" $D/restore.out" 1
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
+# Its last message: the first may have scrolled off a long conversation.
+expect "with its conversation" "t capture-pane -p -t main:agent -S - | grep -c 'slow 1 after\$'" 1 50
expect "and its importance" "t show -pv -t main:agent @dingger_importance 2>/dev/null" high 50
expect "and its draft, back in the input box" "t capture-pane -p -t main:agent | grep -c '^an unsent idea;'" 1 50
expect "reported again" "t show -pv -t main:agent @dingger_draft 2>/dev/null" "an unsent idea;" 30