mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #473 from irisitymichaelgrundberg/feat/session-watching-badge
feat(approvals): let a session watching its own background work keep quiet (#468) # Conflicts: # src/config/cli-registry/stock.ts
This commit is contained in:
File diff suppressed because one or more lines are too long
+39
-2
@@ -36,7 +36,8 @@ interface CliEntry {
|
||||
launch: CliLaunch; // the structured argv template
|
||||
env: CliEnv; // exports, tmux setenv keys, the env-override allowlist
|
||||
capabilities: CliCapabilities; // what every call site reads instead of the id
|
||||
// .workDetect?: { promptGlyph, workingLine } — how this CLI's pane shows work
|
||||
// .workDetect?: { promptGlyph, workingLine, watchingLine?, watchingLines? } — how
|
||||
// this CLI's pane shows work, and how it shows work it started in the background
|
||||
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
|
||||
}
|
||||
```
|
||||
@@ -45,10 +46,46 @@ interface CliEntry {
|
||||
|
||||
### Regexes that come from config
|
||||
|
||||
Two capability fields carry a regular expression an override file can set: `discovery.version.regex` and `capabilities.workDetect.workingLine`. Both go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
||||
Three capability fields carry a regular expression an override file can set: `discovery.version.regex`, `capabilities.workDetect.workingLine` and `capabilities.workDetect.watchingLine`. All three go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
|
||||
|
||||
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
|
||||
|
||||
`watchingLine` reads a different row of the same screen. A CLI draws it while work the agent
|
||||
itself started is still running — Claude prints `⏵⏵ bypass permissions on · 1 monitor · ← for
|
||||
agents` while a monitor, a backgrounded shell or a cloud session is live. Codeman turns that
|
||||
into `Session.watching`, and an idle prompt from such a session opens already acknowledged,
|
||||
so a pane waiting for its own background work never raises an alert a human cannot answer.
|
||||
Group 1 is the label, and a CLI that declares no pattern reports no background work.
|
||||
|
||||
Two CLIs declare such a row today, and they put it in different places. Claude writes its
|
||||
chip on the last row of the screen, so it keeps the default one-row window and anchors on
|
||||
the `·` its footer joins items with. Codex pins
|
||||
`1 background terminal running · /ps to view · /stop to close` ABOVE its composer, which
|
||||
puts the row third from the bottom once the status line and the composer are counted, so its
|
||||
entry declares `watchingLines: 3` and matches that row end to end. Both were measured
|
||||
against live panes rather than read out of a binary, which is the standard for adding a
|
||||
third.
|
||||
|
||||
That label is the one value in the registry that an AGENT can influence, because it comes off
|
||||
the agent's own screen. Two things keep it honest, and both belong to whoever adds a pattern
|
||||
for a new CLI. `watchingLabel()` in `session-activity.ts` searches only the last few
|
||||
non-blank rows, which should be the part of the screen the CLI draws rather than the agent,
|
||||
and the pattern should anchor on chrome only that CLI can produce. Keep the window as small
|
||||
as the layout allows, since every row it adds is another row the agent may be able to write.
|
||||
The label is also ANSI-stripped and length-capped at the source, and every interpolation of
|
||||
it into markup goes through `escapeHtml()`, since it ends up on a badge and in an approval
|
||||
card.
|
||||
|
||||
The two shipped entries do not sit equally well behind that rule, and the difference decides
|
||||
what a pattern is allowed to do. Claude's chip is the last row, so its one-row window holds
|
||||
nothing the agent can write — not even the status line above it, whose command a session
|
||||
running with permissions bypassed can write into its own `.claude/settings.json`. Codex's row
|
||||
shares its slot with the last row of the transcript whenever no terminal is running, so a
|
||||
message ending in that exact line is matched. What keeps that harmless is `hooks: 'none'`: no
|
||||
hook event from a codex session reaches the approvals inbox, so a forged label costs a wrong
|
||||
badge and cannot silence an alert. Before giving a CLI both hook signals and a pattern, make
|
||||
sure its row is one the agent cannot write.
|
||||
|
||||
### Three capabilities that must stay independent
|
||||
|
||||
`external`, `hooks` and `altScreen` describe three different, deliberately unequal sets, and deriving any one from another has already shipped a bug. `shell` has no hooks but is **not** an external CLI, so a hooks predicate written as `!isExternalCliMode()` accepted `until=stop` on a shell session and then blocked the caller for their entire timeout. `deepseek` is the mirror image: it IS external and it DOES have hooks.
|
||||
|
||||
@@ -103,6 +103,30 @@ locked phone and the agent continues.
|
||||
With the inbox off, the buttons are stripped from the notification payload entirely rather
|
||||
than being shown and failing.
|
||||
|
||||
## When a session is watching its own work
|
||||
|
||||
An agent that starts a monitor, puts a shell in the background or hands a task to a cloud
|
||||
session is told by its CLI to end the turn and wait to be notified. The pane then goes
|
||||
quiet, and the CLI's idle notification arrives about a minute later — for a session that
|
||||
wants nothing from you.
|
||||
|
||||
Codeman reads what the CLI prints about its own background work and treats that prompt
|
||||
differently. It raises no tab alert, no desktop notification and no push, the session stays
|
||||
out of NEEDS YOU on every surface, and the row wears a blue **watching** badge instead. Hover
|
||||
it, or read it on a phone through your screen reader, and it says what is running: "1
|
||||
monitor", "2 shells", "1 background terminal".
|
||||
|
||||
The prompt itself is not thrown away. It sits in the Approvals drawer as an ordinary card,
|
||||
still answerable, with a line reading "quiet, watching 1 monitor" where a card you had
|
||||
already looked at would say nothing. The next time that session goes quiet for an ordinary
|
||||
reason, it alerts you exactly as before.
|
||||
|
||||
Two limits are worth knowing. A permission prompt or a question dialog still goes red
|
||||
whatever else the agent started, because that one blocks it outright. A question asked in
|
||||
plain prose is not a dialog, so an agent that starts a monitor and then writes "which branch
|
||||
should I target?" is quiet along with the rest — check a watching session yourself if it has
|
||||
been quiet longer than the work it is waiting for should take.
|
||||
|
||||
## The phone overview
|
||||
|
||||
On phones, tapping the "C" logo gives a session overview with **NEEDS YOU** first, then
|
||||
|
||||
@@ -325,8 +325,29 @@ const capabilitiesSchema = z
|
||||
(src) => compileVersionRegex(src) !== null,
|
||||
'workingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
|
||||
),
|
||||
// Same guard, same reasons: this one runs over the foot of a pane capture every
|
||||
// time a session settles, and ~/.codeman/clis.json can set it.
|
||||
watchingLine: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(src) => compileVersionRegex(src) !== null,
|
||||
'watchingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
|
||||
)
|
||||
.optional(),
|
||||
// Bounded hard: this is how far up the screen a config file may push the search,
|
||||
// and every row it adds is one more row the agent itself may be able to write.
|
||||
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() })
|
||||
|
||||
@@ -220,6 +220,21 @@ const CLAUDE: CliEntry = {
|
||||
workDetect: {
|
||||
promptGlyph: '❯',
|
||||
workingLine: String.raw`…\s*\((?:\d+h\s+)?(?:\d+m\s+)?\d+s\b|esc to interrupt`,
|
||||
// Claude prints what it started in the background on the footer row beneath its
|
||||
// composer, as `⏵⏵ bypass permissions on · 1 monitor · ← for agents`. The labels are
|
||||
// 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.
|
||||
// ⚠️ 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,
|
||||
// Claude installs Codeman's own hooks block into every workspace it runs in, so its
|
||||
@@ -532,7 +547,37 @@ const CODEX: CliEntry = {
|
||||
// `Working (2m 49s • esc to interrupt)` above it while a turn runs. It animates no
|
||||
// braille spinner, and it never prints `esc to interrupt` at rest, so that phrase
|
||||
// alone separates a running turn from an idle one.
|
||||
workDetect: { promptGlyph: '›', workingLine: '[Ee]sc to interrupt' },
|
||||
// Codex pins a row of its own while a background terminal it started is still
|
||||
// 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`.
|
||||
// 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.
|
||||
// ⚠️ Background TERMINALS are the only background work codex advertises on screen.
|
||||
// A sub-agent started without waiting outlives the turn just as a terminal does —
|
||||
// measured 2026-09-22, the sandboxed process was still running — and the pane shows
|
||||
// nothing at all for it: the last rows are the composer and the status line, and
|
||||
// `Sub-agents running` lives in the on-demand `/subagents` panel, not above the
|
||||
// composer. So a codex session waiting on a sub-agent reads as plainly idle here.
|
||||
// Nothing is misfiled by that (codex raises no idle prompts), and there is no row to
|
||||
// match until codex pins one.
|
||||
workDetect: {
|
||||
promptGlyph: '›',
|
||||
workingLine: '[Ee]sc to interrupt',
|
||||
watchingLine: String.raw`^\s{0,4}(\d+ background terminals?) running · /ps to view · /stop to close$`,
|
||||
watchingLines: 3,
|
||||
},
|
||||
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
|
||||
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
|
||||
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
|
||||
|
||||
@@ -344,6 +344,24 @@ export interface CliCapabilities {
|
||||
promptGlyph: string;
|
||||
/** Source of a regex matching the status line this CLI draws while a turn runs. */
|
||||
workingLine: string;
|
||||
/**
|
||||
* Source of a regex matching the row this CLI draws while work it started in the
|
||||
* background is still running, e.g. Claude's `· 1 monitor ·` footer chip or Codex's
|
||||
* `1 background terminal running · /ps to view`. Capture group 1 is the label Codeman
|
||||
* shows, and the whole match stands in when the pattern declares no group. A CLI that
|
||||
* omits this reports no background work, which is what every CLI did before the field
|
||||
* existed.
|
||||
*/
|
||||
watchingLine?: string;
|
||||
/**
|
||||
* How many rows at the FOOT of the screen that row can appear in, counting non-blank
|
||||
* rows only. Claude writes its chip on the last row and keeps the default; Codex pins
|
||||
* its own above the composer, which puts it third from the bottom, so it declares
|
||||
* more. Keep each number as small as that CLI's layout allows: every extra row is
|
||||
* another row an agent might be able to write, and the label is what silences an
|
||||
* alert. See `watchingLabel()` in `session-activity.ts`.
|
||||
*/
|
||||
watchingLines?: number;
|
||||
};
|
||||
/**
|
||||
* How many columns this CLI indents its transcript body by, so a copy taken from its
|
||||
|
||||
@@ -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.
|
||||
@@ -91,3 +93,68 @@ 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 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`).
|
||||
*
|
||||
* 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 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 = 1;
|
||||
|
||||
/** 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`.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Each candidate row 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. Blank
|
||||
* rows are dropped before the window is taken, because a CLI that leaves a blank line
|
||||
* between its chrome rows would otherwise spend the window on nothing. The answer is
|
||||
* stripped of ANSI and capped, because it ends up on a badge and in an approval card.
|
||||
*
|
||||
* @param tailLines how many non-blank rows from the bottom to look at, defaulting to
|
||||
* `WATCHING_TAIL_LINES`; a CLI declares its own when its row is not the last one
|
||||
* @returns the label, or null when the pane shows no background work
|
||||
*/
|
||||
export function watchingLabel(
|
||||
paneText: string | null | undefined,
|
||||
pattern: RegExp,
|
||||
tailLines: number = WATCHING_TAIL_LINES
|
||||
): string | null {
|
||||
if (!paneText) return null;
|
||||
const lines = stripAnsi(paneText)
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line !== '');
|
||||
for (const line of lines.slice(-Math.max(1, tailLines)).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;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,8 @@ import {
|
||||
trackActivityStreak,
|
||||
isSustainedActivity,
|
||||
isPaneQuiet,
|
||||
watchingLabel,
|
||||
WATCHING_TAIL_LINES,
|
||||
IDLE_RECHECK_MS,
|
||||
PANE_PROBE_MIN_INTERVAL_MS,
|
||||
PANE_PROBE_RECHECK_MS,
|
||||
@@ -500,8 +502,35 @@ export class Session extends EventEmitter {
|
||||
private _activityStreak: ActivityStreak | null = null; // Unbroken run of PTY repaints (working detection)
|
||||
private _lastPaneProbeAt = 0; // Throttle for the tmux screen probe
|
||||
private _lastPaneProbeWorking: boolean | null = null; // Its last verdict (null = could not read)
|
||||
/**
|
||||
* Background work the pane's own footer reports, e.g. `1 monitor`; null for none.
|
||||
*
|
||||
* Cached BESIDE `_lastPaneProbeWorking` and refreshed only by a capture that really
|
||||
* happened, so it goes stale exactly as that verdict does. The probe returns its
|
||||
* cached boolean without re-capturing inside `PANE_PROBE_MIN_INTERVAL_MS`, and a
|
||||
* label derived from a capture nobody took would be a guess wearing a fact's clothes.
|
||||
*
|
||||
* ⚠️ It then FREEZES once `_confirmIdle()` concludes: `activityTimeout` is null from
|
||||
* there, and nothing looks at the pane again until it produces output. That is
|
||||
* correct rather than merely tolerable, because work ending repaints the pane either
|
||||
* way — a monitor firing wakes the agent, and codex drops its background-terminal row
|
||||
* on its own. Do not add a timer to keep this fresh; it would spend a `capture-pane`
|
||||
* per idle session per tick to learn nothing.
|
||||
*
|
||||
* A server restart is not a hole in that either, though it looks like one: this field
|
||||
* is live state and starts empty. Reconciliation re-attaches the pane, the attach
|
||||
* repaint carries the composer glyph, and the idle confirmation that arms on it probes
|
||||
* and re-reads the label with no input from anyone — measured 2026-09-23 on a restarted
|
||||
* instance, back within ~20 s for a session whose background terminal was still
|
||||
* running. A session that comes back with no label has no chip on its screen.
|
||||
*/
|
||||
private _watching: string | null = null;
|
||||
/** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */
|
||||
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
|
||||
@@ -1218,6 +1247,16 @@ export class Session extends EventEmitter {
|
||||
return this._isWorking;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the pane says is still running in the background, e.g. `1 monitor`, or null when
|
||||
* nothing is. A session with a label here has ended its turn without wanting anything
|
||||
* from the user, so a surface that would otherwise file it under "needs you" can say
|
||||
* what it is waiting for instead.
|
||||
*/
|
||||
get watching(): string | null {
|
||||
return this._watching;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the session's process tree has active child processes beyond Claude itself.
|
||||
* Detects running bash tools, test suites, builds, servers, etc. that Claude spawned.
|
||||
@@ -1779,6 +1818,7 @@ export class Session extends EventEmitter {
|
||||
totalCost: this._totalCost,
|
||||
messageCount: this._messages.length,
|
||||
isWorking: this._isWorking,
|
||||
watching: this._watching,
|
||||
lastPromptTime: this._lastPromptTime,
|
||||
// Buffer statistics for monitoring long-running sessions
|
||||
bufferStats: {
|
||||
@@ -2937,9 +2977,60 @@ export class Session extends EventEmitter {
|
||||
this._lastPaneProbeAt = now;
|
||||
const text = this._mux.capturePaneText?.(this._muxSession.muxName) ?? null;
|
||||
this._lastPaneProbeWorking = text === null ? null : this._workingLinePattern().test(text);
|
||||
this._readWatching(text);
|
||||
return this._lastPaneProbeWorking;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the background-work chip off the same capture the working probe just took.
|
||||
*
|
||||
* The two questions are different. A turn that is running is work the user is waiting
|
||||
* for; a monitor, a backgrounded shell or a cloud session the agent started is work
|
||||
* the AGENT is waiting for, and it is the reason a pane can sit at its composer with
|
||||
* nothing to say and still not want anything from the user. `_confirmIdle` takes this
|
||||
* capture at exactly the moment the turn ends, which is the moment the answer starts
|
||||
* mattering.
|
||||
*
|
||||
* A capture that could not be read leaves the last answer standing, the way the
|
||||
* working probe treats its own null: no evidence is not evidence of none.
|
||||
*/
|
||||
private _readWatching(paneText: string | null): void {
|
||||
const pattern = this._watchingLinePattern();
|
||||
// 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;
|
||||
const label = watchingLabel(paneText, pattern, this._watchingWindow);
|
||||
if (label === this._watching) return;
|
||||
this._watching = label;
|
||||
// ⚠️ This CHANGES while the session's status does not, so it needs an event of its
|
||||
// own. The label is usually set on the idle transition, which broadcasts anyway, but
|
||||
// it CLEARS when the work ends — and for a CLI whose background work ends without
|
||||
// taking a turn (measured on codex: a background terminal finishing repaints the row
|
||||
// away and nothing else happens) the session is idle before and after. Without this,
|
||||
// the server knew the badge was gone and every open page went on drawing it until
|
||||
// some unrelated event arrived.
|
||||
this.emit('watchingChanged');
|
||||
}
|
||||
|
||||
/**
|
||||
* The regex matching this CLI's background-work chip, or null for a CLI whose registry
|
||||
* entry declares none. Compiled once per session, like the working-line pattern, and
|
||||
* null rather than a fallback: no other CLI has been measured drawing such a chip, and
|
||||
* guessing one would badge sessions on the strength of an unread screen.
|
||||
*/
|
||||
private _watchingLinePattern(): RegExp | null {
|
||||
if (this._watchingLineRe === undefined) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The regex matching this CLI's "a turn is running" status line.
|
||||
*
|
||||
@@ -4191,6 +4282,9 @@ export class Session extends EventEmitter {
|
||||
async stop(killMux: boolean = true): Promise<void> {
|
||||
// Set stopped flag first to prevent new timers from being created
|
||||
this._isStopped = true;
|
||||
// A pane that is gone is watching nothing. Nothing probes a stopped session, so
|
||||
// without this the last chip it drew would ride along on its row forever.
|
||||
this._watching = null;
|
||||
|
||||
this._clearAllTimers();
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
+18
-2
@@ -74,13 +74,25 @@ export function isLiveRow(session: TuiSessionRow): boolean {
|
||||
* outranks a stale `busy` status because the hook is the newer signal. An
|
||||
* errored session has no state of its own here and joins the waiting tier,
|
||||
* since it is equally something only a human can clear.
|
||||
*
|
||||
* ⚠️ An ACKNOWLEDGED item no longer decides the row. `acknowledgedAt` means the
|
||||
* alert this prompt armed has been spent, either because somebody opened the
|
||||
* session on another device or because the inbox opened the item that way for a
|
||||
* session watching its own background work. The item itself stays pending and
|
||||
* answerable, so the row keeps carrying it and the approval card still renders;
|
||||
* it simply stops dragging the session into NEEDS YOU. The web has honoured
|
||||
* that since acknowledgement existed (`approvals-ui.js` clears the pending hook
|
||||
* that `_mobileOverviewState` reads), and this gate is where the TUI had been
|
||||
* reading past it: acknowledging on a phone cleared the alert everywhere except
|
||||
* here. Only `idle` can be acknowledged, so a permission or question dialog is
|
||||
* unaffected by construction, and both are checked ahead of the flag anyway.
|
||||
*/
|
||||
export function classifySession(session: TuiSessionRow, approval?: ApprovalItem): TuiSessionState {
|
||||
if (!isLiveRow(session)) return 'recent';
|
||||
if (approval) {
|
||||
if (approval.kind === 'permission') return 'blocked-permission';
|
||||
if (approval.kind === 'question') return 'blocked-question';
|
||||
return 'waiting';
|
||||
if (!approval.acknowledgedAt) return 'waiting';
|
||||
}
|
||||
if (session.status === 'error') return 'waiting';
|
||||
if (session.isWorking === true || session.status === 'busy') return 'working';
|
||||
@@ -96,7 +108,11 @@ export function classifySession(session: TuiSessionRow, approval?: ApprovalItem)
|
||||
* turn's own start is the pane's last Enter.
|
||||
*/
|
||||
export function stateSince(state: TuiSessionState, session: TuiSessionRow, approval?: ApprovalItem): number {
|
||||
if (approval) return approval.createdAt;
|
||||
// The prompt's own age measures the state only while the prompt is what put the
|
||||
// row in that state. An acknowledged item still rides along on a row that is
|
||||
// plainly idle or working, and dating such a row from it would report how long
|
||||
// ago the prompt arrived as though it were how long the session has been quiet.
|
||||
if (approval && STATE_GROUP[state] === 'needs-you') return approval.createdAt;
|
||||
if (state === 'working') return session.lastSubmitAt ?? session.createdAt ?? 0;
|
||||
return session.lastActivityAt ?? session.createdAt ?? 0;
|
||||
}
|
||||
|
||||
+15
-5
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,15 @@ export interface ApprovalItem {
|
||||
* and reach the user's other devices. See `acknowledge()`.
|
||||
*/
|
||||
acknowledgedAt?: number;
|
||||
/**
|
||||
* Why the item arrived already acknowledged, for display only: the inbox
|
||||
* writes `watching 1 monitor` for a session that went quiet because work it
|
||||
* started itself is still running. A human acknowledgement leaves this unset,
|
||||
* so a card can say "quiet, watching 1 monitor" rather than implying somebody
|
||||
* looked. ⚠️ Pane-derived text, so it is bounded at the source and must not
|
||||
* reach the DOM as markup — see `watchingLabel()` in `session-activity.ts`.
|
||||
*/
|
||||
acknowledgedReason?: string;
|
||||
/**
|
||||
* Present only when the frame parsed confidently. Gates which digits the
|
||||
* answer endpoint accepts; absent → only approve('1')/deny(Esc) are allowed.
|
||||
@@ -93,6 +102,12 @@ interface NotePromptArgs {
|
||||
toolSummary?: string;
|
||||
message?: string;
|
||||
cwd?: string;
|
||||
/**
|
||||
* What the session's pane says is still running in the background
|
||||
* (`Session.watching`, e.g. `1 monitor`). An idle prompt from such a session
|
||||
* opens ALREADY acknowledged: see `notePrompt()`.
|
||||
*/
|
||||
watching?: string | null;
|
||||
/** Returns the raw (ANSI-bearing) pane frame, or null when unavailable. */
|
||||
capture?: () => string | null;
|
||||
}
|
||||
@@ -217,6 +232,22 @@ export class ApprovalInbox {
|
||||
* Record a prompt for a session, superseding any previous item, and return
|
||||
* the new item. Captures context immediately and once more after a short
|
||||
* delay (see RECAPTURE_DELAY_MS).
|
||||
*
|
||||
* ⚠️ An idle prompt from a session that is WATCHING its own background work
|
||||
* opens already acknowledged (`args.watching`). Claude Code ends the turn
|
||||
* after arming a monitor or backgrounding a shell and then reports the pane
|
||||
* idle a minute later, so the alert that follows asks a human to look at a
|
||||
* session that wants nothing from them. Acknowledging is deliberately what
|
||||
* happens here rather than skipping the item: the prompt is real and stays
|
||||
* pending, answerable and available as Read My Mind context, and only the
|
||||
* alert it would have armed is spent. A wrong label therefore costs a card
|
||||
* that does not blink, never an alert that was never created.
|
||||
*
|
||||
* It re-arms by itself. The next idle prompt supersedes this item and builds
|
||||
* a fresh one, so once the background work ends and the session goes quiet
|
||||
* for an ordinary reason, that item carries no acknowledgement and alerts
|
||||
* normally. Only `idle` is eligible: a permission or question dialog blocks
|
||||
* the agent whatever else it started, so its alert must survive.
|
||||
*/
|
||||
notePrompt(args: NotePromptArgs): ApprovalItem {
|
||||
this.resolveForSession(args.sessionId, 'superseded');
|
||||
@@ -231,6 +262,10 @@ export class ApprovalInbox {
|
||||
message: args.message,
|
||||
cwd: args.cwd,
|
||||
};
|
||||
if (args.kind === 'idle' && args.watching) {
|
||||
item.acknowledgedAt = item.createdAt;
|
||||
item.acknowledgedReason = `watching ${args.watching}`;
|
||||
}
|
||||
this.applyCapture(item, args.capture);
|
||||
this.items.set(args.sessionId, item);
|
||||
if (args.capture) this.captures.set(args.sessionId, args.capture);
|
||||
|
||||
+22
-2
@@ -4623,6 +4623,11 @@ class CodemanApp {
|
||||
return {
|
||||
state,
|
||||
pill: this._sidebarRichPillLabel(state),
|
||||
// What the pane's own footer says is still running in the background ("1 monitor",
|
||||
// "2 shells"). A row that has one went quiet because the agent is waiting for that,
|
||||
// which is a different thing from waiting for the user — so it rides BESIDE the
|
||||
// state pill and never replaces it.
|
||||
watching: typeof session.watching === 'string' ? session.watching : '',
|
||||
createdAt: Number(session.createdAt) || 0,
|
||||
since: this._mobileOverviewSince ? this._mobileOverviewSince(state, session) : null,
|
||||
};
|
||||
@@ -4658,6 +4663,17 @@ class CodemanApp {
|
||||
parts.push(stamp(row.since.key, row.since.at, 'for', 'tab-meta-since'));
|
||||
}
|
||||
parts.push(`<span class="tab-pill tab-pill--${escapeHtml(row.state)}">${escapeHtml(row.pill)}</span>`);
|
||||
// The word is duplicated from mobile-overview.js for the same reason the pill labels
|
||||
// above are: it is one word, and this file must render a complete row even when a
|
||||
// stale cached mobile-overview.js has arrived without it.
|
||||
// The visible text is that constant. The pane-derived label appears only in the
|
||||
// tooltip, where escapeHtml() (which escapes both quote characters) is what this file
|
||||
// already relies on for every untrusted string it puts in an attribute, and where the
|
||||
// source caps it at MAX_WATCHING_LABEL_CHARS before it ever gets here.
|
||||
if (row.watching) {
|
||||
const title = escapeHtml(`Still running in the background: ${row.watching}`);
|
||||
parts.push(`<span class="tab-pill tab-pill--watching" title="${title}">watching</span>`);
|
||||
}
|
||||
// Both absolute stamps ALSO on the line itself, not only on the two items.
|
||||
// Below 288px the rail hides `.tab-meta-created` (the `tab-rail-tight`
|
||||
// rule), and a tooltip on a `display: none` element has no hover target —
|
||||
@@ -4695,7 +4711,11 @@ 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.
|
||||
const sig = `${row.state}:${row.since ? row.since.at : 0}:${row.createdAt}`;
|
||||
// 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;
|
||||
tab.dataset.tabState = row.state;
|
||||
@@ -5319,7 +5339,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}"`
|
||||
? ` data-tab-state="${richRow.state}" data-tab-meta-sig="${richRow.state}:${richRow.since ? richRow.since.at : 0}:${richRow.createdAt}:${escapeHtml(richRow.watching)}"`
|
||||
: '';
|
||||
|
||||
// '' whenever the server said nothing about this pane's agent, which covers
|
||||
|
||||
@@ -222,6 +222,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
list.innerHTML = items.map((item) => this._approvalCardHtml(item)).join('');
|
||||
// The quiet reason is the only pane-derived string on a card, and it is the one
|
||||
// an agent could write itself (it prints its own footer row), so it reaches the
|
||||
// DOM as text and never as markup. The card leaves an empty span for it.
|
||||
for (const item of items) {
|
||||
if (!item.acknowledgedReason) continue;
|
||||
const card = list.querySelector(`[data-approval-id="${CSS.escape(item.id)}"]`);
|
||||
const slot = card && card.querySelector('.approval-quiet');
|
||||
if (slot) slot.textContent = 'quiet, ' + item.acknowledgedReason;
|
||||
}
|
||||
},
|
||||
|
||||
_approvalCardHtml(item) {
|
||||
@@ -260,6 +269,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
`<span class="approval-session" data-i18n-skip>${escapeHtml(item.sessionName || item.sessionId.slice(0, 8))}</span>` +
|
||||
`<span class="approval-age" data-i18n-skip>${age}</span>` +
|
||||
`</div>` +
|
||||
// Filled by renderApprovalsDrawer through textContent, never here: see the note
|
||||
// there. An item a human acknowledged carries no reason and gets no line.
|
||||
(item.acknowledgedReason ? `<div class="approval-quiet" data-i18n-skip></div>` : '') +
|
||||
(summary ? `<div class="approval-summary" data-i18n-skip>${escapeHtml(summary)}</div>` : '') +
|
||||
(item.context ? `<pre class="approval-context">${escapeHtml(item.context)}</pre>` : '') +
|
||||
`<div class="approval-actions">${actions}</div>` +
|
||||
|
||||
@@ -212,6 +212,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
|
||||
state,
|
||||
pill: HOME_SESSIONS_PILL_LABEL[state] || state,
|
||||
// What the pane's footer says is still running in the background, straight off
|
||||
// the session payload. Same field, same meaning as on the phone overview.
|
||||
watching: typeof session.watching === 'string' ? session.watching : '',
|
||||
// Epoch ms, straight off the session payload; formatting happens at
|
||||
// render time so the clock below can redo it without a re-render.
|
||||
createdAt: Number(session.createdAt) || 0,
|
||||
@@ -450,6 +453,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
// what stops it ellipsizing.
|
||||
const meta = this._buildHomeSessionsMeta(row);
|
||||
meta.appendChild(pill);
|
||||
// Built by the phone overview so both home screens word the badge identically.
|
||||
// Guarded like every other cross-file call here: a stale cached mobile-overview.js
|
||||
// must cost the badge, not the rail.
|
||||
if (row.watching && typeof this._buildWatchingBadge === 'function') {
|
||||
meta.appendChild(this._buildWatchingBadge(row.watching, 'home-sessions-pill'));
|
||||
}
|
||||
item.appendChild(meta);
|
||||
|
||||
return item;
|
||||
|
||||
@@ -61,6 +61,14 @@ const MOBILE_OVERVIEW_RUN_MODES = [
|
||||
];
|
||||
|
||||
/** Pill copy per state. Kept short: a phone row has ~90px for it. */
|
||||
/**
|
||||
* The one word every surface puts on the watching badge, and the tooltip that says
|
||||
* what the pane actually reported. Both live here so the phone overview, the desktop
|
||||
* home rail and the rich sidebar rows cannot word the same badge three ways.
|
||||
*/
|
||||
const WATCHING_BADGE_TEXT = 'watching';
|
||||
const watchingBadgeTitle = (label) => 'Still running in the background: ' + label;
|
||||
|
||||
const MOBILE_OVERVIEW_PILL_LABEL = {
|
||||
needs: 'needs you',
|
||||
error: 'error',
|
||||
@@ -185,6 +193,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
dir: this._shortenHomePath ? this._shortenHomePath(session.workingDir) : session.workingDir || '',
|
||||
state,
|
||||
pill: MOBILE_OVERVIEW_PILL_LABEL[state] || state,
|
||||
// What the pane's own footer says is still running in the background ("1 monitor",
|
||||
// "2 shells"), straight off the session payload. A row that has one is quiet
|
||||
// because the agent is waiting for that, not because it is waiting for you.
|
||||
watching: typeof session.watching === 'string' ? session.watching : '',
|
||||
// Epoch ms, straight off the session payload; formatting happens at
|
||||
// render time so the clock can redo it without a re-render.
|
||||
createdAt: Number(session.createdAt) || 0,
|
||||
@@ -713,6 +725,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
pill.textContent = row.pill;
|
||||
item.appendChild(pill);
|
||||
|
||||
if (row.watching) item.appendChild(this._buildWatchingBadge(row.watching, 'mobile-overview-pill'));
|
||||
|
||||
const chevron = document.createElement('span');
|
||||
chevron.className = 'mobile-overview-chevron';
|
||||
chevron.setAttribute('aria-hidden', 'true');
|
||||
@@ -734,6 +748,40 @@ Object.assign(CodemanApp.prototype, {
|
||||
return item;
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Watching badge
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* The badge a session wears while work it started in the background is still
|
||||
* running: a monitor, a backgrounded shell, a cloud session.
|
||||
*
|
||||
* It says one word, and the label the pane itself printed ("1 monitor") rides in
|
||||
* the tooltip, because the badge shares a row with the state pill on the narrowest
|
||||
* screen this app renders on. It does NOT replace that pill: an agent can arm a
|
||||
* monitor and ask the user a question in the same breath, so the row still says
|
||||
* "needs you" and this says what else is going on.
|
||||
*
|
||||
* Shared with the desktop home rail (home-sessions.js), for the same reason
|
||||
* `_mobileOverviewState` is: one badge, one wording, one place to change it. The
|
||||
* caller names its own pill class, because each surface styles its pills itself and
|
||||
* the phone's live inside a media query the desktop never enters.
|
||||
*/
|
||||
_buildWatchingBadge(label, baseClass) {
|
||||
const badge = document.createElement('span');
|
||||
const base = baseClass || 'mobile-overview-pill';
|
||||
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;
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Age stamps: started / how long in this state
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -2977,6 +2977,13 @@ html.mobile-init .file-browser-panel {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* Accent, and none of the three above: a session watching work it started itself
|
||||
is not asking the user for anything, and red and yellow are what say it is. */
|
||||
.mobile-overview-pill--watching {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mobile-overview-chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -14,6 +14,14 @@
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// Hooks (Claude Code hook events)
|
||||
_onHookIdlePrompt(data) {
|
||||
// A prompt the server opened ALREADY acknowledged raises no alert here. Today that
|
||||
// means the session is watching work it started itself (`acknowledgedReason` reads
|
||||
// "watching 1 monitor"), so the pane is quiet because the agent is waiting for its
|
||||
// own monitor, not for you. The item still exists and still shows in the drawer;
|
||||
// only the tab alert and the desktop notification are declined. A page that reloads
|
||||
// instead of receiving this event reaches the same conclusion from `acknowledgedAt`
|
||||
// in seedApprovals (approvals-ui.js).
|
||||
if (data.acknowledgedReason) return;
|
||||
// Always track pending hook - alert will show when switching away from session
|
||||
if (data.sessionId) {
|
||||
this.setPendingHook(data.sessionId, 'idle_prompt');
|
||||
|
||||
@@ -12427,6 +12427,17 @@ kbd {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
/* Why this card is not blinking at anyone: the session is watching work it
|
||||
started itself. Accent, like the watching badge on a session row, and never
|
||||
the red or yellow that mean a human is needed. */
|
||||
.approval-quiet {
|
||||
margin-bottom: 6px;
|
||||
color: var(--accent);
|
||||
font-size: 0.68rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.approval-summary {
|
||||
color: var(--text);
|
||||
font-size: 0.76rem;
|
||||
@@ -16314,6 +16325,17 @@ html[data-tab-orientation='vertical'] .home-sessions {
|
||||
color: color-mix(in srgb, var(--green) 45%, var(--text-muted));
|
||||
}
|
||||
|
||||
/* Accent, deliberately none of the three above: a session that is watching something
|
||||
it started (a monitor, a backgrounded shell, a cloud session) is not asking for
|
||||
anything, so it must not borrow the red or the yellow that mean it is. This badge
|
||||
sits BESIDE the state pill rather than replacing it, since an agent can arm a
|
||||
monitor and ask a question in the same breath. */
|
||||
.home-sessions-pill--watching {
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Row accents: same language as the session tabs and the phone overview — red
|
||||
means a question is pending, yellow means it wants input, green means work is
|
||||
happening. Nothing else on this screen may reuse these colors. */
|
||||
@@ -18376,6 +18398,16 @@ html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail
|
||||
color: color-mix(in srgb, var(--green) 45%, var(--text-muted));
|
||||
}
|
||||
|
||||
/* Accent, deliberately none of the three above: a watching session is waiting for
|
||||
work it started itself, not for the user, and the red and yellow here are spoken
|
||||
for by sessions that ARE waiting for the user. */
|
||||
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--watching,
|
||||
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--watching {
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Three lines of content per row instead of two, so give them room to breathe
|
||||
and stop the row actions crowding the pill.
|
||||
|
||||
|
||||
@@ -176,6 +176,12 @@ export function registerHookEventRoutes(
|
||||
// identity beyond the shared per-instance secret, so a prompt claimed for a
|
||||
// session that can never show one must not create an answerable item).
|
||||
let approvalId: string | undefined;
|
||||
// Set when the item opened ALREADY acknowledged, which today means the session is
|
||||
// watching work it started itself. It rides the broadcast so a live page declines to
|
||||
// arm the alert (a reloading page learns the same thing from `acknowledgedAt` when it
|
||||
// seeds from /api/approvals), and it suppresses the push: an alert nobody can answer
|
||||
// is worth even less on a phone than in a tab.
|
||||
let acknowledgedReason: string | undefined;
|
||||
const approvalKind = APPROVAL_KIND_BY_EVENT[event];
|
||||
if (session && hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
|
||||
if (approvalKind) {
|
||||
@@ -194,6 +200,10 @@ export function registerHookEventRoutes(
|
||||
toolSummary: typeof toolSummary === 'string' ? toolSummary : undefined,
|
||||
message: typeof safeData.message === 'string' ? safeData.message : undefined,
|
||||
cwd: typeof safeData.cwd === 'string' ? safeData.cwd : undefined,
|
||||
// What the pane says is still running in the background. An idle prompt from a
|
||||
// session that is watching its own work opens acknowledged, so it never arms an
|
||||
// alert nobody can answer; notePrompt() carries the whole reasoning.
|
||||
watching: session.watching,
|
||||
// Visible tmux frame first (it IS the dialog); raw byte-buffer tail as
|
||||
// the fallback for direct-PTY sessions and the no-op test mux.
|
||||
capture: () => {
|
||||
@@ -203,6 +213,7 @@ export function registerHookEventRoutes(
|
||||
},
|
||||
});
|
||||
approvalId = item.id;
|
||||
acknowledgedReason = item.acknowledgedReason;
|
||||
} else if (APPROVAL_RESOLVING_EVENTS.has(event)) {
|
||||
approvalInbox.resolveForSession(sessionId, 'resolved_in_terminal');
|
||||
}
|
||||
@@ -213,6 +224,7 @@ export function registerHookEventRoutes(
|
||||
timestamp: Date.now(),
|
||||
...safeData,
|
||||
...(approvalId && { approvalId }),
|
||||
...(acknowledgedReason && { acknowledgedReason }),
|
||||
});
|
||||
// Full state ride-along, same shape as the working/idle handlers: the home
|
||||
// screens rank the blocked group on lastActivityAt, and without this a
|
||||
@@ -224,12 +236,17 @@ export function registerHookEventRoutes(
|
||||
// on approvalId, and the answer route refuses keystrokes for dsh dialogs
|
||||
// (third-party TUI, unmeasured contract) — so a dsh push stays a plain
|
||||
// notification instead of offering buttons whose answer would be refused.
|
||||
// Nothing to push for a prompt that opened acknowledged: the agent is waiting for its
|
||||
// own monitor or backgrounded shell, and a phone buzzing about it is the same false
|
||||
// alarm as the tab alert, delivered where it is hardest to ignore.
|
||||
if (!acknowledgedReason) {
|
||||
ctx.sendPushNotifications(`hook:${event}`, {
|
||||
sessionId,
|
||||
sessionName,
|
||||
...safeData,
|
||||
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
|
||||
});
|
||||
}
|
||||
|
||||
// Track in run summary. `prompt_submitted` fires on EVERY prompt of every
|
||||
// Claude pane; only the ones where the conversation actually moved (a /clear
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface SessionListenerRefs {
|
||||
exit: (code: number | null) => void;
|
||||
working: () => void;
|
||||
idle: () => void;
|
||||
watchingChanged: () => void;
|
||||
taskCreated: (task: BackgroundTask) => void;
|
||||
taskUpdated: (task: BackgroundTask) => void;
|
||||
taskCompleted: (task: BackgroundTask) => void;
|
||||
@@ -263,6 +264,17 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Pushes the session state when `Session.watching` changes without the status
|
||||
* changing with it. That is the badge appearing or, more often, going away: a CLI can
|
||||
* finish its background work without taking a turn, so the row is idle before and
|
||||
* after and no other broadcast fires. There is no SSE event of its own, because the
|
||||
* badge reads off the session payload every surface already has.
|
||||
*/
|
||||
watchingChanged: () => {
|
||||
deps.broadcastSessionStateDebounced(session.id);
|
||||
},
|
||||
|
||||
// ─── Background Task Events ──────────────────────────────
|
||||
|
||||
/** Broadcasts `task:created` — new background task discovered */
|
||||
@@ -495,6 +507,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.on('exit', refs.exit);
|
||||
session.on('working', refs.working);
|
||||
session.on('idle', refs.idle);
|
||||
session.on('watchingChanged', refs.watchingChanged);
|
||||
session.on('taskCreated', refs.taskCreated);
|
||||
session.on('taskUpdated', refs.taskUpdated);
|
||||
session.on('taskCompleted', refs.taskCompleted);
|
||||
@@ -531,6 +544,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
|
||||
session.off('exit', refs.exit);
|
||||
session.off('working', refs.working);
|
||||
session.off('idle', refs.idle);
|
||||
session.off('watchingChanged', refs.watchingChanged);
|
||||
session.off('taskCreated', refs.taskCreated);
|
||||
session.off('taskUpdated', refs.taskUpdated);
|
||||
session.off('taskCompleted', refs.taskCompleted);
|
||||
|
||||
@@ -330,6 +330,58 @@ describe('ApprovalInbox', () => {
|
||||
expect(inbox.getById(second.id)).toBeDefined();
|
||||
});
|
||||
|
||||
describe('a session watching its own background work', () => {
|
||||
it('opens its idle prompt already acknowledged, and says why', () => {
|
||||
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle', watching: '1 monitor' });
|
||||
expect(item.acknowledgedAt).toBe(item.createdAt);
|
||||
expect(item.acknowledgedReason).toBe('watching 1 monitor');
|
||||
});
|
||||
|
||||
it('keeps the prompt pending and answerable: only its alert is spent', () => {
|
||||
// Acknowledging rather than skipping creation is what makes a wrong label cheap.
|
||||
// The prompt is real either way, and this way it is still in the drawer, still
|
||||
// answerable and still Read My Mind context.
|
||||
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle', watching: '2 shells' });
|
||||
expect(inbox.listPending().map((i) => i.id)).toEqual([item.id]);
|
||||
expect(inbox.getForSession('s1')?.id).toBe(item.id);
|
||||
expect(inbox.verifyStillAnswerable(item.id)).toBe(true);
|
||||
});
|
||||
|
||||
it('never pre-acknowledges a dialog that blocks the agent', () => {
|
||||
// A permission or question dialog blocks the turn whatever else the agent started,
|
||||
// so watching says nothing about whether a human is needed.
|
||||
for (const kind of ['permission', 'question'] as const) {
|
||||
const item = inbox.notePrompt({ sessionId: `s-${kind}`, sessionName: 'w1', kind, watching: '1 monitor' });
|
||||
expect(item.acknowledgedAt).toBeUndefined();
|
||||
expect(item.acknowledgedReason).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves an ordinary idle prompt alone', () => {
|
||||
for (const watching of [undefined, null, '']) {
|
||||
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle', watching });
|
||||
expect(item.acknowledgedAt).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('re-arms by itself once the background work is over', () => {
|
||||
// The next prompt supersedes this one and is built fresh, so nothing has to
|
||||
// remember to clear the flag when the monitor ends.
|
||||
const watched = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle', watching: '1 monitor' });
|
||||
expect(watched.acknowledgedAt).toBeDefined();
|
||||
const after = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle' });
|
||||
expect(after.id).not.toBe(watched.id);
|
||||
expect(after.acknowledgedAt).toBeUndefined();
|
||||
expect(after.acknowledgedReason).toBeUndefined();
|
||||
});
|
||||
|
||||
it('cannot be acknowledged a second time by a human opening the session', () => {
|
||||
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'idle', watching: '1 monitor' });
|
||||
expect(inbox.acknowledge('s1')).toBeUndefined();
|
||||
expect(inbox.getById(item.id)?.acknowledgedReason).toBe('watching 1 monitor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyStillAnswerable', () => {
|
||||
it('resolves the item and refuses when a parsed dialog left the screen', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
|
||||
@@ -122,6 +122,49 @@ describe('workDetect.workingLine is guarded like every other config regex', () =
|
||||
expect(compileVersionRegex(src), `${entry.id} declares a workingLine the guard refuses`).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('holds the optional watchingLine to the same guard', () => {
|
||||
expectRejected((e) => {
|
||||
(e.capabilities as Record<string, unknown>).workDetect = {
|
||||
promptGlyph: '>',
|
||||
workingLine: 'working',
|
||||
watchingLine: '(a+)+b',
|
||||
};
|
||||
}, 'this one runs over a pane capture every time a session settles, so it can freeze the event loop the same way');
|
||||
});
|
||||
|
||||
it('bounds how far up the screen a config file may search', () => {
|
||||
// The window is the injection guard: every row it adds is another row the agent
|
||||
// itself may be able to write, and the label is what silences an idle alert.
|
||||
for (const lines of [0, 9, 2.5]) {
|
||||
expectRejected((e) => {
|
||||
(e.capabilities as Record<string, unknown>).workDetect = {
|
||||
promptGlyph: '>',
|
||||
workingLine: 'working',
|
||||
watchingLine: 'chip (\\d+)',
|
||||
watchingLines: lines,
|
||||
};
|
||||
}, 'a config file must not be able to widen the search to the whole pane');
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a window with no pattern to bound', () => {
|
||||
expectRejected((e) => {
|
||||
(e.capabilities as Record<string, unknown>).workDetect = {
|
||||
promptGlyph: '>',
|
||||
workingLine: 'working',
|
||||
watchingLines: 3,
|
||||
};
|
||||
}, 'a window with nothing to search is a typo whose failure is otherwise silent');
|
||||
});
|
||||
|
||||
it('accepts every shipped watchingLine', () => {
|
||||
for (const entry of STOCK_CLIS) {
|
||||
const src = entry.capabilities.workDetect?.watchingLine;
|
||||
if (!src) continue;
|
||||
expect(compileVersionRegex(src), `${entry.id} declares a watchingLine the guard refuses`).not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('no shell text can reach the command line', () => {
|
||||
|
||||
@@ -356,3 +356,50 @@ describe('home screens: one order, one numbering', () => {
|
||||
expect(branch).not.toContain('this.sessionOrder[idx]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('home sessions column: watching badge', () => {
|
||||
it('carries the label off the session payload', () => {
|
||||
const app = loadHomeSessionsApp({
|
||||
sessions: sessionMap([{ id: 'watcher', watching: '1 monitor' }, { id: 'plain' }]),
|
||||
sessionOrder: ['watcher', 'plain'],
|
||||
cases: CASES,
|
||||
});
|
||||
|
||||
const rows = Object.fromEntries(app.buildHomeSessionRows().map((r: any) => [r.id, r.watching]));
|
||||
expect(rows).toEqual({ watcher: '1 monitor', plain: '' });
|
||||
});
|
||||
|
||||
it("builds the badge with the phone overview, in the rail's own pill class", () => {
|
||||
// Both home screens must word this badge identically, so the rail borrows the
|
||||
// builder rather than writing a second one — the same arrangement it already has
|
||||
// for state classification and for the stamp wording.
|
||||
const app = loadHomeSessionsApp({
|
||||
sessions: sessionMap([{ id: 'watcher', watching: '2 shells' }]),
|
||||
sessionOrder: ['watcher'],
|
||||
cases: CASES,
|
||||
});
|
||||
|
||||
const row = app._buildHomeSessionRow(app.buildHomeSessionRows()[0]);
|
||||
const badges = collect(row).filter((el: any) => String(el.className).includes('--watching'));
|
||||
expect(badges).toHaveLength(1);
|
||||
expect(badges[0].className).toBe('home-sessions-pill home-sessions-pill--watching');
|
||||
expect(badges[0].textContent).toBe('watching');
|
||||
expect(badges[0].title).toBe('Still running in the background: 2 shells');
|
||||
});
|
||||
|
||||
it('draws no badge for a session running nothing in the background', () => {
|
||||
const app = loadHomeSessionsApp({
|
||||
sessions: sessionMap([{ id: 'plain' }]),
|
||||
sessionOrder: ['plain'],
|
||||
cases: CASES,
|
||||
});
|
||||
|
||||
const row = app._buildHomeSessionRow(app.buildHomeSessionRows()[0]);
|
||||
expect(collect(row).filter((el: any) => String(el.className).includes('--watching'))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
/** Every node under one the builders made, the row itself included. */
|
||||
function collect(node: any): any[] {
|
||||
return [node, ...(node.children || []).flatMap((child: any) => collect(child))];
|
||||
}
|
||||
|
||||
@@ -19,8 +19,11 @@ function fakeElement(): any {
|
||||
type: '',
|
||||
dataset: {},
|
||||
style: {},
|
||||
attrs: {} as Record<string, string>,
|
||||
children: [] as any[],
|
||||
setAttribute() {},
|
||||
setAttribute(name: string, value: string) {
|
||||
el.attrs[name] = value;
|
||||
},
|
||||
appendChild(child: any) {
|
||||
el.children.push(child);
|
||||
return child;
|
||||
@@ -485,3 +488,53 @@ describe('mobile overview run picker (CLI availability gating)', () => {
|
||||
expect(gate).toContain('isCliAvailable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobile overview watching badge', () => {
|
||||
it('carries what the pane says is running in the background', () => {
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: [session({ id: 'a', status: 'idle', watching: '1 monitor' }), session({ id: 'b', status: 'idle' })],
|
||||
cases: CASES,
|
||||
});
|
||||
|
||||
const rows = Object.fromEntries(model.current.map((r: any) => [r.id, r.watching]));
|
||||
expect(rows).toEqual({ a: '1 monitor', b: '' });
|
||||
});
|
||||
|
||||
it('leaves a session that is ALSO blocked on a dialog in NEEDS YOU', () => {
|
||||
// An agent can arm a monitor and ask the user a question in the same breath, so the
|
||||
// badge adds a fact to the row and never moves it out of the group that says a human
|
||||
// is needed. Only the row's own pill decides that.
|
||||
const app = loadOverviewApp();
|
||||
const model = app.buildMobileOverviewModel({
|
||||
sessions: [session({ id: 'a', status: 'idle', watching: '2 shells' })],
|
||||
cases: CASES,
|
||||
pendingHooks: new Map([['a', new Set(['permission_prompt'])]]),
|
||||
});
|
||||
|
||||
expect(model.needsYou.map((r: any) => r.id)).toEqual(['a']);
|
||||
expect(model.needsYou[0].pill).toBe('needs you');
|
||||
expect(model.needsYou[0].watching).toBe('2 shells');
|
||||
});
|
||||
|
||||
it('says one word and puts the detail where every surface can reach it', () => {
|
||||
const app = loadOverviewApp();
|
||||
const badge = app._buildWatchingBadge('1 monitor', 'mobile-overview-pill');
|
||||
|
||||
expect(badge.className).toBe('mobile-overview-pill mobile-overview-pill--watching');
|
||||
expect(badge.textContent).toBe('watching');
|
||||
expect(badge.title).toBe('Still running in the background: 1 monitor');
|
||||
// A phone has no hover target and a screen reader reads neither the class nor the
|
||||
// tooltip, so the label has to be here too or this surface says only "watching".
|
||||
expect(badge.attrs['aria-label']).toBe('Still running in the background: 1 monitor');
|
||||
});
|
||||
|
||||
it('takes the pill class of whichever surface asks for it', () => {
|
||||
// The phone's pill styles live inside a media query the desktop rail never enters,
|
||||
// so the rail passes its own base class and gets the same badge in its own clothes.
|
||||
const app = loadOverviewApp();
|
||||
expect(app._buildWatchingBadge('1 shell', 'home-sessions-pill').className).toBe(
|
||||
'home-sessions-pill home-sessions-pill--watching'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,12 @@ export class MockSession extends EventEmitter {
|
||||
/** `null` once the PTY is gone (or before it has ever started) — see `pid` in Session. */
|
||||
pid: number | null = 12345;
|
||||
isWorking: boolean = false;
|
||||
/**
|
||||
* Mirrors Session.watching — what the pane's footer says is still running in the
|
||||
* background. An idle prompt from such a session opens acknowledged, so the routes
|
||||
* need to be able to set it.
|
||||
*/
|
||||
watching: string | null = null;
|
||||
private _activeChildProcesses: { pid: number; command: string }[] = [];
|
||||
ralphTracker: null = null;
|
||||
writeBuffer: string[] = [];
|
||||
|
||||
@@ -325,6 +325,65 @@ describe('approval routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('an idle prompt from a session watching its own background work', () => {
|
||||
beforeEach(() => {
|
||||
session.terminalBuffer = 'claude> waiting at the composer';
|
||||
session.watching = '1 monitor';
|
||||
});
|
||||
|
||||
it('opens acknowledged, so no surface has an alert to raise', async () => {
|
||||
await postHook(harness, 'idle_prompt', { message: 'Claude is waiting for your input' });
|
||||
const [item] = await listApprovals(harness);
|
||||
expect(item).toMatchObject({ kind: 'idle', acknowledgedReason: 'watching 1 monitor' });
|
||||
expect(item.acknowledgedAt).toEqual(expect.any(Number));
|
||||
});
|
||||
|
||||
it('tells a live page why, so it declines to arm the alert', async () => {
|
||||
await postHook(harness, 'idle_prompt', {});
|
||||
const broadcast = harness.ctx.broadcast.mock.calls.find((c) => c[0] === 'hook:idle_prompt');
|
||||
expect(broadcast?.[1]).toMatchObject({ acknowledgedReason: 'watching 1 monitor' });
|
||||
});
|
||||
|
||||
it('sends no push', async () => {
|
||||
// The loudest surface, and the one a false alarm is hardest to ignore on.
|
||||
await postHook(harness, 'idle_prompt', {});
|
||||
expect(harness.ctx.sendPushNotifications.mock.calls.find((c) => c[0] === 'hook:idle_prompt')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stays answerable from the drawer', async () => {
|
||||
await postHook(harness, 'idle_prompt', {});
|
||||
const [item] = await listApprovals(harness);
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/approvals/${item.id}/answer`,
|
||||
payload: { action: 'text', text: 'carry on' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(session.writeBuffer.join('')).toContain('carry on');
|
||||
});
|
||||
|
||||
it('still raises a permission dialog from the same session', async () => {
|
||||
// Watching says nothing about a dialog: that one blocks the agent outright.
|
||||
session.terminalBuffer = PERMISSION_DIALOG;
|
||||
await postHook(harness, 'permission_prompt', { tool_name: 'Bash' });
|
||||
const [item] = await listApprovals(harness);
|
||||
expect(item.acknowledgedAt).toBeUndefined();
|
||||
expect(item.acknowledgedReason).toBeUndefined();
|
||||
const broadcast = harness.ctx.broadcast.mock.calls.find((c) => c[0] === 'hook:permission_prompt');
|
||||
expect(broadcast?.[1]).not.toMatchObject({ acknowledgedReason: expect.any(String) });
|
||||
expect(harness.ctx.sendPushNotifications.mock.calls.find((c) => c[0] === 'hook:permission_prompt')).toBeDefined();
|
||||
});
|
||||
|
||||
it('alerts normally again once the background work is over', async () => {
|
||||
await postHook(harness, 'idle_prompt', {});
|
||||
session.watching = null;
|
||||
await postHook(harness, 'idle_prompt', {});
|
||||
const [item] = await listApprovals(harness);
|
||||
expect(item.acknowledgedAt).toBeUndefined();
|
||||
expect(harness.ctx.sendPushNotifications.mock.calls.filter((c) => c[0] === 'hook:idle_prompt')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('viewing a session acknowledges its idle prompt (item stays pending) and broadcasts it', async () => {
|
||||
session.terminalBuffer = 'claude> waiting at the composer';
|
||||
await postHook(harness, 'idle_prompt', {});
|
||||
|
||||
@@ -22,6 +22,21 @@ describe('session listener wiring', () => {
|
||||
expect(registerAttachment).toHaveBeenNthCalledWith(2, 'wiring-attach-source-test', '/tmp/report.pdf', 'external');
|
||||
});
|
||||
|
||||
it('pushes the session state when the watching label changes on its own', () => {
|
||||
// The badge appears on the idle transition, which broadcasts anyway. It goes AWAY
|
||||
// when the background work ends, and a CLI can do that without taking a turn — codex
|
||||
// repaints its background-terminal row away and stays idle — so nothing else fires
|
||||
// and every open page would keep drawing a badge the server had already dropped.
|
||||
const session = new Session({ id: 'wiring-watching-test', workingDir: '/tmp', mode: 'codex' });
|
||||
const broadcastSessionStateDebounced = vi.fn();
|
||||
const deps = { broadcastSessionStateDebounced } as unknown as Parameters<typeof createSessionListeners>[1];
|
||||
|
||||
const refs = createSessionListeners(session, deps);
|
||||
refs.watchingChanged();
|
||||
|
||||
expect(broadcastSessionStateDebounced).toHaveBeenCalledWith('wiring-watching-test');
|
||||
});
|
||||
|
||||
/** The listener reads the setting asynchronously; let its promise chain settle. */
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
|
||||
@@ -73,3 +73,53 @@ describe('vertical session navigation UX contract', () => {
|
||||
expect(i18n).toContain("'Adjust only session names in the vertical sidebar.':");
|
||||
});
|
||||
});
|
||||
|
||||
describe('watching badge on a rich session row', () => {
|
||||
it('reads the label off the session payload', () => {
|
||||
expect(app).toContain("watching: typeof session.watching === 'string' ? session.watching : ''");
|
||||
});
|
||||
|
||||
it('renders it beside the state pill rather than in place of it', () => {
|
||||
// A session can be watching a monitor AND holding a question for the user, so the
|
||||
// pill that says which one still decides the row; this badge only adds a fact.
|
||||
const meta = app.slice(app.indexOf('_sidebarRichMetaHTML(row) {'));
|
||||
const body = meta.slice(0, meta.indexOf('_sidebarRichStampText(timestamp, format) {'));
|
||||
expect(body).toContain('tab-pill tab-pill--${escapeHtml(row.state)}');
|
||||
expect(body).toContain('tab-pill tab-pill--watching');
|
||||
expect(body).toContain('Still running in the background:');
|
||||
});
|
||||
|
||||
it('re-renders the row when the background work changes', () => {
|
||||
// The meta line is rebuilt only when this signature moves, so a badge left out of
|
||||
// it would appear and disappear a render late, or not at all.
|
||||
expect(app).toContain(
|
||||
'const sig = `${row.state}:${row.since ? row.since.at : 0}:${row.createdAt}:${row.watching}`'
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes the label everywhere it reaches markup', () => {
|
||||
// `watching` is pane-derived and a config-supplied pattern decides what its capture
|
||||
// group holds, so every interpolation of it into HTML has to go through escapeHtml().
|
||||
// The row is installed with innerHTML, which makes an unescaped quote in that
|
||||
// attribute an injection rather than a cosmetic bug.
|
||||
expect(app).toContain('${richRow.createdAt}:${escapeHtml(richRow.watching)}"');
|
||||
expect(app).not.toContain('${richRow.createdAt}:${richRow.watching}"');
|
||||
});
|
||||
|
||||
it('words the tooltip exactly as the phone overview does', () => {
|
||||
// Both files build this sentence themselves, deliberately, so that a stale cached
|
||||
// module still renders a complete row. Substring-matching the prefix would let the
|
||||
// two drift; the whole sentence is what has to agree.
|
||||
const overview = readFileSync(resolve(publicDir, 'mobile-overview.js'), 'utf8');
|
||||
expect(overview).toContain("'Still running in the background: ' + label");
|
||||
expect(app).toContain('`Still running in the background: ${row.watching}`');
|
||||
});
|
||||
|
||||
it('colours it with the accent, never with the two colours that mean a human is needed', () => {
|
||||
const rule = styles.slice(styles.indexOf('.tab-pill--watching'));
|
||||
const block = rule.slice(0, rule.indexOf('}'));
|
||||
expect(block).toContain('var(--accent)');
|
||||
expect(block).not.toContain('var(--red)');
|
||||
expect(block).not.toContain('var(--yellow)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* The badge a session wears while work it started in the background is still running.
|
||||
*
|
||||
* The bug this pins: an agent that arms a monitor, backgrounds a shell or hands work to a
|
||||
* cloud session is told to end its turn, so the pane falls quiet, Claude Code's idle
|
||||
* notification arrives a minute later, and every Codeman surface files the session under
|
||||
* NEEDS YOU. Nothing wants the user there. The CLI itself says so on the last row of its
|
||||
* screen (`⏵⏵ bypass permissions on · 1 monitor · ← for agents`), and reading that row is
|
||||
* what tells a session waiting for its own background work from one waiting for a human.
|
||||
*
|
||||
* The pane fixtures below are verbatim captures (`tmux -L codeman capture-pane -p`) from a
|
||||
* live Claude Code 2.1.278 session on 2026-09-21.
|
||||
*/
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { getCli } from '../src/config/cli-registry/index.js';
|
||||
import { compileVersionRegex } from '../src/config/cli-registry/patterns.js';
|
||||
import {
|
||||
watchingLabel,
|
||||
WATCHING_TAIL_LINES,
|
||||
MAX_WATCHING_LABEL_CHARS,
|
||||
IDLE_SILENCE_MS,
|
||||
} from '../src/session-activity.js';
|
||||
|
||||
/** The registry's own patterns, which are what every consumer runs. */
|
||||
const CLAUDE_WATCHING = compileVersionRegex(getCli('claude')!.capabilities.workDetect!.watchingLine!)!;
|
||||
const CODEX_WATCHING = compileVersionRegex(getCli('codex')!.capabilities.workDetect!.watchingLine!)!;
|
||||
const CODEX_TAIL = getCli('codex')!.capabilities.workDetect!.watchingLines!;
|
||||
|
||||
/**
|
||||
* The foot of a Codex pane, verbatim (codex-cli 0.154.0, 2026-09-22). Codex does not
|
||||
* write on its last row: the status line is there, the composer above it, and the
|
||||
* background-terminal row above that, which is why codex declares its own window.
|
||||
*/
|
||||
const CODEX_STATUS =
|
||||
' gpt-5.6-sol medium · Context 98% left · ~/codeman-cases/codex-probe · 5h 99% left · weekly 94% left';
|
||||
const CODEX_WITH_TERMINAL = [
|
||||
'• OK',
|
||||
'',
|
||||
' 1 background terminal running · /ps to view · /stop to close',
|
||||
'',
|
||||
'',
|
||||
'› Ask Codex to do anything',
|
||||
'',
|
||||
CODEX_STATUS,
|
||||
'',
|
||||
].join('\n');
|
||||
const CODEX_STOPPED = [
|
||||
'• OK',
|
||||
'',
|
||||
'• Stopping all background terminals.',
|
||||
'',
|
||||
'',
|
||||
'› Ask Codex to do anything',
|
||||
'',
|
||||
CODEX_STATUS,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
/** The bottom of a Claude pane: composer, the user's status line, the footer row. */
|
||||
function pane(footer: string, body = ''): string {
|
||||
return (
|
||||
body +
|
||||
'╭──────────────────────────────────────╮\n' +
|
||||
'│ ❯ │\n' +
|
||||
'╰──────────────────────────────────────╯\n' +
|
||||
' ~/innovi/gtd-board [main] Opus 5 ctx: 11%\n' +
|
||||
` ${footer}\n`
|
||||
);
|
||||
}
|
||||
|
||||
const WITH_MONITOR = pane('⏵⏵ bypass permissions on · 1 monitor · ← for agents');
|
||||
const WITH_SHELL = pane('⏵⏵ bypass permissions on · 1 shell · ← for agents');
|
||||
const NOTHING_RUNNING = pane('⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents');
|
||||
|
||||
/** A composer repaint: the frame Claude ships roughly once a second while working. */
|
||||
const COMPOSER_REPAINT =
|
||||
'\x1b[31;1H\x1b[38;5;246m❯\xa0\x1b[39m\x1b[0m\x1b[33;1H \x1b[38;5;246mOpus 5 in:143,699 out:669 ctx:14%\x1b[39m';
|
||||
|
||||
type SessionInternals = {
|
||||
_handleTerminalOutput(data: string): void;
|
||||
_detectInteractiveActivity(data: string): void;
|
||||
};
|
||||
|
||||
/** One PTY chunk, exactly as the interactive handler sees it. */
|
||||
function feed(session: Session, data: string): void {
|
||||
const internals = session as unknown as SessionInternals;
|
||||
internals._handleTerminalOutput(data);
|
||||
internals._detectInteractiveActivity(data);
|
||||
}
|
||||
|
||||
/** A session whose mux reports a fixed (or scripted) screen for the pane probe to read. */
|
||||
function withFakePane(screen: string | (() => string), mode: 'claude' | 'codex' = 'claude'): Session {
|
||||
const read = typeof screen === 'function' ? screen : () => screen;
|
||||
const mux = {
|
||||
isAvailable: () => true,
|
||||
capturePaneText: () => read(),
|
||||
} as unknown as NonNullable<ConstructorParameters<typeof Session>[0]>['mux'];
|
||||
return new Session({
|
||||
workingDir: '/tmp',
|
||||
mode,
|
||||
mux,
|
||||
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
|
||||
} as ConstructorParameters<typeof Session>[0]);
|
||||
}
|
||||
|
||||
/** Codex's own composer repaint, the frame that arms its idle confirmation. */
|
||||
const CODEX_COMPOSER_REPAINT = '\x1b[31;1H\x1b[38;5;246m›\xa0\x1b[39m\x1b[0m';
|
||||
|
||||
/** Run one turn and let it end, which is when the probe reads the screen. */
|
||||
function runAndSettle(session: Session, repaint: string = COMPOSER_REPAINT): void {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
feed(session, repaint);
|
||||
vi.advanceTimersByTime(1000);
|
||||
}
|
||||
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
|
||||
}
|
||||
|
||||
describe('watchingLabel', () => {
|
||||
it('reads the label off the footer row', () => {
|
||||
expect(watchingLabel(WITH_MONITOR, CLAUDE_WATCHING)).toBe('1 monitor');
|
||||
expect(watchingLabel(WITH_SHELL, CLAUDE_WATCHING)).toBe('1 shell');
|
||||
});
|
||||
|
||||
it('reads every kind of background work the CLI names', () => {
|
||||
const labels = [
|
||||
'2 monitors',
|
||||
'3 shells',
|
||||
'1 cloud session',
|
||||
'2 cloud sessions',
|
||||
'1 local agent',
|
||||
'4 background tasks',
|
||||
'1 MCP task',
|
||||
'1 background dynamic workflow',
|
||||
'2 remote dynamic workflows',
|
||||
'1 Artifact comment monitor',
|
||||
'2 teams',
|
||||
];
|
||||
for (const label of labels) {
|
||||
expect(watchingLabel(pane(`⏵⏵ bypass permissions on · ${label} · ← for agents`), CLAUDE_WATCHING)).toBe(label);
|
||||
}
|
||||
});
|
||||
|
||||
it('says nothing about a pane that is running nothing', () => {
|
||||
expect(watchingLabel(NOTHING_RUNNING, CLAUDE_WATCHING)).toBeNull();
|
||||
expect(watchingLabel('', CLAUDE_WATCHING)).toBeNull();
|
||||
expect(watchingLabel(null, CLAUDE_WATCHING)).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores the same words in the transcript above the composer', () => {
|
||||
// The whole reason the search is confined to the foot of the screen: a session that
|
||||
// PRINTS "1 monitor" (this one has been discussing exactly that) is not running one.
|
||||
const transcript =
|
||||
'> does Codeman know about watching?\n' +
|
||||
'⏺ The footer says · 1 monitor · while a monitor is armed, and · 2 shells · for\n' +
|
||||
' backgrounded commands. Codeman reads neither today.\n' +
|
||||
' Nothing else on the screen means background work is running.\n';
|
||||
expect(watchingLabel(pane('⏵⏵ bypass permissions on · ← for agents', transcript), CLAUDE_WATCHING)).toBeNull();
|
||||
});
|
||||
|
||||
it('looks no further up the screen than the tail it declares', () => {
|
||||
const chip = '⏵⏵ bypass permissions on · 1 monitor · ← for agents';
|
||||
const below = Array(WATCHING_TAIL_LINES).fill(' still here').join('\n');
|
||||
// Blank lines are dropped before the tail is taken, so a pane padded with them must
|
||||
// still read its own footer.
|
||||
expect(watchingLabel(`${chip}\n\n\n\n\n\n`, CLAUDE_WATCHING)).toBe('1 monitor');
|
||||
expect(watchingLabel(`${chip}\n${below}\n`, CLAUDE_WATCHING)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a chip on the row above the footer, which the agent can write', () => {
|
||||
// The status line is one row up, its text comes from a `statusLine` command, and a
|
||||
// session running with permissions bypassed can write that command into
|
||||
// `.claude/settings.json` in its own workspace. The window is what keeps that row
|
||||
// out, so this is the test that would fail if somebody widened it.
|
||||
const forged = pane('⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents').replace(
|
||||
' ~/innovi/gtd-board [main] Opus 5 ctx: 11%',
|
||||
' ~/innovi/gtd-board [main] Opus 5 ctx: 11% · 1 monitor'
|
||||
);
|
||||
expect(watchingLabel(forged, CLAUDE_WATCHING)).toBeNull();
|
||||
// And with the window widened by one, the same screen does match — which is the
|
||||
// whole reason the default is one row.
|
||||
expect(watchingLabel(forged, CLAUDE_WATCHING, 2)).toBe('1 monitor');
|
||||
});
|
||||
|
||||
it('keeps Claude on the default window, because its chip is the last row', () => {
|
||||
expect(getCli('claude')?.capabilities.workDetect?.watchingLines).toBeUndefined();
|
||||
expect(WATCHING_TAIL_LINES).toBe(1);
|
||||
});
|
||||
|
||||
it('refuses a label the footer did not separate, which is the injection guard', () => {
|
||||
// The pattern anchors on the `·` the footer joins its items with. Without that
|
||||
// anchor an agent could silence its own idle alert by printing the words, since the
|
||||
// only rows it cannot write are the footer and the status line.
|
||||
expect(watchingLabel(pane('1 monitor'), CLAUDE_WATCHING)).toBeNull();
|
||||
expect(watchingLabel(pane('running 2 shells for the build'), CLAUDE_WATCHING)).toBeNull();
|
||||
expect(watchingLabel(pane('⏵⏵ bypass permissions on · 1 monitor'), CLAUDE_WATCHING)).toBe('1 monitor');
|
||||
});
|
||||
|
||||
it('reads a coloured footer, because a capture may carry ANSI', () => {
|
||||
const coloured = pane('\u001b[2m⏵⏵ bypass permissions on\u001b[0m · \u001b[36m1 monitor\u001b[0m · ← for agents');
|
||||
expect(watchingLabel(coloured, CLAUDE_WATCHING)).toBe('1 monitor');
|
||||
});
|
||||
|
||||
it('caps the label, because it ends up on a badge and in an approval card', () => {
|
||||
const long = `· ${'9'.repeat(MAX_WATCHING_LABEL_CHARS * 2)} monitors`;
|
||||
const label = watchingLabel(pane(`⏵⏵ bypass permissions on ${long} · ← for agents`), CLAUDE_WATCHING);
|
||||
expect(label?.length).toBe(MAX_WATCHING_LABEL_CHARS);
|
||||
});
|
||||
|
||||
it('survives a pattern handed to it with the global flag set', () => {
|
||||
// compileVersionRegex() never sets `g`, but a test or a reloaded config might, and a
|
||||
// sticky lastIndex would make the same screen match every other call.
|
||||
const global = new RegExp(CLAUDE_WATCHING.source, 'g');
|
||||
expect(watchingLabel(WITH_MONITOR, global)).toBe('1 monitor');
|
||||
expect(watchingLabel(WITH_MONITOR, global)).toBe('1 monitor');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session.watching', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('carries what the pane reported once the turn ends', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = withFakePane(WITH_MONITOR);
|
||||
expect(session.watching).toBeNull();
|
||||
|
||||
runAndSettle(session);
|
||||
|
||||
expect(session.status).toBe('idle');
|
||||
expect(session.watching).toBe('1 monitor');
|
||||
});
|
||||
|
||||
it('lets the badge go when the background work is over', () => {
|
||||
vi.useFakeTimers();
|
||||
const screen = { text: WITH_MONITOR };
|
||||
const session = withFakePane(() => screen.text);
|
||||
|
||||
runAndSettle(session);
|
||||
expect(session.watching).toBe('1 monitor');
|
||||
|
||||
screen.text = NOTHING_RUNNING;
|
||||
runAndSettle(session);
|
||||
expect(session.watching).toBeNull();
|
||||
});
|
||||
|
||||
it('announces the change, because the session status does not move with it', () => {
|
||||
// Measured on codex: a background terminal finishing repaints the row away and the
|
||||
// session is idle before and after, so no other event fires. Without this one the
|
||||
// server drops the label and every open page goes on drawing the badge.
|
||||
vi.useFakeTimers();
|
||||
const screen = { text: WITH_MONITOR };
|
||||
const session = withFakePane(() => screen.text);
|
||||
const changes: (string | null)[] = [];
|
||||
session.on('watchingChanged', () => changes.push(session.watching));
|
||||
|
||||
runAndSettle(session);
|
||||
expect(changes).toEqual(['1 monitor']);
|
||||
|
||||
// A repaint that carries the composer glyph but no chip: the pane went quiet again
|
||||
// without a turn, which is exactly the case the event exists for.
|
||||
screen.text = NOTHING_RUNNING;
|
||||
feed(session, COMPOSER_REPAINT);
|
||||
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
|
||||
expect(changes).toEqual(['1 monitor', null]);
|
||||
expect(session.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('says nothing while the answer stays the same', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = withFakePane(WITH_MONITOR);
|
||||
const changes: (string | null)[] = [];
|
||||
session.on('watchingChanged', () => changes.push(session.watching));
|
||||
|
||||
runAndSettle(session);
|
||||
runAndSettle(session);
|
||||
runAndSettle(session);
|
||||
expect(changes).toEqual(['1 monitor']);
|
||||
});
|
||||
|
||||
it('keeps its last answer when the screen cannot be read', () => {
|
||||
vi.useFakeTimers();
|
||||
const screen: { text: string | null } = { text: WITH_MONITOR };
|
||||
const session = withFakePane(() => screen.text as string);
|
||||
|
||||
runAndSettle(session);
|
||||
expect(session.watching).toBe('1 monitor');
|
||||
|
||||
// A capture that fails is not evidence that nothing is running, which is the same
|
||||
// rule the working probe applies to its own null.
|
||||
screen.text = null;
|
||||
runAndSettle(session);
|
||||
expect(session.watching).toBe('1 monitor');
|
||||
});
|
||||
|
||||
it('reads Codex own row, three up from the bottom of its screen', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = withFakePane(CODEX_WITH_TERMINAL, 'codex');
|
||||
runAndSettle(session, CODEX_COMPOSER_REPAINT);
|
||||
expect(session.status).toBe('idle');
|
||||
expect(session.watching).toBe('1 background terminal');
|
||||
});
|
||||
|
||||
it('reports nothing for a CLI whose screen nobody has characterised', () => {
|
||||
vi.useFakeTimers();
|
||||
expect(getCli('gemini')?.capabilities.workDetect).toBeUndefined();
|
||||
const session = withFakePane(WITH_MONITOR, 'gemini');
|
||||
runAndSettle(session);
|
||||
expect(session.watching).toBeNull();
|
||||
});
|
||||
|
||||
it('never reads another CLI screen', () => {
|
||||
vi.useFakeTimers();
|
||||
// Each pattern is anchored on chrome its own CLI draws, so neither can fire on the
|
||||
// other's pane. A shared fallback would have both reading a screen nobody measured.
|
||||
const codexOnClaudeScreen = withFakePane(WITH_MONITOR, 'codex');
|
||||
runAndSettle(codexOnClaudeScreen, CODEX_COMPOSER_REPAINT);
|
||||
expect(codexOnClaudeScreen.watching).toBeNull();
|
||||
|
||||
const claudeOnCodexScreen = withFakePane(CODEX_WITH_TERMINAL, 'claude');
|
||||
runAndSettle(claudeOnCodexScreen);
|
||||
expect(claudeOnCodexScreen.watching).toBeNull();
|
||||
});
|
||||
|
||||
it('rides along on the payload every session surface reads', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = withFakePane(WITH_SHELL);
|
||||
runAndSettle(session);
|
||||
|
||||
expect(session.toLightDetailedState().watching).toBe('1 shell');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the row Codex draws', () => {
|
||||
it('reads the label, and only while a terminal is running', () => {
|
||||
expect(watchingLabel(CODEX_WITH_TERMINAL, CODEX_WATCHING, CODEX_TAIL)).toBe('1 background terminal');
|
||||
expect(watchingLabel(CODEX_STOPPED, CODEX_WATCHING, CODEX_TAIL)).toBeNull();
|
||||
});
|
||||
|
||||
it('counts terminals', () => {
|
||||
const three = CODEX_WITH_TERMINAL.replace('1 background terminal running', '3 background terminals running');
|
||||
expect(watchingLabel(three, CODEX_WATCHING, CODEX_TAIL)).toBe('3 background terminals');
|
||||
});
|
||||
|
||||
it('needs the window Codex declares: its row is not the last one', () => {
|
||||
// Pins WHY `watchingLines` exists. Claude's default of two rows reaches the status
|
||||
// line and the composer, and Codex's row sits one further up.
|
||||
expect(watchingLabel(CODEX_WITH_TERMINAL, CODEX_WATCHING, 2)).toBeNull();
|
||||
expect(CODEX_TAIL).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('refuses a mention that is not the whole row', () => {
|
||||
// The pattern matches Codex's row end to end, so prose about background terminals —
|
||||
// including prose quoting part of the row — is not enough.
|
||||
for (const line of [
|
||||
'• I left 1 background terminal running for you.',
|
||||
' 1 background terminal running · /ps to view',
|
||||
' see: 1 background terminal running · /ps to view · /stop to close',
|
||||
]) {
|
||||
const claim = CODEX_STOPPED.replace('• Stopping all background terminals.', line);
|
||||
expect(watchingLabel(claim, CODEX_WATCHING, CODEX_TAIL)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('CAN be forged by Codex own output, and is contained by Codex having no hooks', () => {
|
||||
// Codex's row is third from the bottom only while a terminal runs; with none running
|
||||
// that slot is the last row of the transcript, which the agent writes. Matching the
|
||||
// complete row raises the bar but closes nothing, so this test states the limitation
|
||||
// rather than a protection the code does not have.
|
||||
const forged = CODEX_STOPPED.replace(
|
||||
'• Stopping all background terminals.',
|
||||
' 1 background terminal running · /ps to view · /stop to close'
|
||||
);
|
||||
expect(watchingLabel(forged, CODEX_WATCHING, CODEX_TAIL)).toBe('1 background terminal');
|
||||
|
||||
// What makes that cost a wrong badge and nothing more: no hook event from a codex
|
||||
// session reaches the approvals inbox, so there is no idle item to pre-acknowledge
|
||||
// and no alert to silence. A CLI that gains hook signals needs a harder anchor first.
|
||||
expect(getCli('codex')?.capabilities.hooks).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the registry pattern Claude declares', () => {
|
||||
it('is one the config-regex guard accepts', () => {
|
||||
// Same guard as `workingLine`: ~/.codeman/clis.json can set this field, and the
|
||||
// compiled pattern runs over a pane capture on a timer.
|
||||
expect(compileVersionRegex(getCli('claude')!.capabilities.workDetect!.watchingLine!)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not fire on the status line a user configured', () => {
|
||||
// Plan-usage and context figures live one row above the footer and carry numbers.
|
||||
const statusLine = ' ~/innovi/gtd-board [main] Opus 5 (1M context) high ctx: 10% 5h: 48% (32m) 7d: 15% (6d10h)';
|
||||
expect(CLAUDE_WATCHING.test(statusLine)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,24 @@ describe('approvalCard', () => {
|
||||
expect(card.hint).toBe('p to reply');
|
||||
});
|
||||
|
||||
it('says why an acknowledged idle prompt is quiet, in the drawer own words', () => {
|
||||
// The session is watching work it started itself. Asking for a reply would be the
|
||||
// same false alarm the acknowledgement exists to remove, so the card states the
|
||||
// reason and drops out of the warning vocabulary — while staying answerable.
|
||||
const card = approvalCard(
|
||||
item({
|
||||
kind: 'idle',
|
||||
message: 'Claude is waiting for your input',
|
||||
options: undefined,
|
||||
acknowledgedAt: 1_700_000_000_000,
|
||||
acknowledgedReason: 'watching 1 monitor',
|
||||
})
|
||||
);
|
||||
expect(card.tone).toBe('info');
|
||||
expect(card.title).toBe('quiet, watching 1 monitor');
|
||||
expect(card.hint).toBe('p to reply');
|
||||
});
|
||||
|
||||
it('drops the approve/deny-only hint when the frame did not parse', () => {
|
||||
const card = approvalCard(item({ options: undefined }));
|
||||
expect(card.options).toEqual([]);
|
||||
@@ -81,6 +99,12 @@ describe('approvalTone', () => {
|
||||
expect(approvalTone(item({ kind: 'question' }))).toBe('err');
|
||||
expect(approvalTone(item({ kind: 'idle' }))).toBe('warn');
|
||||
});
|
||||
|
||||
it('is neither for a prompt that opened acknowledged', () => {
|
||||
expect(approvalTone(item({ kind: 'idle', acknowledgedReason: 'watching 2 shells' }))).toBe('info');
|
||||
// A dialog stays red whatever else the session started.
|
||||
expect(approvalTone(item({ kind: 'permission', acknowledgedReason: 'watching 2 shells' }))).toBe('err');
|
||||
});
|
||||
});
|
||||
|
||||
describe('approvalAnswerForKey', () => {
|
||||
|
||||
@@ -71,6 +71,32 @@ describe('classifySession', () => {
|
||||
expect(classifySession(row, approval({ sessionId: 'a', kind: 'idle' }))).toBe('waiting');
|
||||
});
|
||||
|
||||
it('stops an ACKNOWLEDGED prompt deciding the row', () => {
|
||||
// Acknowledgement means the alert this prompt armed has been spent, either because
|
||||
// a human opened the session elsewhere or because the inbox opened the item that way
|
||||
// for a session watching its own background work. The web has honoured that since
|
||||
// acknowledgement existed; this gate used to read past it, so an alert cleared on a
|
||||
// phone stayed lit here alone.
|
||||
const quiet = session({ sessionId: 'a', status: 'idle' });
|
||||
const seen = approval({ sessionId: 'a', kind: 'idle', acknowledgedAt: NOW - 1_000 });
|
||||
expect(classifySession(quiet, seen)).toBe('idle');
|
||||
expect(classifySession(session({ sessionId: 'a', status: 'busy' }), seen)).toBe('working');
|
||||
});
|
||||
|
||||
it('keeps a blocking dialog lit whatever its acknowledgement says', () => {
|
||||
// `acknowledge()` is idle-only by construction, so this cannot happen through the
|
||||
// routes. It is pinned because the cost of the two being wired together later is a
|
||||
// permission dialog that stops asking.
|
||||
const row = session({ sessionId: 'a' });
|
||||
const at = NOW - 1_000;
|
||||
expect(classifySession(row, approval({ sessionId: 'a', kind: 'permission', acknowledgedAt: at }))).toBe(
|
||||
'blocked-permission'
|
||||
);
|
||||
expect(classifySession(row, approval({ sessionId: 'a', kind: 'question', acknowledgedAt: at }))).toBe(
|
||||
'blocked-question'
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies a row the server no longer has live as history', () => {
|
||||
expect(classifySession(session({ sessionId: 'a', sources: ['history'], status: 'busy' }))).toBe('recent');
|
||||
expect(classifySession(session({ sessionId: 'a', sources: ['persisted', 'lifecycle'] }))).toBe('recent');
|
||||
@@ -78,6 +104,46 @@ describe('classifySession', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('an acknowledged prompt on a watching session', () => {
|
||||
const sessions = [session({ sessionId: 'watcher', status: 'idle', lastActivityAt: NOW - 120_000 })];
|
||||
const approvals = approvalMap([
|
||||
approval({
|
||||
sessionId: 'watcher',
|
||||
kind: 'idle',
|
||||
createdAt: NOW - 30_000,
|
||||
acknowledgedAt: NOW - 30_000,
|
||||
acknowledgedReason: 'watching 1 monitor',
|
||||
}),
|
||||
]);
|
||||
|
||||
it('raises no alert: the row leaves NEEDS YOU entirely', () => {
|
||||
const groups = groupSessions(buildRows(sessions, approvals));
|
||||
const byKey = Object.fromEntries(groups.map((group) => [group.key, group.rows.map((r) => r.session.sessionId)]));
|
||||
expect(byKey['needs-you']).toEqual([]);
|
||||
expect(byKey['idle']).toEqual(['watcher']);
|
||||
});
|
||||
|
||||
it('keeps the item on the row, because it is still pending and still answerable', () => {
|
||||
const [row] = buildRows(sessions, approvals);
|
||||
expect(row.approval?.acknowledgedReason).toBe('watching 1 monitor');
|
||||
});
|
||||
|
||||
it('dates the row from the pane going quiet, not from the prompt', () => {
|
||||
// The prompt's age measures the state only while the prompt is what put the row in
|
||||
// it. Reading it here would report "idle 30s" for a session quiet for two minutes.
|
||||
const [row] = buildRows(sessions, approvals);
|
||||
expect(row.since).toBe(NOW - 120_000);
|
||||
});
|
||||
|
||||
it('alerts again once the same session goes quiet for an ordinary reason', () => {
|
||||
// The inbox supersedes the acknowledged item and builds a fresh one, so this is the
|
||||
// next prompt rather than the same one changing its mind.
|
||||
const fresh = approvalMap([approval({ sessionId: 'watcher', kind: 'idle', createdAt: NOW - 1_000 })]);
|
||||
const groups = groupSessions(buildRows(sessions, fresh));
|
||||
expect(groups[0].rows.map((row) => row.session.sessionId)).toEqual(['watcher']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupSessions', () => {
|
||||
it('always returns the four groups in display order', () => {
|
||||
expect(groupSessions([]).map((group) => group.key)).toEqual(['needs-you', 'working', 'idle', 'recent']);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { charWidth, stripStyles, toDisplayLines, visibleWidth } from '../../src/
|
||||
import { composerMove, createComposer } from '../../src/tui/tui-composer.js';
|
||||
import { computeLayout, needsBanner } from '../../src/tui/tui-layout.js';
|
||||
import { createTuiModel, type TuiModelStore } from '../../src/tui/tui-model.js';
|
||||
import type { ApprovalItem } from '../../src/web/approval-inbox.js';
|
||||
import {
|
||||
composerCursorCell,
|
||||
detectGlyphTier,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
formatPlanUsage,
|
||||
formatTokens,
|
||||
glyphsFor,
|
||||
pendingApprovalCount,
|
||||
renderFrame,
|
||||
rowLabel,
|
||||
type TuiRenderOptions,
|
||||
@@ -682,3 +684,33 @@ describe('the unicode glyph set is safe to render', () => {
|
||||
expect(every.join('')).not.toContain('\u270B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pendingApprovalCount', () => {
|
||||
const prompt = (over: Partial<ApprovalItem>): ApprovalItem => ({
|
||||
id: 'bbb2:1',
|
||||
sessionId: 'bbb2',
|
||||
sessionName: 'w6-docs',
|
||||
kind: 'idle',
|
||||
createdAt: NOW - 30_000,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('counts a prompt nobody has seen', () => {
|
||||
const model = fixture();
|
||||
model.setApprovals([prompt({})]);
|
||||
expect(pendingApprovalCount(model)).toBe(1);
|
||||
});
|
||||
|
||||
it('does not count one whose alert is already spent', () => {
|
||||
// Both ways an item gets acknowledged: a human opening the session elsewhere, and the
|
||||
// inbox opening it that way for a session watching its own background work. The row
|
||||
// has left NEEDS YOU by the same flag, so a number in the header would point at a
|
||||
// group the reader can see is empty.
|
||||
const model = fixture();
|
||||
model.setApprovals([prompt({ acknowledgedAt: NOW - 20_000 })]);
|
||||
expect(pendingApprovalCount(model)).toBe(0);
|
||||
|
||||
model.setApprovals([prompt({ acknowledgedAt: NOW - 20_000, acknowledgedReason: 'watching 1 monitor' })]);
|
||||
expect(pendingApprovalCount(model)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
// Port: none (pure classifiers + vm-loaded frontend modules — no browser, no server).
|
||||
//
|
||||
// The point of the watching signal is an alert that does NOT fire, so the test that
|
||||
// matters is the negative one. A session that ended its turn because it armed a monitor
|
||||
// or backgrounded a shell gets an idle prompt from Claude Code about a minute later, and
|
||||
// that prompt must reach every surface as a card nobody has to look at rather than as an
|
||||
// alert. The same session holding a permission dialog must still go red everywhere.
|
||||
//
|
||||
// Each surface is exercised through the code it really runs: the TUI classifier, the
|
||||
// live SSE handler in settings-ui.js, the reload seed in approvals-ui.js, and the state
|
||||
// classifier both home screens share.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import { ApprovalInbox, type ApprovalItem } from '../src/web/approval-inbox.js';
|
||||
import { buildRows, groupSessions } from '../src/tui/tui-model.js';
|
||||
import type { TuiSessionRow } from '../src/tui/tui-types.js';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const SESSION = 'watcher-session';
|
||||
|
||||
/** A live unified row for the session under test, quiet at its composer. */
|
||||
function row(overrides: Partial<TuiSessionRow> = {}): TuiSessionRow {
|
||||
return {
|
||||
sessionId: SESSION,
|
||||
sources: ['live'],
|
||||
name: 'watch-probe',
|
||||
mode: 'claude',
|
||||
status: 'idle',
|
||||
workingDir: '/home/dev/case',
|
||||
createdAt: 1_000,
|
||||
lastActivityAt: 2_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The item the server really produces for this case, built by the real inbox rather
|
||||
* than by hand, so a change to how `watching` is honoured breaks this file too.
|
||||
*/
|
||||
function itemFor(watching: string | null): ApprovalItem {
|
||||
const inbox = new ApprovalInbox();
|
||||
const item = inbox.notePrompt({ sessionId: SESSION, sessionName: 'watch-probe', kind: 'idle', watching });
|
||||
inbox.stop();
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Minimal fake DOM node, enough for the handlers these tests drive. */
|
||||
function fakeElement(): Record<string, unknown> {
|
||||
const el: Record<string, unknown> = {
|
||||
className: '',
|
||||
textContent: '',
|
||||
title: '',
|
||||
dataset: {},
|
||||
style: {},
|
||||
children: [] as unknown[],
|
||||
hidden: false,
|
||||
classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
|
||||
setAttribute() {},
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
appendChild(child: unknown) {
|
||||
(el.children as unknown[]).push(child);
|
||||
return child;
|
||||
},
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
interface FrontendApp {
|
||||
pendingHooks: Map<string, Set<string>>;
|
||||
notifications: string[];
|
||||
approvals: Map<string, ApprovalItem>;
|
||||
seeded: ApprovalItem[];
|
||||
setPendingHook(sessionId: string, hook: string): void;
|
||||
clearPendingHooks(sessionId: string, hook?: string): void;
|
||||
_onHookIdlePrompt(data: Record<string, unknown>): void;
|
||||
_onHookPermissionPrompt(data: Record<string, unknown>): void;
|
||||
seedApprovals(): Promise<void>;
|
||||
_mobileOverviewState(session: Record<string, unknown>, hooks?: Set<string>): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The three frontend modules that decide whether a prompt becomes an alert, loaded into
|
||||
* one context the way the page loads them. Everything they call that belongs to app.js
|
||||
* is stubbed to record rather than to render.
|
||||
*/
|
||||
function loadFrontend(seed: ApprovalItem[] = []): FrontendApp {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as { prototype: Record<string, unknown> };
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
window: {},
|
||||
CSS: { escape: (s: string) => s },
|
||||
document: {
|
||||
documentElement: { getAttribute: () => null, dataset: {} },
|
||||
getElementById: () => null,
|
||||
querySelector: () => null,
|
||||
createElement: () => fakeElement(),
|
||||
createElementNS: () => fakeElement(),
|
||||
},
|
||||
MobileDetection: { getDeviceType: () => 'desktop' },
|
||||
});
|
||||
for (const file of ['constants.js', 'mobile-overview.js', 'approvals-ui.js', 'settings-ui.js']) {
|
||||
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
|
||||
}
|
||||
|
||||
const app = Object.create(CodemanApp.prototype) as FrontendApp & Record<string, unknown>;
|
||||
app.pendingHooks = new Map();
|
||||
app.notifications = [];
|
||||
app.approvals = new Map();
|
||||
app.seeded = seed;
|
||||
app.setPendingHook = (sessionId: string, hook: string) => {
|
||||
if (!app.pendingHooks.has(sessionId)) app.pendingHooks.set(sessionId, new Set());
|
||||
app.pendingHooks.get(sessionId)!.add(hook);
|
||||
};
|
||||
app.clearPendingHooks = (sessionId: string, hook?: string) => {
|
||||
if (!hook) app.pendingHooks.delete(sessionId);
|
||||
else app.pendingHooks.get(sessionId)?.delete(hook);
|
||||
};
|
||||
Object.assign(app, {
|
||||
_notifySession: (_id: string, _level: string, kind: string) => app.notifications.push(kind),
|
||||
_apiJson: async () => ({ approvals: app.seeded }),
|
||||
approvalsInboxEnabled: () => true,
|
||||
renderApprovals: () => {},
|
||||
renderSessionTabs: () => {},
|
||||
loadAppSettingsFromStorage: () => ({}),
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('a watching session raises no alert on any surface', () => {
|
||||
const watched = itemFor('1 monitor');
|
||||
|
||||
it('the store opens the prompt acknowledged, which is what every surface reads', () => {
|
||||
expect(watched.acknowledgedAt).toEqual(expect.any(Number));
|
||||
expect(watched.acknowledgedReason).toBe('watching 1 monitor');
|
||||
});
|
||||
|
||||
it('codeman tui leaves the row out of NEEDS YOU', () => {
|
||||
const groups = groupSessions(buildRows([row()], new Map([[SESSION, watched]])));
|
||||
const needsYou = groups.find((group) => group.key === 'needs-you')!;
|
||||
expect(needsYou.rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('a live page declines to arm the tab alert and raises no desktop notification', () => {
|
||||
const app = loadFrontend();
|
||||
app._onHookIdlePrompt({ sessionId: SESSION, acknowledgedReason: watched.acknowledgedReason });
|
||||
expect(app.pendingHooks.get(SESSION)).toBeUndefined();
|
||||
expect(app.notifications).toEqual([]);
|
||||
});
|
||||
|
||||
it('a reloading page does not arm it either', async () => {
|
||||
const app = loadFrontend([watched]);
|
||||
await app.seedApprovals();
|
||||
expect(app.pendingHooks.get(SESSION)).toBeUndefined();
|
||||
// The card itself is still there to answer, which is the whole point of
|
||||
// acknowledging the prompt rather than never creating it.
|
||||
expect(app.approvals.get(watched.id)?.acknowledgedReason).toBe('watching 1 monitor');
|
||||
});
|
||||
|
||||
it('so both home screens classify the session as plainly idle', () => {
|
||||
const app = loadFrontend();
|
||||
app._onHookIdlePrompt({ sessionId: SESSION, acknowledgedReason: watched.acknowledgedReason });
|
||||
const state = app._mobileOverviewState({ status: 'idle' }, app.pendingHooks.get(SESSION));
|
||||
expect(state).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('an ordinary idle prompt still alerts everywhere', () => {
|
||||
const plain = itemFor(null);
|
||||
|
||||
it('the store leaves it unacknowledged', () => {
|
||||
expect(plain.acknowledgedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('codeman tui puts the row in NEEDS YOU', () => {
|
||||
const groups = groupSessions(buildRows([row()], new Map([[SESSION, plain]])));
|
||||
expect(groups[0].rows.map((r) => r.session.sessionId)).toEqual([SESSION]);
|
||||
});
|
||||
|
||||
it('a live page arms the tab alert and notifies', () => {
|
||||
const app = loadFrontend();
|
||||
app._onHookIdlePrompt({ sessionId: SESSION, message: 'Claude is waiting for your input' });
|
||||
expect([...(app.pendingHooks.get(SESSION) ?? [])]).toEqual(['idle_prompt']);
|
||||
expect(app.notifications).toEqual(['hook-idle']);
|
||||
});
|
||||
|
||||
it('a reloading page arms it from the seed', async () => {
|
||||
const app = loadFrontend([plain]);
|
||||
await app.seedApprovals();
|
||||
expect([...(app.pendingHooks.get(SESSION) ?? [])]).toEqual(['idle_prompt']);
|
||||
});
|
||||
|
||||
it('so both home screens put the session in NEEDS YOU', () => {
|
||||
const app = loadFrontend();
|
||||
app._onHookIdlePrompt({ sessionId: SESSION });
|
||||
expect(app._mobileOverviewState({ status: 'idle' }, app.pendingHooks.get(SESSION))).toBe('waiting');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a dialog blocking the agent alerts even while it watches', () => {
|
||||
it('a live page arms and notifies whatever else the session started', () => {
|
||||
// A permission prompt never opens acknowledged (the inbox gates on kind), so the
|
||||
// handler never sees a reason and this is the ordinary path. Pinned because the two
|
||||
// handlers sit side by side and the guard belongs on exactly one of them.
|
||||
const app = loadFrontend();
|
||||
app._onHookPermissionPrompt({ sessionId: SESSION, tool: 'Bash' });
|
||||
expect([...(app.pendingHooks.get(SESSION) ?? [])]).toEqual(['permission_prompt']);
|
||||
expect(app.notifications).toEqual(['hook-permission']);
|
||||
});
|
||||
|
||||
it('codeman tui shows it as blocked', () => {
|
||||
const inbox = new ApprovalInbox();
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: SESSION,
|
||||
sessionName: 'watch-probe',
|
||||
kind: 'permission',
|
||||
watching: '1 monitor',
|
||||
});
|
||||
inbox.stop();
|
||||
const [built] = buildRows([row()], new Map([[SESSION, item]]));
|
||||
expect(built.state).toBe('blocked-permission');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user