Skip to content

Commit c048e52

Browse files
alicodingclaude
andcommitted
feat: attention escalation — idle-aware presence, floating approval prompt, alert guidance, cross-device forward (goal 0023)
Closes the twice-observed missed-decision failure ('background-ish pattern, not user attention pattern'): - internal/adapters/idletime (ioreg HIDIdleTime shell-out, zero cgo, no TCC gate — research-verified; the clipboard.go pattern): presence is now idle-aware. SettingsService.isAway is the ONE decision point: away = unfocused OR idle >= threshold (default 300s, Teams' own away default; a Settings knob). Fixes the focused-but-absent suppression bug (document.hasFocus() alone said 'present' for an unattended Mac). Idletime errors fail toward notifying. - Floating approval prompt (the incoming-call/askpass pattern, ADR-0033's second-window mechanism reused): a small always-on-top window appears over whatever app is focused when a decision parks while away — Approve/Deny inline for MCP writes, Open-in-Mill for guardrail parks (typed input, never blind approval). Deliberately no HideOnFocusLost: a decision prompt never auto-dismisses (the VS Code severity rule); Escape is the explicit dismiss. - notify.Start now actually requests notification authorization (never called before — found reading the pinned source); Settings copy documents the macOS Alerts-style toggle (Duo's documented ask). - Cross-device forward (default off): pending decisions optionally POST {kind,id,description,createdAt} through the user's OWN configured HTTPRequest via the same transport tail Decision webhooks use (never a second HTTP client) — the only layer that reaches an owner at another machine; §1.1-clean. httptest-covered. - e2e: approval-prompt.spec.ts (hash-route standalone render + approve-executes); mcpTestClient.ts extracted as the shared real-MCP-client helper. Window-level behaviors manual-only. Goal 0023 archived; SPEC §3.7 + ADR-0032 Update in-change. Full gates green (go -race, lint both tag sets, 178 vitest, 153 e2e). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FYwojT8GdUbYSoggbvEFft
1 parent d166c94 commit c048e52

28 files changed

Lines changed: 1466 additions & 94 deletions

docs/SPEC.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2598,6 +2598,29 @@ the now-existing mechanism**, not built yet: the same `notify.SendPlain`/
25982598
`SendActionable` primitives this pass added would carry it, once a
25992599
concrete "fire on X" event is chosen.
26002600

2601+
**Attention escalation — `LOCKED` and built (docs/goals/archive/0023-
2602+
attention-escalation.md, ADR-0032's Update).** The `document.hasFocus()`-only
2603+
presence gate above had a real bug (focused-but-idle read as present,
2604+
observed live) — corrected, plus two layers added: **(1)** an
2605+
idle-aware presence gate, `internal/adapters/idletime` (`ioreg -c
2606+
IOHIDSystem`, zero cgo) + `SettingsService.isAway` (away = unfocused OR
2607+
idle ≥ a Settings-configurable threshold, default 300s; an idletime
2608+
read error fails toward away, §8's posture); `NotifyPendingApproval`
2609+
now takes the frontend's `hasFocus()` reading as a param instead of
2610+
gating client-side. **(2)** a floating approval prompt at the same
2611+
away verdict — `#/approvalprompt`, ADR-0033's second-window mechanism
2612+
reused (deliberately not `HideOnFocusLost`), Approve/Deny for an MCP
2613+
write or "Open in Mill" for a guardrail park. **(3)** alert-style
2614+
authorization is now actually requested (`notify.Start` previously
2615+
never called `RequestNotificationAuthorization` at all); Settings
2616+
documents the System Settings → Notifications → Mill → Alerts toggle.
2617+
**(4)** a cross-device forward, `composition.SendJSONWebhook` (reuses
2618+
the integration-http/decision-outcome transport tail) +
2619+
`SettingsService.ForwardPendingApproval`, default-off, POSTs
2620+
`{kind, id, description, createdAt}` to a Settings-configured
2621+
HTTPRequest, independent of the presence gate — the layer that reaches
2622+
the owner with no local Mac to notify on at all.
2623+
26012624
**Still `OPEN`, real named gaps:** a menu-bar/dock presence toggle (see
26022625
above); appearance settings beyond light/dark; a default working
26032626
directory/scope (blocked on §6); fullscreen window-state tracking

docs/adr/0032-mcp-write-approval-park-and-poll.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,49 @@ this channel; unchanged.
172172
- ADR-0017's status gains a pointer here; SPEC §3.6 (MCP approval
173173
paragraph) and §3.7 (the notifications `OPEN` item, now partly
174174
resolved by this) update in the implementing change.
175+
176+
## Update — the presence gate corrected from focus to idle+focus, plus a floating prompt and a cross-device forward (docs/goals/0023-attention-escalation.md, delivered)
177+
178+
The `document.hasFocus()` gate this ADR's §3 originally specified had
179+
a real, observed bug: a focused-but-unattended Mac (the window sitting
180+
in the foreground while the user was away from the desk entirely)
181+
never notified, since a focused window read as "present" regardless of
182+
how long it had sat idle. Corrected, all four items delivered:
183+
184+
- **Presence moved backend-side.** `internal/adapters/idletime`
185+
(`ioreg -c IOHIDSystem`'s `HIDIdleTime` counter — zero cgo, no TCC
186+
gate, confirmed directly) plus `SettingsService.isAway(focused)`:
187+
away = unfocused OR idle ≥ threshold (default 300s, a Settings
188+
knob). `NotifyPendingApproval` now takes the frontend's own
189+
`document.hasFocus()` reading as a parameter instead of gating
190+
client-side, so App.tsx's per-new-item loop always reports and the
191+
backend makes the one presence decision. An idletime read error
192+
(server mode, or a real desktop failure) fails TOWARD away — §8's
193+
fail-safe posture applied here too.
194+
- **A floating approval prompt**, not just the OS notification: the
195+
same away verdict also shows a small always-on-top window
196+
(`#/approvalprompt`, ADR-0033's second-window mechanism reused
197+
verbatim — Hidden/Frameless/floating/hash-routed — deliberately NOT
198+
`HideOnFocusLost`, since a decision prompt must not vanish just
199+
because focus wandered; Escape is its one explicit dismiss). Shows
200+
the oldest unresolved pending item; Approve/Deny inline for an MCP
201+
write, "Open in Mill" for a guardrail/human-review park (blind
202+
approval from the prompt is never offered, mirroring the
203+
notification's own split above).
204+
- **Alert-style authorization is now actually requested.** Checked
205+
directly against the pinned notifications module's native
206+
implementation: `RequestNotificationAuthorization` has no per-type
207+
parameter to request Alert specifically — it always requests
208+
`UNAuthorizationOptionAlert | Sound | Badge` as one fixed bundle, so
209+
there was nothing to select, only something to call — which
210+
`notify.Start` had never done at all until now (a real gap, not a
211+
design choice). Backgrounded so app startup doesn't block on the
212+
permission dialog.
213+
- **A cross-device forward** for when there's no local Mac to notify
214+
on at all: `composition.SendJSONWebhook` (the exact same transport
215+
tail integration-http/decision-outcome's own webhook already share
216+
— never a second HTTP client) plus `SettingsService.ForwardPendingApproval`,
217+
a fire-and-forget, default-off, Settings-configured POST of
218+
`{kind, id, description, createdAt}` to the owner's own
219+
Configure-authored HTTPRequest (ntfy/Telegram/etc. — §1.1-clean),
220+
independent of the presence gate above.

docs/goals/0023-attention-escalation.md

Lines changed: 0 additions & 35 deletions
This file was deleted.

docs/goals/BACKLOG.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,12 @@ this pipeline and on this code)**
3838
fix the audited green-but-wrong class (12 silent persistence
3939
sites, unfailable test fake, uncovered safety-badge source);
4040
frontend half already largely clean
41-
3. [ ] [0023 — Attention escalation](0023-attention-escalation.md)
42-
floating approval prompt + idle-aware presence + alert-style +
43-
cross-device forward (twice-observed missed-decision pain)
41+
3. [x] [0023 — Attention escalation](archive/0023-attention-escalation.md)
42+
delivered: floating approval prompt (ADR-0033's mechanism reused,
43+
`#/approvalprompt`), idle-aware presence gate (`internal/adapters/idletime`,
44+
backend-side `isAway`), alert-style authorization request (notify.Start),
45+
cross-device forward (`composition.SendJSONWebhook`,
46+
`ForwardPendingApproval`) — see ADR-0032's Update note
4447

4548
**Ratified 2026-08-10 (owner): three groups, A→B→C. 0001 stays standing
4649
live-review material, interleaved during owner reviews, not a lane.**
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# 0023 — Attention escalation: decisions reach the human wherever they are
2+
3+
## Goal
4+
A parked decision (MCP write, guardrail ask) reliably reaches the
5+
owner — including away-from-this-Mac — closing the twice-observed miss
6+
("still very much background-ish pattern and not user attention
7+
pattern"). Layered per the ADR-0032 research + owner discussion.
8+
9+
## Plan
10+
1. [x] Floating approval prompt — the incoming-call/askpass pattern:
11+
an always-on-top mini-window Mill draws over whatever app is
12+
focused when a decision parks, Approve/Deny inline. REUSES the
13+
Quick Panel's second-window mechanism (ADR-0033); same
14+
focus-yield-on-dismiss mitigation. Built: `main.go`'s
15+
`approvalprompt` window (Hidden/Frameless/DisableResize/520×200/
16+
WindowCentered/Floating/CanJoinAllSpaces/HideOnEscape, deliberately
17+
NOT HideOnFocusLost), `SettingsService.SetApprovalPromptWindow`/
18+
`showApprovalPrompt`/`DismissApprovalPrompt`
19+
(settingsservice_approvalprompt.go), `app/ApprovalPrompt(App).tsx`
20+
at the `#/approvalprompt` hash route — shows the oldest unresolved
21+
pending item, Approve/Deny for an MCP write, "Open in Mill" for a
22+
guardrail/human-review park (never blind-approve). E2e-covered
23+
(`e2e/approval-prompt.spec.ts`); window-level behavior (floating
24+
level, backend-triggered Show, Escape, focus-yield) stays
25+
manual-only per `.claude/rules/testing.md`.
26+
2. [x] Idle-aware presence gate: replace `document.hasFocus()` (a
27+
focused window on an unattended Mac suppressed the notification by
28+
design — observed live) with system idle time; "present" =
29+
recently-active, not merely focused. Research the cgo-free macOS
30+
idle-seconds option first. Built: `internal/adapters/idletime`
31+
(`ioreg -c IOHIDSystem`'s `HIDIdleTime` counter, zero cgo, no TCC
32+
gate — confirmed directly; unit-tested against a captured real
33+
sample). The presence decision moved backend-side:
34+
`SettingsService.isAway(focused)` — away = unfocused OR
35+
idle≥threshold (default 300s, a Settings knob,
36+
`GetAttentionIdleThreshold`/`SetAttentionIdleThreshold`) — and
37+
`NotifyPendingApproval` now takes the frontend's own
38+
`document.hasFocus()` as a param instead of gating client-side. An
39+
idletime read error (server mode, or a real desktop failure) fails
40+
TOWARD away, per §8's fail-safe posture.
41+
3. [x] Alert-style notification guidance: request .alert
42+
authorization; Settings copy documents the System Settings →
43+
Mill → Alerts toggle (Duo's own documented ask — banners
44+
auto-dismiss in ~5s, structurally background-ish). Also verify
45+
notification delivery under the dev bundle's ad-hoc signature
46+
(ADR-0032's named unknown; owner observations pending). Checked
47+
directly against the pinned notifications module source
48+
(`notifications_darwin.m`): `RequestNotificationAuthorization` has
49+
no per-type parameter — it always requests
50+
`UNAuthorizationOptionAlert | Sound | Badge` as one fixed bundle,
51+
so there's nothing to select beyond calling it, which `notify.Start`
52+
previously never did at all (a real, now-closed gap) — backgrounded
53+
so app startup never blocks on the permission dialog. Settings
54+
copy added naming the System Settings → Notifications → Mill →
55+
Alerts toggle. Notification-delivery-under-dev-signing verification
56+
stays an owner on-machine check, unblocked but not performed here.
57+
4. [x] Cross-device forward: Settings-configured forward of
58+
pending-decision events to the owner's OWN HTTPRequest (ntfy/
59+
Telegram/etc.) — §1.1-clean (user-configured connector), the only
60+
layer that reaches the owner at the work machine. Built:
61+
`composition.SendJSONWebhook` (the exact transport tail
62+
integration-http/decision-outcome's own webhook already share —
63+
never a second HTTP client), `SettingsService.ForwardPendingApproval`
64+
(fire-and-forget, default off, gated on enabled+configured, fires
65+
`{kind, id, description, createdAt}` as the request's whole body,
66+
independent of the presence gate), a Settings section (enable
67+
toggle + the ADR-0009 `EntityRefField` request picker, reused
68+
directly — `views/` importing `configure/` is allowed by
69+
`.dependency-cruiser.cjs`). Go-tested against a local `httptest`
70+
server via the `SetHTTPRequestLookup` seam.
71+
72+
## Acceptance
73+
Owner at another machine (or idle) learns of a parked decision within
74+
seconds via at least one configured layer, and can resolve it from the
75+
floating prompt on return; the focused-but-idle suppression case is
76+
demonstrably fixed.

frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/guardrailservice.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,23 @@ import * as guardrail$0 from "../../domain/guardrail/models.js";
2424
import * as $models from "./models.js";
2525

2626
/**
27-
* CreateRule validates and stores a new rule, minting its ID.
27+
* CreateRule validates and stores a new rule, minting its ID. On a
28+
* persist failure the appended rule is rolled back rather than left
29+
* live in memory only -- a rule that silently failed to save must not
30+
* appear to be gating anything (docs/goals/0025 item 2's memory-vs-
31+
* store consistency rule, applied to guardrail rules too since a
32+
* phantom-saved rule here is worse than most: it's the thing deciding
33+
* whether a step needs approval).
2834
*/
2935
export function CreateRule(rule: guardrail$0.Rule): $CancellablePromise<guardrail$0.Rule> {
3036
return $Call.ByID(3694735496, rule);
3137
}
3238

3339
/**
3440
* DeleteRule removes a rule by ID; deleting an absent rule is a no-op,
35-
* matching every other Configure entity's delete semantics.
41+
* matching every other Configure entity's delete semantics. Returns the
42+
* persist error (rather than swallowing it, docs/goals/0025 item 1) and
43+
* restores the deleted rule if the store write fails.
3644
*/
3745
export function DeleteRule(id: string): $CancellablePromise<void> {
3846
return $Call.ByID(1475597571, id);
@@ -58,7 +66,7 @@ export function TestRules(workflowID: string, nodeID: string): $CancellablePromi
5866

5967
/**
6068
* UpdateRule replaces an existing rule in place, same validation as
61-
* create.
69+
* create; rolls back to the previous rule value if the persist fails.
6270
*/
6371
export function UpdateRule(rule: guardrail$0.Rule): $CancellablePromise<void> {
6472
return $Call.ByID(2711428157, rule);

0 commit comments

Comments
 (0)