From 9ccf7558b6154401c4ccf8c5d7ad482418a6a644 Mon Sep 17 00:00:00 2001 From: afpruivo <148154314+afpruivo@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:51:08 +0100 Subject: [PATCH] Windows: take the approval card down when the relay stops waiting (#857) When the island's ack misses the 800 ms window (or nobody answers within 108 s), pipe.rs drops the request and the terminal takes over, but the island kept the card for its own 110 s. Clicks did nothing, and every other PermissionRequest in that time was declined by the one-card rule. Rust now emits `approval-expired` with the request id when it gives up without a decision; the island drops the card if it is that request's and cancels its 110 s timer. Co-Authored-By: Claude Opus 5.5 --- windows/src-tauri/src/pipe.rs | 13 ++++++++++--- windows/src/island/hooks.ts | 8 ++++++++ windows/tests/hooks.test.mjs | 15 +++++++++++++++ 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/windows/src-tauri/src/pipe.rs b/windows/src-tauri/src/pipe.rs index 2a6a1d1229..ab512f4b68 100644 --- a/windows/src-tauri/src/pipe.rs +++ b/windows/src-tauri/src/pipe.rs @@ -242,9 +242,16 @@ async fn handle(app: AppHandle, mut pipe: impl Relay) { // No decision: say nothing at all. coucou-hook then writes nothing to stdout // and Claude Code asks in the terminal, exactly as if Coucou were closed. - if let Some(d) = decision { - let _ = pipe.write_all(format!("{d}\n").as_bytes()).await; - let _ = pipe.flush().await; + // The island is told, so a card that came up late (or is still up after the + // long wait) goes instead of holding the view with nobody to answer (#857). + match decision { + Some(d) => { + let _ = pipe.write_all(format!("{d}\n").as_bytes()).await; + let _ = pipe.flush().await; + } + None => { + let _ = app.emit_to(WINDOW_LABEL, "approval-expired", &id); + } } pipe.finish(); } diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index b39ecc218a..a7795c7938 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -241,6 +241,14 @@ export function registerHookHandlers(island: Island) { // The last plan numbers seen survive a restart, as on the Mac. State.planUsage ??= restorePlanUsage(storedClaudePlanUsage()); void onEvent("hook", (payload) => handleHook(island, payload)); + // The relay stopped waiting without a decision and the terminal has taken + // over: a card still up for that request would be lying (#857). + void onEvent("approval-expired", (requestId) => { + if (State.pendingApproval?.requestId !== requestId) return; + if (pendingTimeout != null) window.clearTimeout(pendingTimeout); + pendingTimeout = null; + dropPendingCard(island); + }); } function handleHook(island: Island, payload: HookPayload) { diff --git a/windows/tests/hooks.test.mjs b/windows/tests/hooks.test.mjs index 45ec5ce052..552bbd5f98 100644 --- a/windows/tests/hooks.test.mjs +++ b/windows/tests/hooks.test.mjs @@ -329,6 +329,21 @@ test("the end of the turn takes a waiting card down and releases the relay", () } }); +test("a card the relay stopped waiting for goes, and the next request gets its card (#857)", () => { + hook({ hook_event_name: "PermissionRequest", request_id: "r1", session_id: "s1", tool_name: "Bash" }); + // Another request's expiry leaves this card alone. + emit("approval-expired", "r0"); + assert.equal(State.pendingApproval?.requestId, "r1"); + emit("approval-expired", "r1"); + assert.equal(State.pendingApproval, null); + hook({ hook_event_name: "PermissionRequest", request_id: "r2", session_id: "s1", tool_name: "Bash" }); + assert.equal(State.pendingApproval?.requestId, "r2"); + assert.deepEqual(sent("approval_decline"), []); + // r1's 110 s timer was cancelled: it doesn't take r2's card down. + seconds(100); + assert.equal(State.pendingApproval?.requestId, "r2"); +}); + test("Codex's Interrupt puts its pill back to idle", () => { hook({ hook_event_name: "UserPromptSubmit", session_id: "s1", coucou_agent: "codex", prompt: "go" }); assert.equal(task("agent_codex").state, "thinking");