The first agent demo — see-and-stop Activity feed (#60) + headless runner (#61) + collapse/fold redesign - #67
Conversation
Extend the wire contract + daemon so the GUI approval queue can render
agent-initiated over-cap proposals, ordered and counted down by expiry.
- contract: add ProposalOrigin{App,Agent}; carry it on SignerRequest::Propose;
append remaining_ms + origin to PendingRecord (the inbox wire shape).
- daemon: stamp each PendingReq with its origin; PendingList now calls
expire_stale() BEFORE building records (expiry is daemon-authoritative, never
a bypassable UI timer) and computes remaining_ms from expires_at (panic-free).
- client: thread origin through propose / propose_blocking.
- tests: new pending_list suite (origin round-trip, expire-before-list,
remaining_ms==0 on terminal); update propose callers across the e2e suites.
A top-level Approvals inbox and Activity ledger built on the daemon's PendingList. Money-safety: approval ALWAYS routes through the clear-signing review (no blind-approve from a list row); agent proposals are Resolve-only — the app never broadcasts, the agent executes its own write once Allowed. - approvals_view: dense soonest-to-expire queue (loading / empty / fail-loud / expired states) + the clear-signing review (agent band, danger-early, ⌘Enter approve / Esc cancel). - activity_view: terminal records as the two-actor "Atlas → You" chain, honestly labelled session-scoped (in-memory). - shell: Approvals + Activity surfaces, ⌘⇧A, a PendingList poller, and resolve-only approve/deny that is non-optimistic (re-fetch authoritative status on success, fail loud on error). - shell_chrome: sidebar Approvals entry with an amber pending-count badge, plus an Activity entry. - palette: Approvals / Activity / Approve / Deny commands. signer: resolve_blocking.
- sidecar: propose with ProposalOrigin::Agent so the queue attributes the agent (Atlas) versus a human-composed action. - copy: the needs-approval guidance (sidecar / server / failure) no longer says "the approval UI is not in this alpha" — it now sends the human to the Deckard app's Approvals queue (⌘⇧A) + clear-signing review card. - acceptance: assert the shield tool docs carry the "Approvals queue" keyword. - common: ignore origin when destructuring Propose in the mock.
…ess runner (#61) + Superhuman collapse/fold redesign #60 — the Activity FEED (not just an inbox): a daemon-side ActivityRecord ledger (additive, byte-stable wire) that retains auto-allowed + executed agent actions (tx_hash, timestamp, the ACTUAL breached cap — per-tx vs daily, daemon-computed, no hardcoded lie), an honest auto_allowed flag (hands-free vs human-approved), and expire-on-read. App: a Superhuman-style inbox — a "NEEDS YOU" triage band you clear to "All clear" + a settled LOG, inline clear-signing review, a STOP control, two-signal fidelity (muted label / glyph carries color / amber on rows you acted on), and the shield payoff made visible ("moved X ETH to your private balance"). #61 — a self-contained headless runner (scripts/demo-agent.sh + just demo-vault/ demo-deposit/demo-agent) that drives the same 6 key-less MCP tools in a poll -> shield -> execute loop (idempotent ratchet, over-cap re-poll, STOP/locked detection, mainnet refusal). No new authority, no new wire. The Claude-Desktop prompt doc stays as the narrative version. Redesign (post-review): collapsed the redundant Approvals surface into the feed and deleted it; deleted the demo "agent acting" toggle + breathing pulse (the real runner replaces them); folded the "Agent" sidebar entity INTO the Wallet (Atlas is key-less automation on the same EOA, not a separate account) — the policy fence now lives in the wallet cockpit. Invariants held: agent stays key-less; ONE approval path (Resolve over the control channel, no Execute on approve, no blind-approve); STOP zeroizes + denies in-flight; the verdict path is frozen; wire is additive + byte-stable; never signs chain_id=0. Verification: cargo fmt clean; clippy -D warnings clean (default + tray); 245 workspace tests; shellcheck clean. Reviewed by a multi-agent workflow + codex (static); all confirmed findings fixed (notably the CRITICAL blind-approve path and a policy-fence honesty bug after STOP).
…inherited EIP-7702 delegation) UX (#2): the feed heading + STOP control are now PINNED outside the scroll region, so the panic brake can never scroll off screen — only the body (needs-you + log) scrolls beneath it. (The header was inside the scroll container before; in a normal-width window STOP showed, but it could scroll away — and a safety control shouldn't.) Demo (#4 root cause): `just demo-deposit` now clears any inherited EIP-7702 delegation on the demo wallet before funding it. The demo wallet is anvil's PUBLIC account-0 key, and on the forked Sepolia someone had 7702-delegated that address (its on-chain code = 0xef0100<delegate>). A plain ETH transfer to a 7702-delegated account runs the DELEGATE contract instead of crediting the EOA, so the balance never rose and the agent's deposit-delta detector never fired (shields, which only SIGN, still worked — which is why it looked half-broken). Clearing it back to a plain EOA makes the loop fully hands-free: deposit arrives -> Atlas notices -> shields -> broadcasts, no manual MCP commands. Verified end-to-end against the live app + runner. Plus the new STOP-flow screenshots (pinned STOP, armed "Confirm STOP", the revoked banner). fmt + clippy (default + tray) + 245 workspace tests + shellcheck all green.
Follow-ups: the demo is now truly hands-free, and STOP is pinned#4 — why the agent never noticed deposits (root cause). The demo wallet is anvil's public account-0 (
(Delightful irony: EIP-7702 is exactly Deckard's deferred "agent wallet" model.) #2 — STOP is now pinned. The heading + STOP control moved outside the scroll region, so the panic brake can never scroll off screen. The full flow, driven live (the
STOP arms on the first click ("Confirm STOP — revoke & lock signing · Esc to cancel"), fires on the second → the "Stopped — key zeroized" banner, and the runner reports the kill cleanly ( All green: fmt · clippy (default + tray) · 245 workspace tests · shellcheck. |
Resolved one conflict in crates/deckard-mcp/src/sidecar.rs: main refactored the sidecar to delegate chain-probing + the low-level socket request to the new deckard-wallet-client crate (PR #56 browser-bridge-out-of-mcp). Took main's structure (self.wallet.ensure_chain() + the trimmed deckard_contract imports), keeping ProposalOrigin since shield()/execute() still tag their proposes Agent-origin (the #60 feed shows those as "Atlas"). Carried this branch's additive wire change (SignerRequest::Propose gained an `origin` field) into the merged-in deckard-wallet-client: its connect-time chain probe now passes `origin: ProposalOrigin::Agent` (was failing to compile without it). Verified green on the merged tree: cargo fmt --check, clippy -D warnings (default + tray), full workspace tests, shellcheck.
…lock #66) Resolved conflicts so the Swap surface (main) and the Activity feed (this branch) coexist: - Surface enum + view_label + render dispatch: keep BOTH Surface::Activity and Surface::Swap. - lock() reset: take main's shield/send/swap sub-struct resets (the CommitFlow refactor moved shield/send state into self.shield/self.send and added self.swap) AND keep this branch's activity STOP-banner/arming/review clears. - Send/Shield render dispatch: take main's generic render_commit(&{SEND,SHIELD}_VIEW); insert the Activity arm; keep main's Swap arm. - Shield/Send review propose: take main's recipient-snapshot return tuple AND keep this branch's `origin: ProposalOrigin::App` arg (the wire's Propose requires origin). - palette_commands: keep main's `swap` command + this branch's activity/approvals/STOP commands; dropped main's brittle `assert_eq!(COMMANDS.len(), 11)` (the merged registry is larger) in favor of membership asserts. - swap.rs: main's swap shaped-approve propose_blocking now passes ProposalOrigin::App (App-origin — the user's foreground swap from the GUI), required by this branch's wire change. Verified green on the merged tree: cargo fmt --check, clippy -D warnings (default + tray), full workspace tests (0 failures), shellcheck.
… human swap "Atlas" Codex/two-signal finding: a user-initiated GUI swap produced two feed rows with MISMATCHED actors — the order row hardcoded ProposalOrigin::Agent (cyan "Atlas wants to swap…") while its companion exact-gross approve was correctly App (amber "You…"). A foreground human action narrated as the agent's action is exactly the trust-signal confusion the two-signal model exists to prevent. propose_order is only ever called from the app, so every order in the feed was mislabeled; the Swap surface is unflagged + user-reachable, so it's user-visible on Sepolia/mainnet. Fix (symmetric to Propose, which already carries origin): SignerRequest::ProposeOrder gains an additive `origin` field, threaded through Daemon::propose_order + SignerClient::propose_order[_blocking]; the daemon stores the caller's origin instead of a hardcoded Agent; the app's GUI swap (swap.rs + shell.rs) passes App. Reserves Agent for a future sidecar swap path. Display-only — the verdict path (evaluate_order) is untouched. Round-trip test extended for both origins; all call sites updated. Verified: fmt --check, clippy -D warnings (default + tray), full workspace tests (0 failures).
Merged latest
|
…dows so the STOP control + row trailing cluster never clip off the right edge)
…o-deposit chain guard Codex full-pass must-fix: refresh_activity reconciled the feed selection by RAW NUMERIC INDEX, not by request_id. The ~2s poller can insert/remove proposed rows between renders, so a stale index could point at a DIFFERENT pending record than the operator sees highlighted — and `x`/deny (and the palette deny-selected) resolve the SELECTED record. Result: a deny could fire against the wrong pending card under background churn. (Wrong-APPROVE was never reachable — approve self-guards by opening the request_id-keyed review first.) Fix: preserve the highlight by request_id across every refresh — capture the selected id from the old snapshot, re-key to it in the new pending subset, and clamp only if it's gone. Corrected the now-accurate approve/deny comment (deny is one-key by design: it only REFUSES a spend — the fail-safe direction — and now targets the highlighted record by id). demo-deposit HIGH footgun: the chain guard only refused chain 1, so any OTHER real chain fronted at :8545 (a forwarded Sepolia/Holesky node, a tunnel) would pass and `cast send` with a PUBLIC dev key. Switched to an allowlist — refuse unless the RPC reports the exact demo chain id (matches the `demo` recipe's own enforcement). Verified: fmt --check, clippy -D warnings (default + tray), full workspace tests (0 failures), shellcheck.
…mo runner + amber-honesty
Three codex-pass follow-ups, all approved as fix-now.
1. STOP-integrity hole (HIGH, swap path). `stop()` skipped the on-chain `invalidateOrder` for any
order whose LOCAL approval record had lapsed to `ApprovalStatus::Expired` (the 120s approval
TTL). But a signed order is loose on the CoW orderbook and settleable until its REAL `valid_to`
(up to 24h) — those are different clocks. So a signed order could still settle AFTER STOP. Fix:
gate STOP's cancel on the order's `valid_to`, never the local `Expired` status; skip only
genuinely-unsettleable (`valid_to` in the past) orders. Extracted the selection into a pure
`select_orders_to_cancel(requests, now)` with unit tests (a locally-Expired-but-settleable order
IS selected; unsigned / already-cancelled / past-valid_to / plain-Tx are skipped).
2. Demo runner redesign (HIGH+LOW, scripts/demo-agent.sh). The loop tracked deposits with a single
high-water mark it ratcheted BOTH up (on deposit) and down (after a shield settled). That
dual-direction scalar raced the settlement: a deposit landing in the ~2s window between a
broadcast and the balance dropping got absorbed by the downward rebaseline and lost (and an
unexpected shield reply orphaned a deposit). Replaced with a FIXED baseline set once at startup
— work each poll is just (public - baseline), new proposals gated on "nothing in flight", and the
daemon's deterministic request_id is the idempotency authority (re-propose returns the same
record as-is). No ratchet, no race, less code. Aligned docs/build/32-agent-loop-prompt.md to the
same model.
3. Amber-honesty (MEDIUM, the feed). A lapsed card mapped to `Decided{approved:false}`, so the feed
tinted it amber "you acted" / "not approved" even though NOBODY acted. Split out a new additive
`ActivityLifecycle::Expired` (roundtrip-tested): a human denial / STOP revoke stay
`Decided{false}` (a human DID act → amber is honest); a lapsed window renders neutral (muted
"expired" label + a neutral dash glyph, never amber). The two-signal "amber = a human acted"
promise now holds.
Verified: cargo fmt --check, just check (clippy -D warnings, default + tray), cargo test --workspace
(272 passed, 0 failed), shellcheck + bash -n on demo-agent.sh.
A high-recall /code-review pass (7 angles → verify) over our authored #60/#61 + fixes diff surfaced five real, reachable bugs — every one in code added this session. All fixed, with regression tests. 1. Runner wedges forever (HIGH, scripts/demo-agent.sh). try_execute() only dropped a saved id on broadcast / STOP / user_denied; an `expired` (120s approval-TTL lapse) fell through to "keep waiting", so the id stuck in PENDING_IDS — and the redesign's `pending_count == 0` gate then blocked EVERY future shield. Same for a re-proposed user_denied card (propose path treated it as transient → infinite "retry" spam). Fix: classify the daemon's terminal verdicts (mcp_is_terminal_refusal = expired | a human denied | user_denied) in BOTH try_execute and the propose-failure handler — drop the id AND advance the baseline past the refused funds (recorded per-id in a new PENDING_BASE array), so the loop gives up on dead funds and keeps watching for new ones. Aligned docs/build/32-agent-loop-prompt.md (it had the same expired-handling gap). 2. `--once` smoke shields the whole wallet (MED, justfile). Pinning baseline=0 against the prefunded demo wallet (anvil acct 0 holds ~10 ETH on the fork) treated the whole balance as surplus → over the per-tx cap → no broadcast → exit 1. Added `just demo-smoke`: captures the PRE-deposit balance, deposits, then runs --once pinned to that baseline so it shields exactly the deposit. The bare --once no longer defaults baseline to 0 (reads live balance = safe no-op). 3. Approved-then-lapsed erased the amber signal (MED, daemon.rs). A human-approved over-cap card that lapsed before execute mapped to ActivityLifecycle::Expired (neutral) — erasing that a human acted. Fix: activity_lifecycle now maps `Expired if req.approved` → Decided{approved:true}. 4. STOP-revoke of an auto-allow showed muted, not amber (MED, activity_view.rs). The amber tint keyed on bare `!auto_allowed`, but lock() leaves auto_allowed=true on a STOP-revoked row. A STOP is a human action → must read amber. Extracted a tested `human_acted` helper (any Decided{approved:false} OR !auto_allowed) and keyed the tint on it. 5. Stale activity_reviewing → dropped refusal (MED, shell.rs). resolve_activity_target preferred a set `activity_reviewing` even after a poll settled that record and the render fell through to the feed, so the first `x` resolved a stale off-screen id (a silent no-op) instead of the highlighted row. Fix: honor `activity_reviewing` only while its id is still in the pending subset, else fall back to the highlighted row. Verified: fmt --check, just check (clippy -D, default + tray), cargo test --workspace (274 pass, 0 fail — +2 regression tests), shellcheck + bash -n.
…a blind-approve regression) A second adversarial pass over the prior fix commit (5734602) caught that two of those fixes introduced NEW bugs — exactly why fixes get the same scrutiny as features. 1. BLIND-APPROVE regression (HIGH, shell.rs). Fix #5's filter fallback turned a safe no-op into a real wrong-row resolve: when a review for card X is open and a background poll expires X, the render falls through to the feed (it's &self, can't clear activity_reviewing) with a DIFFERENT over-cap card Y highlighted. A one-key ⌘Enter then approved Y — whose clear-signing card was never shown — violating "no blind-approve of a spend" (the agent would then execute Y's overage). Real fix: clear `activity_reviewing` IN refresh_activity, atomically with the snapshot swap, when its id leaves the pending set. Now approve sees None → re-opens the highlighted row's review (no blind-approve), and deny targets the visible highlighted row (fail-safe, not the old no-op). 2. False-positive `expired` match (HIGH, demo-agent.sh). mcp_is_terminal_refusal grepped a BARE "expired", which also matches transient RPC errors ("connection expired", "session expired") embedded in broadcast_failed/signer_error prose — mis-classifying a transient failure as terminal and abandoning a still-valid card's funds. Anchored to the exact daemon copy ("this request expired", "a human denied this request"). Verified: terminal copies match, RPC errors don't. 3. Two-signal mismatch (MED, activity_view.rs). The amber LABEL was re-keyed to `human_acted` but the "→ You" actor-chain link still gated on bare `!auto_allowed`, so a STOP-revoked auto-allow showed the amber label with NO human in the chain. Re-keyed `needed_human` to `human_acted` too. 4. Propose-path baseline clarity (demo-agent.sh). `BASELINE_WEI=public_wei` is numerically the just-now propose-time balance (symmetric with try_execute's PENDING_BASE), but read as "absorb the whole wallet". Rewrote as `wei_add BASELINE_WEI surplus` (identical value) so the intent — "skip exactly the funds the daemon just refused" — is plain. Verified: fmt --check, just check (clippy -D, default + tray), cargo test --workspace (274 pass, 0 fail), shellcheck + bash -n, and a matcher-precision simulation.
…e verify loop found) Round-3 verification found a THIRD path to the same blind-approve hazard the round-2 fix only partially closed: `review_activity_row` (the proposed-row click handler) set `activity_reviewing` UNCONDITIONALLY, so a click landing one frame after a background poll settled that row left a stale review id with no refresh before the next ⌘Enter — and `approve_activity`'s `.or_else(highlighted)` fallback then resolved a DIFFERENT highlighted over-cap row whose clear-signing card was never shown. Whack-a-mole on entry points won't converge, so this is the DEEP fix: approve now resolves ONLY the actively-reviewed, STILL-PENDING record — it has NO highlighted-row fallback. The decision is a pure `approve_target(reviewing, pending)` helper (returns the id to resolve, or None = "open a review first, resolve nothing"), so a blind-approve of an unreviewed spend is structurally impossible no matter how `activity_reviewing` went stale (poll settle, raced click, anything). Deny keeps its one-key highlighted-row fallback (it only REFUSES — the fail-safe direction). Also hardened `review_activity_row` to set `activity_reviewing` only when the clicked row is still pending (no phantom review for a just-settled row). The round-2 refresh-time clear stays as state hygiene but is no longer load-bearing for the invariant. New unit test `approve_target_never_falls_back_to_the_highlighted_row` pins the guarantee: a reviewed id that has left the pending set resolves NOTHING (never the other highlighted row). Verified: fmt --check, just check (clippy -D, default + tray), cargo test --workspace (275 pass, 0 fail), shellcheck.
Brings in main's browser-bridge provider events, wallet balance refresh, swap UX fixes (one-hold/repeat-amount/hardening), and release/docs work. Conflicts resolved: - palette_commands.rs: kept ours — our branch added activity/approvals/STOP commands beyond main's swap, so COMMANDS.len() is >12; dropped main's brittle hardcoded `== 12` for the membership asserts. - anvil_e2e.rs: kept BOTH new tests (ours `activity_feed_executed_carries_tx_hash` #60 + main's `repeat_same_amount_swap_approve_is_not_replay_blocked`) as separate fns + main's helpers; unioned the imports (ActivityLifecycle/BreachedLimit/ProposalOrigin + SwapOrder). Semantic merge fix (git didn't flag — clean auto-merge, wouldn't compile): main added new propose/propose_order calls with the OLD single-arg signature in shaped_approve.rs (`shaped_approve_requires_a_live_pending_order`); adapted them to our `(intent, ProposalOrigin)` signature (App for the order, Agent for the shaped approve), matching the file's existing calls. Verified: cargo check --workspace --all-targets, fmt --check, just check (clippy -D, default + tray), cargo test --workspace (277 pass, 0 fail), shellcheck.







The first agent demo — see-and-stop, and a demo you can actually run
Closes #60 and #61. Builds on the prior Tier-0 approval-queue work already on this branch.
#60 — the Activity feed (the see-and-stop surface)
ActivityRecordledger (additive + byte-stable wire;PendingRecord/ApprovalStatusuntouched) that retains auto-allowed + executed agent actions — withtx_hash, a daemon-stamped timestamp, anauto_allowedflag (hands-free vs human-approved), and the actual breached cap (PerTxCap/DailyCap, recomputed off the frozen verdict path — kills the hardcoded "over per-tx cap" lie). Expire-on-read.#61 — a self-contained headless runner
scripts/demo-agent.sh+just demo-vault/just demo-deposit/just demo-agent: a poll → shield → execute loop over the same six key-less MCP tools (idempotent ratchet, over-cap re-poll, STOP/locked detection, mainnet refusal). No new authority, no new wire. The 60-second demo is three terminal commands + the app window. The Claude-Desktop prompt doc stays as the narrative version.Redesign (from the design/product review — challenged the spec)
Invariants held
Agent stays key-less; one approval path (Resolve over the control channel — no Execute on approve, no blind-approve from a list row); STOP zeroizes + denies in-flight + stays reachable; the verdict path (
evaluate/propose/execute re-check/stop/mainnet guardrail) is frozen; wire additive + byte-stable; never signschain_id = 0.Verification
cargo fmt --all --checkclean ·just check(clippy-D warnings, default and--features tray) clean · 245 workspace tests ·shellcheckclean.Screenshots (driving the real app — please attach via the web UI)
Captured at
/tmp/deckard-qa-shots/final/:01-feed-inbox-log.png— NEEDS YOU (over-cap shield, selected,⌘⏎ approve · x deny, the real per-tx-cap cite) + the executed-shield LOG with the privacy payoff + tx hashes.03-clear-signing-review.png— the inline review card citing the actual cap ("Per-transaction cap", not a hardcoded string).04-feed-log-payoff.png— the settled log: "moved X ETH to your private balance" (muted), a denied row in amber ("declined / not approved"), no Agents entity.05-wallet-no-agents.png— the wallet cockpit; the "Agents" sidebar entity is gone.(CLI can't embed local PNGs — drag-drop these into the PR.)
Notes / minor follow-ups
revoke-all). Worth a one-line scroll-to-top on open.codex login(its refresh token expired); the prompt is preserved at/tmp/codex-review/for a re-run.