diff options
| author | Igor Soarez <igor@soarez.org> | 2026-11-05 13:12:08 +0200 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-11-05 13:12:08 +0200 |
| commit | 3b7b7ed091bae3b0609c38b2d445201cc4b7a026 (patch) | |
| tree | 1d098a7401e08ddb66696d749d0717a2c5bf4102 | |
| parent | 9518a5442576268a04e7dbfcddeb2457add2f189 (diff) | |
pi: a dialog during a run is blocked, and a cancelled run is idle, not done
| -rw-r--r-- | README.md | 9 | ||||
| -rw-r--r-- | ROADMAP.md | 22 | ||||
| -rw-r--r-- | crates/dinggerd/assets/pi/pi-agent-state.ts | 47 | ||||
| -rw-r--r-- | crates/dinggerd/src/hooks.rs | 48 | ||||
| -rw-r--r-- | crates/dinggerd/src/watcher.rs | 21 | ||||
| -rwxr-xr-x | tests/e2e.sh | 29 | ||||
| -rwxr-xr-x | tests/pi-live.sh | 31 |
7 files changed, 170 insertions, 37 deletions
@@ -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 @@ -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 |
