feat(desktop): approval card for parked device tool calls (#20938, PR 2)
Depends on #20973 (PR 1a) and #20975 (PR 1b). Stacked on feat/desktop-approval-dispatch-relay, so the diff shows their commits too until they merge; I will rebase onto main as each lands. The PR 2-only change is the last three commits (47 files). Merge order: #20973 → #20975 → this → PR 3, in the same release window.
What changed and why
Card half of #20938 (PR 2, stacked on feat/desktop-approval-dispatch-relay, itself stacked on feat/desktop-approval-dispatch). PR 1a taught the kernel to park send_message, run_applescript, read_message_history, list_message_chats, list_mail_messages and capture_screen behind an invocation-bound approval dispatch; PR 1b connected that to the relay and told Swift about it with approval_requested and approval_resolved. This PR renders it. DesktopToolApprovalStore projects each approval_requested frame into a card and closes it on approval_resolved, whoever ended it: the user, expiry, cancellation, or an owner change. DesktopToolApprovalCard shows the tool's title and question, the exact target (resourceRef), the content preview the kernel bound the invocation to, the time the request stops waiting, and the answers: Allow Once, Allow for This Chat (1 h) only when the request offers allow_session (with its covers text under the buttons), and Deny. DesktopToolApprovalCardList mounts at the live edge of the thread whose kernel session asked, matched through AgentRuntimeStatusStore.sessionIdBySurface, so a card never shows under another conversation: main chat (QueryAnswerThread), a workstream (TaskChatPanel) and the agent pill (AIResponseView, which falls back to the main chat session when no agent pill is open). A capture_screen card is plain words only: "Take a screenshot", "Let Omi take a screenshot of your whole screen?", no target or preview rows (its screen resource is a grant key, not something to show), and only Allow Once or Deny: the runtime never offers a session grant for a screenshot, so a press of Allow for This Chat on such a card is refused locally like any answer the runtime did not offer. The copy and the options come from the runtime policy, so the card view needed no new code for it.
Answers go through signed direct control. DesktopCoordinatorService.resolveDispatchJSON calls resolve_desktop_dispatch with the full input: allow once sends resolution.decision: "allow" and no grant; allow for this chat adds a grant with runId: null, the dispatch's capability, operation and resourceRef, source: "user" and a 1 h expiresAtMs; deny sends decision: "deny". A refused or undelivered answer puts the card back to pending with the reason and the buttons return; the kernel's approval_resolved always wins over a local answer in flight, and a second press while answering or after the decision sends nothing. The card never runs anything: the only path to the tool is still the kernel's resolution, exactly as PR 1a left it.
The app declares desktop_tool_approval_cards through client_capabilities right after a successful runtime handshake, but only to a runtime whose init advertises desktop_tool_approval_requests; an older daemon without it still runs the chat and keeps its immediate approval_required, so this build never takes the chat down over a capability. With a current daemon PR 1b's interim gate turns parking on whenever this app is connected. The gate and the client_capabilities message stay as they are so this diff remains a reviewable UI change; removing them so parking is unconditional is a follow-up (PR 3) once the card has shipped in a beta. Resolved cards stay in the thread so an answer does not make the request vanish; the store keeps a bounded tail of fifty and never drops a pending card. Cards belong to one daemon process and one owner: the store is cleared on every runtime handshake, when the daemon exits and when the owner is revoked; a card whose deadline passes without a frame closes locally as expired and loses its buttons; and an answer the kernel refuses because the dispatch is no longer pending closes the card the way the kernel did rather than looping on "Couldn't send your answer". Model-authored text on the card is display-safe: bidirectional controls, zero-width and other invisible characters and control characters are shown as their code point (⟨U+202E⟩) rather than stripped, newlines are kept, and the raw resourceRef is what a session grant covers while the card shows the marked copy; the recipient is drawn in a monospaced run, a multi-line value is drawn as one ruled block so it cannot pose as another row, and a thread read by chat id still shows the handle it was given. Answers sent by the bridge carry resolvedBy: "desktop_automation"; the card's buttons answer as user.
Two automation bridge actions, tool_approval_snapshot (read-only) and answer_tool_approval (declaring all four effects, because an allow lets the parked tool run), let omi-ctl read and answer cards through the same store method the buttons call. They are non-production only like the rest of the bridge and are how the live run below was driven without Accessibility permission.
A card waiting on the person is not a stall. The founder's live run found the chat lane's 90 s tool no-progress guard (ChatProvider.perToolStallAbortMs) revoking the turn and interrupting the bridge while a card was up (app log: ChatProvider: tool no-progress guard fired at 90s (active_tools=1); interrupting bridge, 90 s after tool.started; the kernel then wrote run.cancellation_requested, attempt.cancelled, approval.resolved with decision: cancelled, run.cancelled), with the orange "taking longer than usual" banner up from 20 s. StallDetector now has a waitingOnUser state the turn's 500 ms tick loop sets from the approval store: while a card for the session is pending nothing promotes to slow or stalled, no tool is ever overdue, the tool row reads "Waiting for your approval" (new ToolCallStatus.waitingApproval, persisted as in-flight), and when the card closes every clock restarts from that moment so the 90 s covers only the tool's real execution. The composer's stop button still cancels the run and the card still reads "Cancelled, not run". Every lane shares this one guard, because the main chat, the agent pill and a workstream thread all send through ChatProvider.sendMessage with their own surface; the check is keyed by the session that turn resolved (pinnedSession.sessionId), so a card raised on the pill pauses the pill's turn and not the main chat's. The pill case is unit-tested, not live on the pill. Two residuals, documented rather than fixed: while a card is up every in-flight tool row of that turn reads "Waiting for your approval", not only the parked tool's, because the detector keys tools by the harness toolUseId and the card carries the kernel invocation id (the in-app model runs one tool at a time, so in practice it is the same row); and a card stuck in .answering behind a hung direct-control call keeps the guard paused until the card's local expiry, at most 180 s, since callRuntimeControlTool has no timeout of its own. On the daemon side (PR 1b's area, kept here because it is small) the hermes and openclaw ACP adapters' 150 s no-progress cancel now treats a run the kernel holds in waiting_approval as progress, through an isRunWaitingOnUser accessor the adapters get from the kernel.
One finding from the live run that PR 3 should take: left to itself, the in-app pi-mono model answers "send an iMessage" by calling create_desktop_dispatch rather than send_message, because the five tools' descriptions in omi-tool-manifest.ts say the call "always requires an approval dispatch". That model-authored dispatch is the pre-existing flow (no binding, no card, nothing in the app renders it, and it never expires), so the card appeared in the live run only when the model was told to call the tool directly; without that description change the in-app model often takes the old create_desktop_dispatch path and no card appears. Those descriptions should say the app asks the user itself; that change regenerates the Swift manifest digests, so it is prepared as a separate tiny branch, feat/device-tool-descriptions, stacked on this one to merge right after it.
Not in this PR: a "Needs approval" line in the pill's activity row, parking for the realtime voice surface (unchanged from PR 1b), deleting the interim gate, and the tool-description change above (its own branch).
Oversized-file growth is limited to the call sites that cannot leave those files; everything else is in new files (DesktopToolApprovalStore.swift, DesktopToolApprovalCard.swift, AgentRuntimeProcess+ToolApprovals.swift, Automation/DesktopAutomationToolApprovalActions.swift). e2e/flows/device-tool-approval-card.yaml covers them for the flow-coverage gate, and scripts/desktop_flow_contract.py lists the new actions file.
Line-Count-Exception: desktop/macos/Desktop/Sources/Chat/AgentRuntimeProcess.swift | 4373 -> 4384 | two kinds in the closed message switch, the post-handshake clear and conditional capability send, a two-line routing case, the owner-revoke case that clears cards, and one clear on daemon exit; the statics, wire message, routing body and clearing live in AgentRuntimeProcess+ToolApprovals.swift, and messageOwnerIsCurrentlyAuthorized drops private so that extension can call it
Line-Count-Exception: desktop/macos/Desktop/Sources/DesktopAutomationBridge.swift | 5172 -> 5173 | one registerToolApprovalActions() call in the builtin registration list; the actions live in Automation/DesktopAutomationToolApprovalActions.swift
Line-Count-Exception: desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift | 3018 -> 3020 | the approvalSurface argument at the one AIResponseView call site
Line-Count-Exception: desktop/macos/Desktop/Sources/MainWindow/Components/ChatBubble.swift | 2162 -> 2172 | the "Waiting for your approval" caption under the tool row and its accessibility value; the status itself and the icon live in ChatProvider's enum and ToolActivityTimelineLayout
Line-Count-Exception: desktop/macos/Desktop/Sources/Providers/ChatProvider.swift | 6824 -> 6834 | the waitingApproval case of ToolCallStatus with its doc line, its detector mapping, the turn's session id captured for the stall clocks, and the tick-loop lines that ask the approval store about that session; the lookup lives in ChatProvider+ToolApprovalWait.swift
Product invariants affected
INV-AGENT-* (product/invariants/agent-control-plane.md), section Authority: the card is a display projection and holds no authority. Its allow path reuses resolve_desktop_dispatch over the same signed direct-control channel directControlTool already uses, it never calls a tool, and the session grant it mints is bounded to the dispatch's exact resource and 1 h. The relay still dispatches only on the kernel's approval.resolved. Guard tests: ApprovalCardTests (allow once sends no grant; allow for this chat sends a session-scoped grant for the exact resource and is refused locally when the runtime did not offer it; a second press sends nothing; the kernel's resolution wins over a local answer in flight; a refused answer returns the card to pending), AgentRuntimeProcessTests (the handshake now requires desktop_tool_approval_requests; the shell declares desktop_tool_approval_cards after the handshake).
INV-CHAT-1 (product/invariants/chat-continuity.md) is named because desktop/macos/Desktop/Sources/Chat/**, desktop/macos/Desktop/Sources/FloatingControlBar/** and desktop/macos/Desktop/Sources/MainWindow/Components/Chat*.swift match its path globs. Unaffected: the store is a process-local projection of daemon frames, writes no transcript state, and is cleared on every handshake, daemon exit and owner change.
INV-CHAT-2 (product/invariants/chat-scroll-placement.md) is named because desktop/macos/Desktop/Sources/MainWindow/Components/ChatMessagesView.swift matches its glob. Unaffected: the card list is appended after the message rows inside the existing scroll content and touches no launch placement, anchoring, follow or virtualization logic; it never scrolls the reader, and a card appearing or closing changes content at the live edge only, exactly as a new message row does.
INV-AUTH-1 (product/invariants/auth-session.md) is named because desktop/macos/Desktop/Sources/Providers/ChatProvider.swift matches its glob. Unaffected: the change there is the waitingApproval status case, its detector mapping and two lines in the stall tick loop; it touches no session, token, owner-transition, 401 or Keychain path, and the lookup it adds reads a MainActor store keyed by the surface's kernel session.
INV-UI-1 and INV-UI-2 (product/invariants/brand-ui.md, product/invariants/desktop-ux-contract.md): the card draws with Ink, OmiType, OmiSpacing and PageGlass tokens and OmiButtonStyle only, no literal colors or fonts; text on the card and the "Waiting for your approval" caption uses only Ink.primary/Ink.secondary, never Ink.tertiary, which measures under WCAG AA on the glass panel (ShellGlassChromeTests failed on the first version's caption; the card file is now in that test's list of chat surfaces too); check_desktop_ux_contract.py reports no hand-rolled UX increase across the changed files.
How it was verified
From desktop/macos at the branch tip (fe0cbf069a, stacked on PR 1b 7cc3022b9a, PR 1a 10ef09e38a, main d3208a0cda). The capture_screen gate was folded into the stack after the first round of checks; the build, the focused suites, the Node run and the preflight below were re-run on this tip, while the ./test.sh run and the live packaged-app run were on the earlier tip (1158b110dd), before capture_screen was gated:
xcrun swift build -c debug --package-path Desktop-> clean (warnings are errors in this package).- Focused suites through
scripts/dev-feedback.py --once swift:ApprovalCardTests24/24 (new),AgentRuntimeProcessTests74/74,StallDetectorTests24/24 (3 new),ChatStallWatchdogTests10/10 (2 new),ToolCallStatusTests31/31,KernelTurnRecordedProjectionTests41/41,AgentRuntimeStatusStoreTests17/17,AuthorizedToolExecutionTests15/15,DesktopCoordinatorServiceTests32/32,DesktopAutomationActionEffectTests6/6,AgentContinuityGauntletTests5/5,ShellGlassChromeTests14/14; 0 failures. The whole Swift suite on the top of the stack (this branch plus PR 3) ran 7900 tests with one failure,RecentScreensMenuContentTests.testDayOldFrameShowsACalendarDateNotAClockTime, which also fails on upstream/main (clock and locale);SubscriptionEntitlementServiceTests.testAuthChangeInvalidatesCachecrashes the test process with signal 5 in a whole-suite run on upstream/main too, so that run skipped it. ./test.sh-> its shell phase ran all 54 launcher checks pluscheck-e2e-flow-coverage.py --strict; the only test-level failure wastests/test-omi-dev.sh, which fails on this machine's globaltag.gpgsign/tag.forceSignAnnotated(a baregit tagthen wants a message) and passes with those two settings off. The script then stopped at its Python phase becausebackend/.venvdid not exist; afterbackend/scripts/sync-python-deps.shthe same pytest command passed 269/269, and the Swift phase was run asscripts/swift-test-suites.sh: 108 suite invocations green, 2849 tests, and three failures outside this diff, each reproduced by the script's isolating rerun:RecentScreensMenuContentTests.testDayOldFrameShowsACalendarDateNotAClockTime,MeetingScreenEvidencePassTests.testRetryAfterFinalizeJoinsTheRunTheDeadlineLeftInFlight,DesktopAutomationWindowPresentationTests.testQuietPresentationAlsoParksNonactivatingOverlayPanels. None touches a file in this PR. Two fail alone for environmental reasons:RecentScreensMenuContentTestsrenders a 2027 frame as "13 Jan 2027 at 3:00 AM" where the test wants a bare calendar date (clock and locale), andDesktopAutomationWindowPresentationTestsmeasures real AppKit window frames on this display (title-bar and display geometry). The third,MeetingScreenEvidencePassTests, is a timing race (screenshots storeidlewherereadyis expected) that fails in the full run and in the one isolated run I made, and may pass alone. None was run on 1b's tip.- Node side:
npm ci,npm run buildclean,npx vitest runinagent-> 73 files, 959 tests passed (958 from PR 1b plus the ACP waiting-on-user test inruntime-adapter.test.ts). python3 .github/scripts/check_desktop_ux_contract.py --changed-files <list> --base feat/desktop-approval-dispatch-relay-> "OK: INV-UI-2 - no hand-rolled UX increase across 13 changed desktop file(s)";check_agent_doc_references.py-> OK;desktop-changelog.py validate-> OK.scripts/pr-preflight --base feat/desktop-approval-dispatch-relay --pr-body-file <this body>-> "PR preflight passed: 38 checks" (lane ci, base 7cc3022b9a29, 47 files; Python 3.11, as for PR 1a/1b). The run drove three changes that are in the diff: the approval wiring moved out ofAgentRuntimeProcess.swiftintoAgentRuntimeProcess+ToolApprovals.swiftfor the oversized-file ratchet,e2e/flows/device-tool-approval-card.yamlcovers the four new Swift files (two hermetic bridge steps, a log expectation for the capability line, and the live steps as manual prose), andscripts/desktop_flow_contract.pylists the new Automation actions file so flow lint knows its actions.scripts/failure-class prepare --pr-body-file <this body>-> OK,requires_declaration: false(nofix:commits).
Real path, packaged app. OMI_APP_NAME="omi-sauransh-test" ./run.sh --yolo --no-wait built, signed and launched the named bundle (com.omi.omi-sauransh-test, dev backend, auth seeded from the production session) with this branch's app and daemon; omi-ctl wait-ready reported a signed-in main state and health a running runtime on protocol 2, and the daemon logged client_capabilities client=desktop-shell desktopToolApprovals=on. The card was driven through the new bridge actions (tool_approval_snapshot, answer_tool_approval), which call the same store method the buttons call; the accessibility driver on this machine was unavailable, so my harness never pressed a SwiftUI button; two cards were pressed by a person in the app during the run (below), which covered that path.
- Pending: asking the main chat to call
send_messagedirectly parked the call; within 4 s the snapshot showed one pending card (send_message, resource+1 555 010 0001, options allow_once/allow_session/deny, "Waits until 12:59 PM") and the thread rendered it under the "Using send_message" row (image 001). - Deny:
answer_tool_approval ... answer=deny-> store statedenied, status "Denied, not run" (image 002); the kernel store shows the dispatchresolvedbyuserwith{"decision":"deny","selectedOptionId":"deny"}, the ledger row for that invocationfailedwitherror_code=approval_deniedand nodispatched_at_ms, and the model replied "The iMessage wasn't sent - the approval was denied." Nothing was sent. - Allow once, with a read tool so nothing is sent to anyone: a direct
list_message_chatscall parked (image 003);answer=allow_once-> "Allowed once" (image 004); the ledger shows exactly one row for it with onedispatched_at_msand onecompleted_at_ms, and the app log one "Executing tool: list_message_chats with args: ["limit": 3]". The tool itself then failed because the fresh named bundle has no Full Disk Access, which the model reported; that is a TCC fact about the test bundle, not the approval path. - Allow once on a real send, pressed by a person: a third direct
send_messagecard (+1 555 010 0002, text "Expiry check, do not send", raised at 12:59:33 for the expiry check below) was answered Allow Once in the app's own card by the founder at the machine five seconds later, while my harness was waiting for it to expire. The kernel store shows that dispatchresolvedbyuserwith{"decision":"allow","selectedOptionId":"allow_once"}and its ledger rowsucceededwith onedispatched_at_msand onecompleted_at_ms, so the SwiftUI button path itself ran end to end once and Messages accepted the send to that reserved 555 number. No screenshot of that moment exists; the harness was asleep. - Expiry, before the fix: the founder ran the same read-only ask himself and left the card alone; at 20 s the thread showed the orange "This is taking longer than usual" banner with Cancel, and at 90 s the card flipped to "Cancelled, not run" without anyone touching it. The app log has
ChatProvider: tool no-progress guard fired at 90s (active_tools=1); interrupting bridge90 s aftertool.started, and the kernel events for that run readrun.waiting_approval,approval.requested, thenrun.cancellation_requested,attempt.cancelled,approval.resolved(decision: cancelled,resolvedBy: system, reasonrun_tool_attempt_terminal) andrun.cancelled. That is the defect the stall fix above closes. - Expiry, after the fix, on the rebuilt bundle: a direct
list_mail_messagescard raised at 15:00:14 was left alone; at 30 s the tool row read "Waiting for your approval" with no banner (image 008); at its 180 s deadline the card read "Expired, not run" (image 007), the model replied "The request to read the latest mail expired before approval, so I couldn't retrieve it", the kernel store shows the dispatchexpiredbysystem(approval_wait_expired) and the ledger rowfailedwitherror_code=approval_expiredand nodispatched_at_ms, and the app log for that process has nono-progress guard firedline. - Re-tested by a person on the final head (this branch plus PR 3, rebuilt bundle, 16:27–16:31): a plain "Send an iMessage to +1 555 010 0002 saying: testing Omi approvals" (no tool instructions) raised the card and Allow Once ran it once; the same ask answered Deny read "Denied, not run"; "List my recent Mail messages" left alone read "Expired, not run" — kernel
approval.resolvedwithdecision: expired,reason: approval_wait_expired, 180.005 s afterapproval.requested, and zero cancellation events for that run. - Allow for this chat was not exercised live (it would mint a real grant against the dev backend session); its request shape is pinned by
ApprovalCardTestsand the kernel side by PR 1a'scontrol-tools.test.ts.
Not verified live: the workstream (TaskChatPanel) and agent pill (AIResponseView) hosts, which share the same DesktopToolApprovalCardList and differ only in the surface they pass; an answer refused by the kernel (ok: false) and a transport failure, both unit-tested; a Claude Code or pi lane through the stdio relay (the live run used the in-app pi-mono harness, whose device tools take the same relay). Two first-run setup gaps on this machine, both outside the diff: run.sh's bundle audit needs Homebrew pcre2 for the SDK's vendored ripgrep and a static ffmpeg at the git-ignored Desktop/Sources/Resources/ffmpeg; I installed pcre2 and fetched the arm64 build from the same source codemagic.yaml uses.
Tests
ApprovalCardTests (new, 24): a requested frame becomes a pending card with target, preview and three answers; Allow for This Chat is offered only when the runtime offers it; a malformed frame is ignored; allow once sends one resolution and no grant; allow for this chat sends a session-scoped 1 h grant for the exact resource (runId: null, resourcePattern = resourceRef, expiresAtMs = now + 3600 s) and is refused locally when not offered; deny sends a deny resolution and the card reads denied; a second press while answering or after the decision sends nothing; a refused answer (ok: false) and a transport failure both put the card back to pending with the reason; approval_resolved closes the card for allow, deny, expired and cancelled; a resolution that arrives before its request still closes the card; a resolution for another card or with an unknown decision changes nothing; the kernel's resolution wins over a local answer in flight; trimming drops resolved cards and never pending ones; DesktopCoordinatorService.resolveDispatchJSON passes the full resolution and grant through direct control; the bridge actions are registered with the declared effects and their helpers read and guard the shared store like the card; bridge answers carry resolvedBy: desktop_automation while the buttons answer as user; a pending card expires locally at its deadline and offers no buttons, and a press after the deadline sends nothing; a refusal because the dispatch is no longer pending closes the card like the kernel did; reset() drops every card and early resolutions stay bounded at fifty; preview text is display-safe with each hidden character shown as ⟨U+XXXX⟩ while the raw resourceRef still feeds the grant's resourcePattern, and a thread read by chat id keeps its handle; a capture_screen card shows only its plain title and question with no target or preview rows and only Allow Once and Deny, a press of Allow for This Chat sends nothing, and Allow Once sends one resolution with no grant; Clear Chat sends the main chat's journal clear, which is what ends the chat's grants and parked cards in the daemon (PR 1b), so "Allow for This Chat" does not outlive the chat. e2e/flows/device-tool-approval-card.yaml gains a manual step for that.
AgentRuntimeProcessTests: a runtime that advertises desktop_tool_approval_requests gets the client_capabilities message (protocol version, request id, desktop-shell, the capability) and an older runtime without it still passes the handshake and gets none; approval_requested and approval_resolved parse to their kinds; clearToolApprovalCards() empties the shared store. The three places that call it (handshake success, daemon exit, owner revoke) are reviewed rather than unit-tested: the test-quality ratchet forbids another production-source scan, and the message router is private.
StallDetectorTests (3 new): waiting on the person pauses every clock and restarts them when the card closes so the 90 s abort counts from the answer; a card appearing clears an already-stalled tool to waitingOnUser and nothing re-promotes while it is up; repeating the same wait state changes nothing. ChatStallWatchdogTests: the tick loop tells the detector about the wait, from the approval store keyed by the turn's own resolved session, before it asks which tools are overdue; a card on the pill's session pauses that turn and not the main chat's, and a closed card no longer pauses. ToolCallStatusTests and KernelTurnRecordedProjectionTests cover the new in-flight status. runtime-adapter.test.ts (Node, 1 new): a hermes attempt whose run the kernel holds in waiting_approval is not cancelled for lack of progress, and the cancel fires one timeout after the card closes.
UI evidence
Captured in the packaged named bundle built from this branch, through the bridge's in-process window capture (capture_main_window_png), while driving the card with omi-ctl; captured_by: agent, source: visual-audit in .agent-artifacts/ui-evidence/desktop-approval-card/evidence.json.
- Pending card under the tool row, with target, preview, three answers, the covers note and the expiry line:
.agent-artifacts/ui-evidence/desktop-approval-card/001-desktop-macos-approval-card-pending.png - After Deny:
.agent-artifacts/ui-evidence/desktop-approval-card/002-desktop-macos-approval-card-denied.png - A second tool's pending card (
list_message_chats) with the earlier denied card kept in the thread:.agent-artifacts/ui-evidence/desktop-approval-card/003-desktop-macos-approval-card-read-pending.png - After Allow Once:
.agent-artifacts/ui-evidence/desktop-approval-card/004-desktop-macos-approval-card-allowed-once.png - A third tool's pending card (
list_mail_messages):.agent-artifacts/ui-evidence/desktop-approval-card/005-desktop-macos-approval-card-mail-pending.png - The same card after a person pressed Allow Once in the app:
.agent-artifacts/ui-evidence/desktop-approval-card/006-desktop-macos-approval-card-mail-allowed-by-person.png - The real expiry on the rebuilt bundle, "Expired, not run" at 180 s:
.agent-artifacts/ui-evidence/desktop-approval-card/007-desktop-macos-approval-card-expired.png - The same card 30 s in, past the old stall threshold: tool row "Waiting for your approval", no banner, buttons and deadline intact:
.agent-artifacts/ui-evidence/desktop-approval-card/008-desktop-macos-approval-card-waiting-no-banner.png
No capture of a capture_screen card: it is the same view with no target or preview rows, pinned by ApprovalCardTests, and was not driven live.
The captures above predate moving the card's field labels, covers note and pending status line, and the tool row's "Waiting for your approval" caption, from Ink.tertiary to Ink.secondary; those runs now draw one step darker, and the images have not been recaptured.
Known shape of this first version, visible in 002 and 004: a resolved card stays at the live edge of the thread, below the model's closing reply, rather than anchored to the turn that asked. Anchoring it to the turn needs the transcript to carry the dispatch id; left for a follow-up.
Failure class (fixes)
Failure-Class: none