feat(approvals): let a session watching its own background work keep quiet (#468) - #473
Merged
Ark0N merged 8 commits intoSep 23, 2026
Conversation
An agent that arms a monitor, backgrounds a shell or hands a task to a cloud session is told to end its turn. The pane then falls quiet, Claude Code's idle_prompt notification arrives a minute later, and every surface files the session under NEEDS YOU with nothing for a human to answer. Claude states what it is still running on the last row of its screen (`⏵⏵ bypass permissions on · 1 monitor · ← for agents`). That row is now `capabilities.workDetect.watchingLine` in the CLI registry, guarded by compileVersionRegex() like every other config regex, and the idle probe reads it off the capture it already takes: `watchingLabel()` in session-activity.ts searches the last five lines only, so a session that PRINTS "1 monitor" is not mistaken for one running it. The label lands on Session.watching and rides toLightDetailedState() out to every surface. The phone overview, the desktop home rail and the rich sidebar rows wear it as a `watching` badge in the accent colour, beside the state pill and never in place of it: an agent can arm a monitor and ask a question in the same breath, and only the pill says which. Verified end to end against a throwaway session on an isolated beta instance: the payload carried `watching: "1 monitor"` once the turn ended, the badge rendered next to a yellow `waiting` pill, and both cleared when the monitor died. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Codex states background work too, and it says so in a different place. Claude writes `· 1 monitor ·` on the last row of the screen; Codex pins `1 background terminal running · /ps to view · /stop to close` ABOVE its composer, which puts that row third from the bottom once the status line and the composer are counted. So how far up the screen to look is now per-CLI data as well: `capabilities.workDetect.watchingLines`, bounded to 1..8 by the schema, and defaulting to Claude's two. That bound is the point. The window is half the injection guard, since every row it adds is another row the agent itself may be able to write, and the label is what silences an idle alert. The other half is the anchor, and Codex's is ` · /ps to view`: chrome naming a slash command only the CLI can offer, so a session that writes "I left 1 background terminal running for you" into its own output matches nothing. Measured against a live codex-cli 0.154.0 pane rather than read out of a binary. The row appears when the terminal starts, follows the composer down as the conversation grows, and is gone after `/stop`. Verified end to end on an isolated beta: the session payload carried `watching: "1 background terminal"` and the badge rendered with it, and both cleared when the terminal stopped. The fixtures in the tests are that capture verbatim. Codex has no hook signals, so no idle prompt and no false NEEDS YOU row: for a Codex session this is the badge alone, which is the case the maintainer said a registry field could cover and a hook never could. Cross-CLI tests pin that neither pattern fires on the other's screen, and that a CLI declaring nothing still reports nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Measured on the same beta, codex-cli 0.154.0: a sub-agent started without waiting outlives the turn exactly as a background terminal does — the sandboxed process was still running — and codex shows nothing for it. The last rows of the pane are the composer and the status line, and `Sub-agents running` belongs to the on-demand `/subagents` panel rather than to the row above the composer. So there is no second codex label to add. A codex session waiting on a sub-agent reads as plainly idle, which misfiles nothing (codex raises no idle prompts) and simply leaves that one kind of quiet unexplained until codex pins a row for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported from a manual test: a Codex session went on showing the watching badge after its background terminal had finished. The server was right and the page was stale — `Session.watching` changes while the session's status does not, and nothing broadcast it. The label is usually SET on the idle transition, which broadcasts anyway, so the badge always appeared correctly. It CLEARS when the work ends, and a CLI can end background work without taking a turn: codex repaints its background-terminal row away and stays idle, so `_confirmIdle()` concludes without emitting `idle` (that emit is guarded by `wasWorking || isInitialReady`) and no other event fires. Every open page kept drawing a badge the server had already dropped. `_readWatching()` now emits `watchingChanged` when, and only when, the label really changes, and the wiring pushes the session state on it. No new SSE event: the badge reads off the session payload every surface already has. A/B measured on an isolated beta with the page loaded and then left untouched. Without this commit the server dropped the label at t+50s and the page still showed the badge at t+100s; with it, page and server cleared in the same ten-second window. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claimed after a manual test that a session comes back from a server restart without its badge until it next produces output. Measured instead of assumed, and it is wrong: a codex session with a background terminal still running had its label back within about 20 seconds of the restart, with no input from anyone. Reconciliation re-attaches the pane, the attach repaint carries the composer glyph, the idle confirmation arms on it, and the probe re-reads the label — the ordinary path, doing the ordinary thing. What produced the false claim was a session whose monitor had simply expired while it sat there. Its footer carries no chip, so `watching: null` was the right answer and there was nothing missing to restore. Recorded at the field and in the invariants, because the shape of this invites exactly one wrong fix: a polling timer to keep a value fresh that the pane already refreshes by itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CONTRIBUTING says releases are handled by the maintainer via changesets after merge, and every `.changeset/*.md` on master was written by him or by the release bot — including the ones covering other people's pull requests. The summary this file carried moves to the pull-request description, where it is the maintainer's to reuse or rewrite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
irisitymichaelgrundberg
marked this pull request as ready for review
September 23, 2026 06:43
Ark0N
pushed a commit
that referenced
this pull request
Sep 23, 2026
- session.ts: a pane capture that fails now CLEARS the watching label (and emits watchingChanged so pages drop the badge) instead of keeping the last one, so a failed capture degrades toward an alert rather than pre-acknowledging the next real idle prompt. Test updated; invariant noted in architecture-invariants. - approvals-ui.js: the header bell counts only unacknowledged items (pendingApprovalsCount), matching codeman tui's pendingApprovalCount(); pinned in watching-no-alert.test.ts. - mobile-overview.js: move the orphaned "Pill copy per state" JSDoc back onto MOBILE_OVERVIEW_PILL_LABEL. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Owner
|
Merged, thanks @irisitymichaelgrundberg! This ships in 1.32.1. This is the shape we agreed in #468 in every detail: read from the screen rather than a hook, a badge rather than a fifth state, the CLI's own string on the wire, and the acknowledgement path rather than a new suppression. The negative test and fixing the TUI's Three things at merge:
I also resolved a small conflict with #469 in the Codex entry of stock.ts; both the watching fields and |
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements #468, in the shape you laid out there: the screen rather than a hook, a badge rather than a fifth state, the CLI's own string on the wire, the badge only where a state pill already is, and the acknowledgement path rather than a new suppression.
What it does
An agent that arms a monitor, backgrounds a shell or starts a background terminal is told to end its turn. The pane goes quiet, the idle notification lands a minute later, and the session shows up under NEEDS YOU with nothing to answer. The CLI says which kind of quiet it is on its own screen, so that row is now registry data and the idle probe reads it.
The signal.
capabilities.workDetect.watchingLine(optional, per CLI) pluswatchingLines, which says how many non-blank rows at the foot of the screen may hold it._confirmIdle()already captures the pane when a turn ends, so the label comes off that same capture through a pure helper insession-activity.ts, lands onSession.watchingand ridestoLightDetailedState()out. It is cached beside_lastPaneProbeWorkingand goes stale with it, never derived from a capture that did not happen.The fix is the alert that does not fire.
hook-event-routespasses the label tonotePrompt(), which opens the idle item already acknowledged, with a newacknowledgedReasonrecording why. Nothing new suppresses anything — the prompt stays pending, answerable and Read My Mind context, so a wrong label costs a card that does not blink rather than an alert that was never created. Everything follows from that flag: the broadcast carries the reason so a live page declines to arm, the push is skipped, a reloading page readsacknowledgedAtinseedApprovals()as it always did, andclassifySession()now reads it too.Two pre-existing bugs in that same gate, fixed here as you asked.
classifySession()ignoredacknowledgedAt, so an alert cleared on one device stayed lit incodeman tuialone;pendingApprovalCount()had the same gap, so the header counted a session the list no longer showed. The TUI card reads the reason as well, in the drawer's own words.The badge.
watching, in the accent colour, beside the state pill on the phone overview, the desktop home rail and the rich sidebar and rail rows — never in place of the pill, because an agent can arm a monitor and ask a question in the same breath. Nothing is added to the header tab strip or the plain sidebar.Two CLIs, and they are not equally safe
Claude writes its chip on the last row (
· 1 monitor ·, also shells, cloud sessions, background tasks), so the default one-row window holds only Ink's own chrome — not even the status line above it, whose command a bypassed session can write into its own.claude/settings.json. That row was inside the window in an earlier revision of this branch, which is exactly the hole you warned about.Codex pins
1 background terminal running · /ps to view · /stop to closeabove its composer, and that slot holds the last row of the transcript whenever no terminal is running. Matching the complete row raises the bar without closing it, so the entry says so rather than claiming otherwise. What contains it ishooks: 'none': no hook event from a codex session reachesnotePrompt(), so a forged label costs a wrong badge and cannot quiet an alert. A CLI that gains hook signals must not keep a pattern that soft, and the registry comment,docs/cli-registry.mdand a test all state it.Both patterns were measured against live panes. A codex sub-agent started without waiting outlives its turn the same way, and codex shows nothing on screen for it, so there is no second label to add; that measurement is recorded where the next person will look for it.
What it deliberately does not do
A prose question is not a dialog. An agent that arms a monitor and then writes "which branch should I target?" is silenced along with the false alarms until the background work ends. A permission prompt or a question dialog still goes red whatever else the agent started.
docs/wiki/Notifications-And-Approvals.mdsays so where users meet it.Verification
npm testgreen (416 files, 7907 tests), typecheck, lint, format and the frontend checks clean.test/watching-no-alert.test.tsis the negative one you asked for: a watching session with a pending idle item raises no alert on the store, the TUI classifier, the live SSE handler, the reload seed or the home-screen classifier, while the same session with a permission item still goes red. The pane fixtures elsewhere are verbatim captures.Measured end to end on an isolated beta, not only in tests:
1 background terminalagainst the full-row anchor and dropped it when the terminal ended;A two-model review (Codex CLI and Claude, same diff, same brief) raised eleven findings and all are applied — the two that mattered were the window reaching the status line and the label reaching an HTML attribute unescaped.
No changeset: CONTRIBUTING says those are yours after merge, and the summary above is there to reuse.