feat(session): say when a session is watching its own background work

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>
This commit is contained in:
Michael Grundberg
2026-09-21 18:00:30 +02:00
co-authored by Claude Opus 5
parent 9466acfc1a
commit 3f2cde2db7
18 changed files with 590 additions and 5 deletions
+39
View File
@@ -91,3 +91,42 @@ export function isSustainedActivity(streak: ActivityStreak | null, streakMs: num
export function isPaneQuiet(lastActivityAt: number, now: number, silenceMs: number = IDLE_SILENCE_MS): boolean {
return now - lastActivityAt >= silenceMs;
}
/**
* 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.
*/
export const WATCHING_TAIL_LINES = 5;
/**
* What a pane says is still running in the background, e.g. `1 monitor` or `2 shells`.
*
* The CLI writes that chip while a monitor, a backgrounded shell or a cloud session it
* started is still going, which is exactly the case where the agent has ended its turn
* without wanting anything from the user. `pattern` comes from the CLI's own registry
* entry (`capabilities.workDetect.watchingLine`); group 1 is the label when the pattern
* declares one, and the whole match stands in when it does not.
*
* @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
.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;
}