feat(approvals): let a watching session keep quiet, and fix the tui gate

The badge alone left the row in NEEDS YOU, which is the thing the issue was
about. The fix is the alert that does not fire.

An idle prompt from a session that is watching its own background work now
opens ALREADY acknowledged. `hook-event-routes` passes `Session.watching` to
`notePrompt()`, which sets `acknowledgedAt` and records why in a new
`acknowledgedReason`. Nothing new suppresses anything: `acknowledge()` has
always meant "the alert this prompt armed is spent", and the prompt itself
stays pending, answerable and available as Read My Mind context. A wrong label
therefore costs a card that does not blink, never an alert that was never
created.

Every surface follows from that. The broadcast carries the reason, so a live
page declines to arm the tab alert and raises no desktop notification. The push
is skipped, since a false alarm is hardest to ignore on a phone. A reloading
page reads `acknowledgedAt` in `seedApprovals()`, which it already did. And
`classifySession()` now reads it too, which is a pre-existing bug fixed here:
acknowledging on one device cleared the alert everywhere except `codeman tui`.
It re-arms for free, because the next idle prompt supersedes the item and is
built fresh. Only `idle` is eligible, so a dialog that blocks the agent still
goes red whatever else it started.

The label is pane-derived and therefore prompt-injectable, so it is now read
from the last two rows of the screen only, with Claude's pattern anchored on
the `·` its footer joins items with, ANSI-stripped and length-capped at the
source. An agent that prints `· 1 monitor ·` into its own output finds no
match.

Verified on an isolated beta: a session that armed a monitor took its idle
prompt acknowledged with no alert on any surface, wore the badge, and showed
"quiet, watching 1 monitor" on its still-answerable card; the same session with
the monitor killed alerted normally on the next prompt. `test/watching-no-alert.test.ts`
pins both directions across all four surfaces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Grundberg
2026-09-22 08:10:05 +02:00
co-authored by Claude Opus 5
parent 3f2cde2db7
commit 74884a20eb
19 changed files with 635 additions and 43 deletions
+18 -2
View File
@@ -74,13 +74,25 @@ export function isLiveRow(session: TuiSessionRow): boolean {
* outranks a stale `busy` status because the hook is the newer signal. An
* errored session has no state of its own here and joins the waiting tier,
* since it is equally something only a human can clear.
*
* ⚠️ An ACKNOWLEDGED item no longer decides the row. `acknowledgedAt` means the
* alert this prompt armed has been spent, either because somebody opened the
* session on another device or because the inbox opened the item that way for a
* session watching its own background work. The item itself stays pending and
* answerable, so the row keeps carrying it and the approval card still renders;
* it simply stops dragging the session into NEEDS YOU. The web has honoured
* that since acknowledgement existed (`approvals-ui.js` clears the pending hook
* that `_mobileOverviewState` reads), and this gate is where the TUI had been
* reading past it: acknowledging on a phone cleared the alert everywhere except
* here. Only `idle` can be acknowledged, so a permission or question dialog is
* unaffected by construction, and both are checked ahead of the flag anyway.
*/
export function classifySession(session: TuiSessionRow, approval?: ApprovalItem): TuiSessionState {
if (!isLiveRow(session)) return 'recent';
if (approval) {
if (approval.kind === 'permission') return 'blocked-permission';
if (approval.kind === 'question') return 'blocked-question';
return 'waiting';
if (!approval.acknowledgedAt) return 'waiting';
}
if (session.status === 'error') return 'waiting';
if (session.isWorking === true || session.status === 'busy') return 'working';
@@ -96,7 +108,11 @@ export function classifySession(session: TuiSessionRow, approval?: ApprovalItem)
* turn's own start is the pane's last Enter.
*/
export function stateSince(state: TuiSessionState, session: TuiSessionRow, approval?: ApprovalItem): number {
if (approval) return approval.createdAt;
// The prompt's own age measures the state only while the prompt is what put the
// row in that state. An acknowledged item still rides along on a row that is
// plainly idle or working, and dating such a row from it would report how long
// ago the prompt arrived as though it were how long the session has been quiet.
if (approval && STATE_GROUP[state] === 'needs-you') return approval.createdAt;
if (state === 'working') return session.lastSubmitAt ?? session.createdAt ?? 0;
return session.lastActivityAt ?? session.createdAt ?? 0;
}