fix(watching): close the review findings on the label and its window

A dual review (Codex CLI and Claude's code-reviewer, same diff, same brief)
found the trust boundary weaker than the comments around it claimed. Eleven
findings, all applied.

The two blockers were both about who can write the row the label is read from.
Claude's window covered two rows, and the second one is the status line, whose
command a session running with permissions bypassed can write into its own
`.claude/settings.json` — so an agent could print `· 1 monitor ·` onto a row of
its own and silence its own idle alert. The default window is one row now, which
is the footer and nothing else, and the constant says why. Separately, the label
reached `data-tab-meta-sig` unescaped while the row is installed with innerHTML,
which is an injection sink for any config-supplied pattern whose capture group is
permissive; it goes through escapeHtml() like every other untrusted string in
that file.

The Codex entry could not be fixed the same way, and now says so. Its row is
third from the bottom only while a terminal runs; with none running that slot
holds the last row of the transcript, so matching the complete row (with the
`/stop to close` tail, window narrowed to three) raises the bar without closing
it. What contains it is `hooks: 'none'`: no hook event from a codex session
reaches notePrompt(), so a forged label costs a wrong badge and cannot quiet an
alert. The registry comment, `docs/cli-registry.md` and the test all state that
rather than claiming a guarantee the code does not have.

Also from the review: the TUI header badge no longer counts an acknowledged
item, which was the same gate the classifier fix already went through and was
wrong for human acknowledgement too; the TUI approval card reads the quiet
reason and drops to a new `info` tone instead of asking for a reply; the badge
carries an aria-label, because the phone it was built for has no hover target;
the schema refuses `watchingLines` without a `watchingLine`; and the pattern and
its window are resolved together rather than one memoized and one not.

Documentation moved with it. The mechanism now lives in
`docs/architecture-invariants.md` with CLAUDE.md keeping the rule and a pointer,
`docs/wiki/Notifications-And-Approvals.md` tells users why a session stopped
buzzing, and both that page and the changeset name the limitation neither did
before: a question asked in plain prose is not a dialog, so it is silenced along
with the false alarms while background work runs.

Verified live again after the narrowing, on an isolated beta: a Claude session
reported `1 monitor` and took its idle prompt acknowledged, and a Codex session
reported `1 background terminal` against the full-row anchor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Grundberg
2026-09-22 17:11:33 +02:00
co-authored by Claude Opus 5
parent 64c288a683
commit 05c788ce9d
19 changed files with 292 additions and 59 deletions
+8
View File
@@ -323,6 +323,14 @@ const capabilitiesSchema = z
watchingLines: z.number().int().min(1).max(8).optional(),
})
.strict()
// A window with nothing to search is a typo, not a configuration. Refused at LOAD
// time for the same reason `privilegedParams[].param` is checked against the params
// the entry declares: the failure is otherwise silent and looks like a feature that
// simply never fires.
.refine(
(v) => v.watchingLines === undefined || v.watchingLine !== undefined,
'watchingLines has nothing to bound without a watchingLine'
)
.optional(),
model: z
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
+21 -10
View File
@@ -211,12 +211,15 @@ const CLAUDE: CliEntry = {
// the CLI's own words for each kind of background task, and group 1 is the one
// Codeman badges the session with. Verified against a live 2.1.278 pane on
// 2026-09-21.
// ⚠️ The leading `·` is an anchor, not decoration. This pattern runs over the foot
// of the screen, which is the one part of it the AGENT does not write, and the
// separator is what keeps it on the footer's own item list. An agent that could get
// a bare `1 monitor` matched would silence its own idle alert by printing it. A
// footer that ever carries the chip as its only item therefore reports no watching
// rather than opening that door. See `watchingLabel()` in `session-activity.ts`.
// ⚠️ Two things keep an agent from writing its own label here, and both matter.
// The footer is the LAST row, so the default one-row window (`WATCHING_TAIL_LINES`)
// holds nothing but Ink's own chrome — in particular it leaves out the status line
// directly above, whose content comes from a `statusLine` command a bypassed
// session can write into its own `.claude/settings.json`. And the leading `·` keeps
// the match on the footer's own item list rather than on any text that happens to
// carry a count. A footer that ever drew the chip as its only item would report no
// watching rather than open that door. See `watchingLabel()` in
// `session-activity.ts`.
watchingLine: String.raw`·\s*(\d+ (?:monitors?|shells?|teams?|local agents?|cloud sessions?|MCP tasks?|background tasks?|(?:background|remote) dynamic workflows?|Artifact comment monitors?))`,
},
requiresMux: false,
@@ -534,16 +537,24 @@ const CODEX: CliEntry = {
// running: ` 1 background terminal running · /ps to view · /stop to close`. Unlike
// Claude's footer chip that row sits ABOVE the composer, which puts it third from the
// bottom once the status line and the composer are counted, hence `watchingLines`.
// The ` · /ps to view` tail is the anchor: it is CLI chrome, it names a slash command
// that only the CLI can offer, and without it a bare count in the transcript would do.
// Measured against a live codex-cli 0.154.0 pane on 2026-09-22: the row appears when
// the terminal starts, follows the composer down as the conversation grows, and is
// gone after `/stop`.
// ⚠️ This entry CANNOT promise what Claude's does, and the difference is Codex's
// layout rather than its pattern. The third row from the bottom is the chip only
// while a terminal runs; with none running it is the last row of the transcript,
// which the agent writes. Matching the complete row raises the bar — an assistant
// message has to end with this exact line, to the character — but nothing here makes
// forging it impossible, so do not read the Claude comment above as applying here.
// What contains it is that codex declares `hooks: 'none'`: no hook event from a codex
// session ever reaches `notePrompt()`, so there is no idle item to pre-acknowledge
// and a forged label costs a wrong badge and nothing else. A CLI that gains hook
// signals must not keep a pattern this soft.
workDetect: {
promptGlyph: '›',
workingLine: '[Ee]sc to interrupt',
watchingLine: String.raw`(\d+ background terminals?) running · /ps to view`,
watchingLines: 4,
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
watchingLines: 3,
},
transcript: 'codex-rollout',
altScreen: 'strip-full',
+13 -11
View File
@@ -98,19 +98,21 @@ export function isPaneQuiet(lastActivityAt: number, now: number, silenceMs: numb
* How many rows at the foot of a pane capture may hold the background-work row, for a
* CLI that declares no number of its own (`capabilities.workDetect.watchingLines`).
*
* Claude Code draws its 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 rows is what
* covers the chip wherever a trailing blank or a one-line notice pushes it up by one.
* Codex pins its row above the composer instead and declares four.
* One, because the tightest window is the right default and Claude Code needs no more:
* it draws its chip on the LAST row of the screen. Blank rows are dropped before the
* window is taken, so a trailing blank costs nothing, and a CLI that ever prints a row
* BELOW its chip loses the badge rather than gaining a hole.
*
* ⚠️ The ceiling is the security boundary, not a tidiness measure. The label is
* PANE-DERIVED, and everything on that screen above the CLI's own chrome is text the
* agent wrote itself, so an agent that printed `· 1 monitor ·` into its output would
* silence its own idle alert. Keep each CLI's window as small as its layout allows,
* keep its pattern anchored on chrome only that CLI can draw, and never widen either to
* a whole-pane search.
* ⚠️ The size of this window is a trust boundary, not a tidiness measure, and the row
* it excludes first is the one that taught us so: Claude's status line sits directly
* above the footer, its content comes from a `statusLine` command, and a session running
* with permissions bypassed can write that command into `.claude/settings.json` in its
* own workspace. A window of two therefore let an agent print `· 1 monitor ·` onto a row
* of its own and silence its own idle alert. Every row added here is another row
* somebody may be able to write, so widen this only for a CLI whose layout forces it,
* and never to a whole-pane search.
*/
export const WATCHING_TAIL_LINES = 2;
export const WATCHING_TAIL_LINES = 1;
/** Longest label a badge will carry. A footer chip is a handful of words. */
export const MAX_WATCHING_LABEL_CHARS = 40;
+11 -5
View File
@@ -82,6 +82,7 @@ import {
isSustainedActivity,
isPaneQuiet,
watchingLabel,
WATCHING_TAIL_LINES,
IDLE_RECHECK_MS,
PANE_PROBE_MIN_INTERVAL_MS,
PANE_PROBE_RECHECK_MS,
@@ -517,6 +518,8 @@ export class Session extends EventEmitter {
private _workingLineRe: RegExp | undefined = undefined;
/** Lazily compiled `capabilities.workDetect.watchingLine`. See _watchingLinePattern(). */
private _watchingLineRe: RegExp | null | undefined = undefined;
/** Resolved with the pattern above: how many rows at the foot of the screen to search. */
private _watchingWindow = WATCHING_TAIL_LINES;
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
@@ -2760,9 +2763,7 @@ export class Session extends EventEmitter {
// Called only from the probe, and only with what a capture returned: `null` is
// "the screen could not be read", which is not evidence that nothing is running.
if (!pattern || paneText === null) return;
// How far up the screen this CLI's row can sit is its own business: Claude writes on
// the last row, Codex pins one above its composer. Both stay at the foot.
this._watching = watchingLabel(paneText, pattern, getCli(this.mode)?.capabilities.workDetect?.watchingLines);
this._watching = watchingLabel(paneText, pattern, this._watchingWindow);
}
/**
@@ -2773,8 +2774,13 @@ export class Session extends EventEmitter {
*/
private _watchingLinePattern(): RegExp | null {
if (this._watchingLineRe === undefined) {
const src = getCli(this.mode)?.capabilities.workDetect?.watchingLine;
this._watchingLineRe = src ? compileVersionRegex(src) : null;
// The pattern and the window it runs over are one decision, so they are resolved
// together: how far up the screen a CLI's row can sit is as much a property of its
// layout as the row itself. Claude writes on the last row and keeps the default,
// Codex pins one above its composer and declares more.
const detect = getCli(this.mode)?.capabilities.workDetect;
this._watchingLineRe = detect?.watchingLine ? compileVersionRegex(detect.watchingLine) : null;
this._watchingWindow = detect?.watchingLines ?? WATCHING_TAIL_LINES;
}
return this._watchingLineRe;
}
+18 -5
View File
@@ -28,8 +28,11 @@
import type { ApprovalItem, ApprovalOption } from '../web/approval-inbox.js';
import type { TuiApprovalAnswer } from './tui-client.js';
/** Card severity, in the same red/yellow vocabulary the web inbox uses. */
export type TuiApprovalTone = 'err' | 'warn';
/**
* Card severity, in the same red/yellow vocabulary the web inbox uses, plus the quiet
* third case: an item that opened acknowledged asks for nothing and reads grey.
*/
export type TuiApprovalTone = 'err' | 'warn' | 'info';
export interface TuiApprovalCard {
tone: TuiApprovalTone;
@@ -50,8 +53,14 @@ function clean(text: string | undefined): string {
return (text ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_CARD_TEXT);
}
/**
* How loud the card is. An idle prompt the inbox opened ALREADY acknowledged is not
* asking for anything — the session is watching work it started itself — so it drops to
* `info` and out of the warning vocabulary the other two share with the web inbox.
*/
export function approvalTone(item: ApprovalItem): TuiApprovalTone {
return item.kind === 'idle' ? 'warn' : 'err';
if (item.kind !== 'idle') return 'err';
return item.acknowledgedReason ? 'info' : 'warn';
}
/**
@@ -65,9 +74,13 @@ export function approvalCard(item: ApprovalItem): TuiApprovalCard {
const summary = clean(item.toolSummary) || clean(item.toolName);
if (item.kind === 'idle') {
// Say what it is waiting for rather than asking for a reply, in the same words the
// web drawer uses for the same item. The prompt is still answerable, so the hint
// stays either way.
const quiet = clean(item.acknowledgedReason);
return {
tone: 'warn',
title: message || 'waiting for your reply',
tone: approvalTone(item),
title: quiet ? `quiet, ${quiet}` : message || 'waiting for your reply',
detail: [],
options: [],
hint: 'p to reply',
+15 -5
View File
@@ -462,7 +462,8 @@ export function computeListWindow(
* The pending dialog, drawn above the tail: the question, the parsed options
* with their digits, and the keys that answer them. Red for a permission or
* question prompt, yellow for an idle one, the same severity vocabulary the web
* inbox uses.
* inbox uses. An idle prompt that opened acknowledged carries neither: it reads
* grey with the idle glyph, because nothing about it wants the reader.
*/
export function renderApprovalCard(
item: ApprovalItem,
@@ -472,8 +473,8 @@ export function renderApprovalCard(
): string[] {
const paint = painterFor(opts.color);
const card = approvalCard(item);
const color = card.tone === 'err' ? SGR.red : SGR.yellow;
const glyph = card.tone === 'err' ? glyphs.blockedPermission : glyphs.waiting;
const color = card.tone === 'err' ? SGR.red : card.tone === 'warn' ? SGR.yellow : SGR.gray;
const glyph = card.tone === 'err' ? glyphs.blockedPermission : card.tone === 'warn' ? glyphs.waiting : glyphs.idle;
const lines: string[] = [];
const push = (text: string, style: string): void => {
lines.push(padDisplay(paint(clipStyledLine(text, width), style), width));
@@ -571,10 +572,19 @@ function previewBody(
// Chrome
// ─────────────────────────────────────────────────────────────────────────────
/** Sessions with a prompt waiting on a human, which is what the badge counts. */
/**
* Sessions with a prompt waiting on a human, which is what the badge counts.
*
* An ACKNOWLEDGED item is not one of them. Its alert has been spent, either by somebody
* opening the session elsewhere or because the inbox opened it that way for a session
* watching its own background work, and the row has already left NEEDS YOU by the same
* flag (`classifySession`). Counting it here would put a number in the header for a
* group the reader can see is empty.
*/
export function pendingApprovalCount(model: TuiRenderModel): number {
let count = 0;
for (const group of model.groups()) for (const row of group.rows) if (row.approval) count++;
for (const group of model.groups())
for (const row of group.rows) if (row.approval && !row.approval.acknowledgedAt) count++;
return count;
}
+5 -1
View File
@@ -4662,6 +4662,10 @@ class CodemanApp {
const prev = tab.dataset.tabState;
// The since ANCHOR moves without the state changing (each new turn re-stamps
// lastSubmitAt), so key the compare on both.
// Unescaped on purpose, and it still matches the attribute the initial render wrote:
// that one goes through escapeHtml() because it is interpolated into markup, and the
// browser hands the decoded string back through `dataset`. `watching` is the only
// pane-derived value in this signature, which is why it is the only one escaped there.
const sig = `${row.state}:${row.since ? row.since.at : 0}:${row.createdAt}:${row.watching}`;
if (tab.dataset.tabMetaSig === sig) return;
tab.dataset.tabMetaSig = sig;
@@ -5281,7 +5285,7 @@ class CodemanApp {
const richMeta = this._sidebarRichMetaHTML(richRow);
const richClass = richRow ? ` tab-state-${richRow.state}` : '';
const richData = richRow
? ` data-tab-state="${richRow.state}" data-tab-meta-sig="${richRow.state}:${richRow.since ? richRow.since.at : 0}:${richRow.createdAt}:${richRow.watching}"`
? ` data-tab-state="${richRow.state}" data-tab-meta-sig="${richRow.state}:${richRow.since ? richRow.since.at : 0}:${richRow.createdAt}:${escapeHtml(richRow.watching)}"`
: '';
const inlineSessionActions = this.shouldInlineSessionActions();
+5
View File
@@ -773,7 +773,12 @@ Object.assign(CodemanApp.prototype, {
badge.className = base + ' ' + base + '--watching';
badge.setAttribute('data-i18n-skip', '');
badge.textContent = WATCHING_BADGE_TEXT;
// The label rides in BOTH, because a tooltip is desktop-only: a phone has no hover
// target, and a screen reader gets the one word either way. This is the surface the
// badge was built for first, so "watching" with no way to learn what would be the
// wrong place to save a line.
badge.title = watchingBadgeTitle(label);
badge.setAttribute('aria-label', watchingBadgeTitle(label));
return badge;
},