Skip to content

feat(approvals): let a session watching its own background work keep quiet (#468) - #473

Merged
Ark0N merged 8 commits into
Ark0N:masterfrom
irisitymichaelgrundberg:feat/session-watching-badge
Sep 23, 2026
Merged

Ark0N merged 8 commits into
Ark0N:masterfrom
irisitymichaelgrundberg:feat/session-watching-badge

Conversation

@irisitymichaelgrundberg

Copy link
Copy Markdown
Contributor

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) plus watchingLines, 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 in session-activity.ts, lands on Session.watching and rides toLightDetailedState() out. It is cached beside _lastPaneProbeWorking and goes stale with it, never derived from a capture that did not happen.

The fix is the alert that does not fire. hook-event-routes passes the label to notePrompt(), which opens the idle item already acknowledged, with a new acknowledgedReason recording 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 reads acknowledgedAt in seedApprovals() as it always did, and classifySession() now reads it too.

Two pre-existing bugs in that same gate, fixed here as you asked. classifySession() ignored acknowledgedAt, so an alert cleared on one device stayed lit in codeman tui alone; 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 close above 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 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. A CLI that gains hook signals must not keep a pattern that soft, and the registry comment, docs/cli-registry.md and 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.md says so where users meet it.

Verification

npm test green (416 files, 7907 tests), typecheck, lint, format and the frontend checks clean.

test/watching-no-alert.test.ts is 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:

  • a Claude session armed a monitor, took its idle prompt acknowledged with the reason attached, wore the badge, and alerted normally again once the monitor was killed;
  • a Codex session reported 1 background terminal against the full-row anchor and dropped it when the terminal ended;
  • with a page loaded and then left untouched, the badge appeared and later vanished within the same ten-second window the server dropped the label — before the last commit the page kept drawing it, which is what that commit fixes;
  • a restart is not a hole: a session whose terminal was still running had its label back within about twenty seconds, with no input.

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.

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
irisitymichaelgrundberg marked this pull request as ready for review September 23, 2026 06:43
@Ark0N
Ark0N merged commit 8536aae into Ark0N:master Sep 23, 2026
2 checks passed
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>
@Ark0N

Ark0N commented Sep 23, 2026

Copy link
Copy Markdown
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 pendingApprovalCount() on the way were both welcome.

Three things at merge:

  • _readWatching() now clears the label on a failed pane capture instead of keeping the last one. A stale watching could otherwise pre-acknowledge a real idle prompt at the end of a later turn, and the A session watching its own background work shows up as NEEDS YOU #468 principle is that a wrong label should fail toward a card, not toward silence. The existing watchingChanged emit tells the page, with a test.
  • The web header bell counted acknowledged items while the TUI no longer did, which would have become common with every monitor-armed session. It now counts the same thing, so the bell hides when everything pending is acknowledged.
  • The "Pill copy per state" JSDoc is back above the constant it describes.

I also resolved a small conflict with #469 in the Codex entry of stock.ts; both the watching fields and transcriptGutter are there.

@github-actions github-actions Bot mentioned this pull request Sep 23, 2026
@irisitymichaelgrundberg
irisitymichaelgrundberg deleted the feat/session-watching-badge branch September 23, 2026 11:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants