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
+33 -16
View File
@@ -21,6 +21,8 @@
* in 12/12 windows and the four idle ones in 0/12.
*/
import { stripAnsi } from './utils/regex-patterns.js';
/**
* A gap longer than this ends a run of continuous output. Claude repaints at
* least once a second while working, so this leaves generous headroom.
@@ -95,13 +97,21 @@ export function isPaneQuiet(lastActivityAt: number, now: number, silenceMs: numb
/**
* How many lines at the foot of a pane capture may hold the background-work chip.
*
* Claude Code draws that chip on the last row of the screen, under its composer box
* and under whatever status line the user configured, so five lines reach it with
* room to spare. The ceiling is the point of the constant: the transcript above the
* composer quotes arbitrary text, and a session that PRINTS the words "1 monitor"
* must not be read as running one.
* Claude Code draws that chip on the last row of the screen. The row above it is the
* status line, which a user's own `statusLine` command writes, and two lines is what
* covers the chip wherever a trailing blank or a one-line notice pushes it up by one.
*
* ⚠️ The ceiling is the security boundary, not a tidiness measure. The label is
* PANE-DERIVED, so everything on that screen above the footer is text an agent wrote
* itself, and an agent that printed `· 1 monitor ·` into its own output would silence
* its own idle alert. Keep the window at the footer, keep each CLI's pattern anchored
* on the separator its footer actually uses, and never widen this to a whole-pane
* search.
*/
export const WATCHING_TAIL_LINES = 5;
export const WATCHING_TAIL_LINES = 2;
/** Longest label a badge will carry. A footer chip is a handful of words. */
export const MAX_WATCHING_LABEL_CHARS = 40;
/**
* What a pane says is still running in the background, e.g. `1 monitor` or `2 shells`.
@@ -112,21 +122,28 @@ export const WATCHING_TAIL_LINES = 5;
* entry (`capabilities.workDetect.watchingLine`); group 1 is the label when the pattern
* declares one, and the whole match stands in when it does not.
*
* Each candidate line is tested on its own, bottom row first, so a pattern can anchor
* itself with `^` or `$` against a single row rather than against a joined block. The
* answer is stripped of ANSI and capped, because it ends up on a badge and in an
* approval card.
*
* @returns the label, or null when the pane shows no background work
*/
export function watchingLabel(paneText: string | null | undefined, pattern: RegExp): string | null {
if (!paneText) return null;
const lines = paneText
const lines = stripAnsi(paneText)
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line !== '');
if (lines.length === 0) return null;
// A pattern compiled by compileVersionRegex() never carries the `g` flag, but a caller
// reaching in from a test or a config reload might, and a stale lastIndex would make
// the same screen match every other call.
pattern.lastIndex = 0;
const match = pattern.exec(lines.slice(-WATCHING_TAIL_LINES).join('\n'));
if (!match) return null;
const label = (match[1] ?? match[0]).trim();
return label === '' ? null : label;
for (const line of lines.slice(-WATCHING_TAIL_LINES).reverse()) {
// A pattern compiled by compileVersionRegex() never carries the `g` flag, but a
// caller reaching in from a test or a config reload might, and a stale lastIndex
// would make the same screen match every other call.
pattern.lastIndex = 0;
const match = pattern.exec(line);
if (!match) continue;
const label = (match[1] ?? match[0]).trim().slice(0, MAX_WATCHING_LABEL_CHARS);
if (label) return label;
}
return null;
}