mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(approvals): let a watching session keep quiet, and fix the tui gate
The badge alone left the row in NEEDS YOU, which is the thing the issue was about. The fix is the alert that does not fire. An idle prompt from a session that is watching its own background work now opens ALREADY acknowledged. `hook-event-routes` passes `Session.watching` to `notePrompt()`, which sets `acknowledgedAt` and records why in a new `acknowledgedReason`. Nothing new suppresses anything: `acknowledge()` has always meant "the alert this prompt armed is spent", and the prompt itself stays pending, answerable and available as Read My Mind context. A wrong label therefore costs a card that does not blink, never an alert that was never created. Every surface follows from that. The broadcast carries the reason, so a live page declines to arm the tab alert and raises no desktop notification. The push is skipped, since a false alarm is hardest to ignore on a phone. A reloading page reads `acknowledgedAt` in `seedApprovals()`, which it already did. And `classifySession()` now reads it too, which is a pre-existing bug fixed here: acknowledging on one device cleared the alert everywhere except `codeman tui`. It re-arms for free, because the next idle prompt supersedes the item and is built fresh. Only `idle` is eligible, so a dialog that blocks the agent still goes red whatever else it started. The label is pane-derived and therefore prompt-injectable, so it is now read from the last two rows of the screen only, with Claude's pattern anchored on the `·` its footer joins items with, ANSI-stripped and length-capped at the source. An agent that prints `· 1 monitor ·` into its own output finds no match. Verified on an isolated beta: a session that armed a monitor took its idle prompt acknowledged with no alert on any surface, wore the badge, and showed "quiet, watching 1 monitor" on its still-answerable card; the same session with the monitor killed alerted normally on the next prompt. `test/watching-no-alert.test.ts` pins both directions across all four surfaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3f2cde2db7
commit
74884a20eb
@@ -2,4 +2,12 @@
|
||||
'aicodeman': minor
|
||||
---
|
||||
|
||||
Sessions now say when they are watching work they started themselves. An agent that arms a monitor, backgrounds a shell or hands a task to a cloud session is told to end its turn, so the pane goes quiet, Claude Code's idle notification lands a minute later and the session shows up under NEEDS YOU with nothing for anyone to answer. Claude prints what it is still running on the last row of its screen, and the CLI registry now carries that row as `capabilities.workDetect.watchingLine`, so the idle probe reads the label ("1 monitor", "2 shells") along with the working line it already reads. The label reaches every session payload as `watching`, and the phone overview, the desktop home rail and the rich sidebar rows wear it as a `watching` badge in the accent colour. It sits beside the state pill and never replaces it, because an agent can arm a monitor and ask you a question in the same breath.
|
||||
A session watching work it started itself no longer asks you to look at it. An agent that arms a monitor, backgrounds a shell or hands a task to a cloud session is told to end its turn, so the pane goes quiet, Claude Code's idle notification lands a minute later, and the session shows up under NEEDS YOU with nothing for anyone to answer.
|
||||
|
||||
Claude prints what it is still running on the last row of its screen, and the CLI registry now carries that row as an optional `capabilities.workDetect.watchingLine`. The idle probe reads the label ("1 monitor", "2 shells") off the capture it already takes when a turn ends, and it reaches the session payload as `watching`.
|
||||
|
||||
The prompt that follows then opens already acknowledged. It stays in the Approvals drawer, stays answerable and stays Read My Mind context, and only the alert it would have armed is spent: no tab alert, no desktop notification, no push, and no NEEDS YOU row on any surface, `codeman tui` included. The card says why, reading "quiet, watching 1 monitor" rather than implying somebody looked. It re-arms by itself, because the next idle prompt supersedes this item and is built fresh. A permission or question dialog still goes red whatever else the agent started.
|
||||
|
||||
Sessions also wear a `watching` badge beside their state pill on the phone overview, the desktop home rail and the rich sidebar and rail rows.
|
||||
|
||||
Fixes a pre-existing bug in the same gate: `classifySession()` never looked at `acknowledgedAt`, so an idle alert cleared by opening the session on another device stayed lit in `codeman tui` alone.
|
||||
|
||||
@@ -201,7 +201,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`.
|
||||
|
||||
⚠️ **A `❯` sighting is NOT the end of a turn, and neither is silence.** Claude redraws the composer (`❯`) about once a second all through a turn, so the old "saw a ❯, wait 2s → idle" rule flipped every working session to idle two seconds in (measured: a session mid-tool-call at 17 minutes reporting `status:"idle"`). Its working indicator is `✻ Actualizing… (13m 23s · ↓ 47.5k tokens)`: the glyph animates through `· ✢ ✳ ∗ ✻ ✽`, the gerund is randomized, and the finished line (`✻ Cooked for 2m 49s`) carries the same glyph, so neither `SPINNER_PATTERN` (braille, not what current versions draw) nor a keyword list can see it. Matching the new line in the STREAM does not work either: tmux ships partial repaints, so the whole line reaches the PTY only every few tens of seconds. So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks the SCREEN via `capturePaneText()` + `CLAUDE_WORKING_LINE_PATTERN` before believing it; a sustained run of repaints (`session-activity.ts`, pure + unit tested) is what marks a turn as started, with the same screen probe vetoing keystroke echo. Idle now lands ~3-5s after a turn ends instead of 2s into one. ⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life. ⚠️ `workingLine` is config-supplied (a user `clis.json` can set it) and the compiled pattern runs on the PTY hot path, so it goes through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()`: a nested quantifier there is a ReDoS against the event loop, and the helper returns null rather than throwing so the fallback is structural. ⚠️ **A quiet pane is not always a pane that wants you.** 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 goes quiet, Claude Code's `idle_prompt` notification lands a minute later and every surface files the session under NEEDS YOU with nothing to answer. The CLI states what it is still running on the last row of its screen (`⏵⏵ bypass permissions on · 1 monitor · ← for agents`), which is the optional `capabilities.workDetect.watchingLine`: `_confirmIdle()`'s own capture feeds `watchingLabel()` (`session-activity.ts`, pure), the label lands on `Session.watching` and rides `toLightDetailedState()` out to every surface as a `watching` badge. ⚠️ That badge NEVER replaces the state pill and never moves a row out of NEEDS YOU: an agent can arm a monitor and ask a question in the same breath, and only the pill says which. The search is confined to the last `WATCHING_TAIL_LINES` lines of the capture, because the transcript above the composer quotes arbitrary text and a session that PRINTS "1 monitor" is not running one. Tests: `test/session-watching.test.ts`.
|
||||
⚠️ **A `❯` sighting is NOT the end of a turn, and neither is silence.** Claude redraws the composer (`❯`) about once a second all through a turn, so the old "saw a ❯, wait 2s → idle" rule flipped every working session to idle two seconds in (measured: a session mid-tool-call at 17 minutes reporting `status:"idle"`). Its working indicator is `✻ Actualizing… (13m 23s · ↓ 47.5k tokens)`: the glyph animates through `· ✢ ✳ ∗ ✻ ✽`, the gerund is randomized, and the finished line (`✻ Cooked for 2m 49s`) carries the same glyph, so neither `SPINNER_PATTERN` (braille, not what current versions draw) nor a keyword list can see it. Matching the new line in the STREAM does not work either: tmux ships partial repaints, so the whole line reaches the PTY only every few tens of seconds. So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks the SCREEN via `capturePaneText()` + `CLAUDE_WORKING_LINE_PATTERN` before believing it; a sustained run of repaints (`session-activity.ts`, pure + unit tested) is what marks a turn as started, with the same screen probe vetoing keystroke echo. Idle now lands ~3-5s after a turn ends instead of 2s into one. ⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life. ⚠️ `workingLine` is config-supplied (a user `clis.json` can set it) and the compiled pattern runs on the PTY hot path, so it goes through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()`: a nested quantifier there is a ReDoS against the event loop, and the helper returns null rather than throwing so the fallback is structural. ⚠️ **A quiet pane is not always a pane that wants you.** 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 goes quiet, Claude Code's `idle_prompt` notification lands a minute later and every surface files the session under NEEDS YOU with nothing to answer. The CLI states what it is still running on the last row of its screen (`⏵⏵ bypass permissions on · 1 monitor · ← for agents`), which is the optional `capabilities.workDetect.watchingLine`: `_confirmIdle()`'s own capture feeds `watchingLabel()` (`session-activity.ts`, pure), and the label lands on `Session.watching`. ⚠️ **The fix is the alert that does not fire, not the badge.** `hook-event-routes` passes that label to `notePrompt()`, which opens the idle item ALREADY acknowledged (`acknowledgedAt` + `acknowledgedReason`), so the prompt stays pending, answerable and Read-My-Mind context while the alert it would have armed is spent — the same `acknowledge()` semantics a human viewing the session has always produced. The broadcast carries `acknowledgedReason` so a live page declines to arm (`_onHookIdlePrompt`), the push is skipped, a reloading page reads `acknowledgedAt` in `seedApprovals()`, and `classifySession()` ignores an acknowledged item so `codeman tui` agrees. It re-arms for free: the next idle prompt supersedes this item and is built fresh. ⚠️ Only `idle` is eligible, so a permission or question dialog still goes red whatever else the agent started. The badge is the cosmetic half, and it rides BESIDE the state pill on the surfaces that have one, never in place of it. ⚠️ The label is **pane-derived and therefore prompt-injectable**: the search is confined to the last `WATCHING_TAIL_LINES` lines and each CLI's pattern anchors on its footer's own separator, because an agent that got a bare `1 monitor` matched would silence its own alert by printing it. Tests: `test/session-watching.test.ts` and `test/watching-no-alert.test.ts` (the negative one).
|
||||
|
||||
**Workspace-trust dialog auto-accept** (`session-trust-dialog.ts`, pure + unit tested): Claude Code asks once per directory ("Is this a project you created or one you trust?") before it will read or edit anything, and since Codeman sessions run permission-skipping or classifier-guarded modes the answer is always yes, so a session parked on that dialog is simply stuck. ⚠️ **Match the compacted SCREEN, never the stream.** tmux repaints a row by writing each word and then a cursor-forward (`\x1b[C`) instead of a space, and Ink colours each word separately, so the wire carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder`; stripping the escapes leaves `Itrustthisfolder`, because the spaces are not there to strip, they were never sent. A plain `includes('trust this folder')` therefore never matched a single chunk and the auto-accept was silently DEAD for every session that hit the dialog. `compactScreenText()` removes ALL whitespace instead (plus the `ESC ( B` charset selects that `stripAnsi` does not cover, which would otherwise land inside a phrase as a literal `(B`), which survives both that repaint style and the spaced full-screen redraw. ⚠️ **Never answer it with a blind `\r`.** The layout has changed under us at least twice, and Claude Code 2.1.252 dropped the option numbers, put "No, exit" FIRST and highlights IT by default, so the Enter that answered the old dialog now picks *exit* and the pane dies (`Pane is dead (status 1)`) seconds after the session starts. `trustDialogNextKey()` reads the `❯` marker and returns ONE step at a time (an arrow while the cursor is on the wrong option, Enter only once the screen shows it on the trust option), with the pane re-read between steps, so a dropped arrow costs a repaint instead of the session; a frame that does not say which option is highlighted returns null and waits for the next repaint. ⚠️ The LAST marked option in the text wins, because the direct-PTY fallback reads an append-only buffer where every repaint since launch is still present and an older frame must not out-vote the freshest one. ⚠️ Answering types into a live session, so THREE guards must all hold and none is redundant: a **startup-only window** (`TRUST_DIALOG_WINDOW_MS`, 90s, since the dialog renders before the main UI and leaving it open forever would let an agent transcript that merely QUOTES the dialog trigger an Enter, this file being an example), a **two-marker match** requiring a trust phrase AND one of the dialog's own confirm affordances (`isTrustDialogScreen`), and an **attempt cap** (`TRUST_DIALOG_MAX_ATTEMPTS`, 6: a keystroke can land while Ink is still mounting the widget and be dropped, which is the other half of why sessions got stuck here, but retrying forever would hammer keys into whatever came next; it was 3 while one Enter answered the dialog, and answering now costs at least two keystrokes). ⚠️ It reads `capturePaneText()` and falls back to a deliberately SHORT tail of the terminal buffer only on a direct-PTY session, which has no pane: that buffer is append-only, so a longer tail would keep re-matching a dialog answered minutes ago. ⚠️ **The scan must schedule its own next read** (`_trustDialogTimer`, cleared in `_clearAllTimers()`): it runs from the PTY `onData` handler, which was enough while one Enter answered the dialog, but the arrow that moves the cursor is the LAST output the pane produces, so a two-keystroke answer waiting on more output parks forever with the cursor sitting on the right option (measured on a live 2.1.252 spawn: cursor moved at 6 s, then nothing).
|
||||
|
||||
|
||||
+12
-5
@@ -52,13 +52,20 @@ Three capability fields carry a regular expression an override file can set: `di
|
||||
|
||||
`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 — and Codeman shows
|
||||
that as the session's watching badge, so a quiet pane waiting for its own background work
|
||||
does not read as a pane waiting for a human. `watchingLabel()` in `session-activity.ts` runs
|
||||
the pattern over the last few lines of a capture only, because the transcript above the
|
||||
composer quotes arbitrary text and a session that PRINTS "1 monitor" is not running one.
|
||||
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.
|
||||
|
||||
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
|
||||
`WATCHING_TAIL_LINES` rows, which is the part of the screen the CLI draws rather than the
|
||||
agent, and the pattern itself anchors on the separator that CLI's footer uses to join its
|
||||
items. Without both, an agent could silence its own idle alert by printing `· 1 monitor ·`
|
||||
into its output. The label is also ANSI-stripped and length-capped at the source, since it
|
||||
ends up on a badge and in an approval card.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -207,10 +207,17 @@ const CLAUDE: CliEntry = {
|
||||
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.
|
||||
watchingLine: String.raw`(\d+ (?:monitors?|shells?|teams?|local agents?|cloud sessions?|MCP tasks?|background tasks?|(?:background|remote) dynamic workflows?|Artifact comment monitors?))`,
|
||||
// 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.
|
||||
// ⚠️ The leading `·` is an anchor, not decoration. This pattern runs over the foot
|
||||
// of the screen, which is the one part of it the AGENT does not write, and the
|
||||
// separator is what keeps it on the footer's own item list. An agent that could get
|
||||
// a bare `1 monitor` matched would silence its own idle alert by printing it. A
|
||||
// footer that ever carries the chip as its only item therefore reports no watching
|
||||
// rather than opening that door. See `watchingLabel()` in `session-activity.ts`.
|
||||
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
|
||||
|
||||
+32
-15
@@ -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.
|
||||
@@ -95,13 +97,21 @@ export function isPaneQuiet(lastActivityAt: number, now: number, silenceMs: numb
|
||||
/**
|
||||
* How many lines at the foot of a pane capture may hold the background-work chip.
|
||||
*
|
||||
* Claude Code draws that chip on the last row of the screen, under its composer box
|
||||
* and under whatever status line the user configured, so five lines reach it with
|
||||
* room to spare. The ceiling is the point of the constant: the transcript above the
|
||||
* composer quotes arbitrary text, and a session that PRINTS the words "1 monitor"
|
||||
* must not be read as running one.
|
||||
* Claude Code draws that chip on the last row of the screen. The row above it is the
|
||||
* status line, which a user's own `statusLine` command writes, and two lines is what
|
||||
* covers the chip wherever a trailing blank or a one-line notice pushes it up by one.
|
||||
*
|
||||
* ⚠️ The ceiling is the security boundary, not a tidiness measure. The label is
|
||||
* PANE-DERIVED, so everything on that screen above the footer is text an agent wrote
|
||||
* itself, and an agent that printed `· 1 monitor ·` into its own output would silence
|
||||
* its own idle alert. Keep the window at the footer, keep each CLI's pattern anchored
|
||||
* on the separator its footer actually uses, and never widen this to a whole-pane
|
||||
* search.
|
||||
*/
|
||||
export const WATCHING_TAIL_LINES = 5;
|
||||
export const WATCHING_TAIL_LINES = 2;
|
||||
|
||||
/** 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`.
|
||||
@@ -112,21 +122,28 @@ export const WATCHING_TAIL_LINES = 5;
|
||||
* 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 line 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. The
|
||||
* answer is stripped of ANSI and capped, because it ends up on a badge and in an
|
||||
* approval card.
|
||||
*
|
||||
* @returns the label, or null when the pane shows no background work
|
||||
*/
|
||||
export function watchingLabel(paneText: string | null | undefined, pattern: RegExp): string | null {
|
||||
if (!paneText) return null;
|
||||
const lines = paneText
|
||||
const lines = stripAnsi(paneText)
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line !== '');
|
||||
if (lines.length === 0) return null;
|
||||
// A pattern compiled by compileVersionRegex() never carries the `g` flag, but a caller
|
||||
// reaching in from a test or a config reload might, and a stale lastIndex would make
|
||||
// the same screen match every other call.
|
||||
for (const line of lines.slice(-WATCHING_TAIL_LINES).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(lines.slice(-WATCHING_TAIL_LINES).join('\n'));
|
||||
if (!match) return null;
|
||||
const label = (match[1] ?? match[0]).trim();
|
||||
return label === '' ? null : label;
|
||||
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;
|
||||
}
|
||||
|
||||
+17
-1
@@ -498,7 +498,21 @@ 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)
|
||||
private _watching: string | null = null; // Background work the pane's own footer reports
|
||||
/**
|
||||
* 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 the background work ending is itself
|
||||
* what wakes the agent and repaints the pane. Do not add a timer to keep this fresh;
|
||||
* it would spend a `capture-pane` per idle session per tick to learn nothing.
|
||||
*/
|
||||
private _watching: string | null = null;
|
||||
/** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */
|
||||
private _workingLineRe: RegExp | undefined = undefined;
|
||||
/** Lazily compiled `capabilities.workDetect.watchingLine`. See _watchingLinePattern(). */
|
||||
@@ -2743,6 +2757,8 @@ export class Session extends EventEmitter {
|
||||
*/
|
||||
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;
|
||||
this._watching = watchingLabel(paneText, pattern);
|
||||
}
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -4617,6 +4617,10 @@ class CodemanApp {
|
||||
// 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>`);
|
||||
@@ -5277,7 +5281,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}:${richRow.watching}"`
|
||||
: '';
|
||||
|
||||
const inlineSessionActions = this.shouldInlineSessionActions();
|
||||
|
||||
@@ -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>` +
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -12382,6 +12382,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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', {});
|
||||
|
||||
@@ -15,7 +15,12 @@ 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, IDLE_SILENCE_MS } from '../src/session-activity.js';
|
||||
import {
|
||||
watchingLabel,
|
||||
WATCHING_TAIL_LINES,
|
||||
MAX_WATCHING_LABEL_CHARS,
|
||||
IDLE_SILENCE_MS,
|
||||
} from '../src/session-activity.js';
|
||||
|
||||
/** The registry's own pattern for Claude, which is what every consumer runs. */
|
||||
const CLAUDE_WATCHING = compileVersionRegex(getCli('claude')!.capabilities.workDetect!.watchingLine!)!;
|
||||
@@ -112,7 +117,7 @@ describe('watchingLabel', () => {
|
||||
// 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' +
|
||||
'⏺ 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();
|
||||
@@ -120,11 +125,31 @@ describe('watchingLabel', () => {
|
||||
|
||||
it('looks no further up the screen than the tail it declares', () => {
|
||||
const chip = '⏵⏵ bypass permissions on · 1 monitor · ← for agents';
|
||||
const blanks = Array(WATCHING_TAIL_LINES).fill(' still here').join('\n');
|
||||
// Blank lines are dropped before the tail is taken, so padding with them must not
|
||||
// push the footer out of range.
|
||||
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${blanks}\n`, CLAUDE_WATCHING)).toBeNull();
|
||||
expect(watchingLabel(`${chip}\n${below}\n`, CLAUDE_WATCHING)).toBeNull();
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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