mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(session): say when a session is watching its own background work
An agent that arms a monitor, backgrounds a shell or hands a task to a cloud session is told to end its turn. The pane then falls quiet, Claude Code's idle_prompt notification arrives a minute later, and every surface files the session under NEEDS YOU with nothing for a human to answer. Claude states what it is still running on the last row of its screen (`⏵⏵ bypass permissions on · 1 monitor · ← for agents`). That row is now `capabilities.workDetect.watchingLine` in the CLI registry, guarded by compileVersionRegex() like every other config regex, and the idle probe reads it off the capture it already takes: `watchingLabel()` in session-activity.ts searches the last five lines only, so a session that PRINTS "1 monitor" is not mistaken for one running it. The label lands on Session.watching and rides toLightDetailedState() out to every surface. The phone overview, the desktop home rail and the rich sidebar rows wear it as a `watching` badge in the accent colour, beside the state pill and never in place of it: an agent can arm a monitor and ask a question in the same breath, and only the pill says which. Verified end to end against a throwaway session on an isolated beta instance: the payload carried `watching: "1 monitor"` once the turn ended, the badge rendered next to a yellow `waiting` pill, and both cleared when the monitor died. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9466acfc1a
commit
3f2cde2db7
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'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.
|
||||
+12
-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? } — 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,19 @@ 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 — 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.
|
||||
Group 1 is the label, and a CLI that declares no pattern reports no background work.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -308,6 +308,16 @@ 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(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
@@ -206,6 +206,11 @@ 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.
|
||||
watchingLine: String.raw`(\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
|
||||
|
||||
@@ -344,6 +344,14 @@ 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 chip this CLI draws at the foot of its screen while
|
||||
* work it started in the background is still running, e.g. Claude's `· 1 monitor ·`.
|
||||
* 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;
|
||||
};
|
||||
/** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */
|
||||
requiresMux: boolean;
|
||||
|
||||
@@ -91,3 +91,42 @@ export function isSustainedActivity(streak: ActivityStreak | null, streakMs: num
|
||||
export function isPaneQuiet(lastActivityAt: number, now: number, silenceMs: number = IDLE_SILENCE_MS): boolean {
|
||||
return now - lastActivityAt >= silenceMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many lines at the foot of a pane capture may hold the background-work chip.
|
||||
*
|
||||
* Claude Code draws that chip on the last row of the screen, under its composer box
|
||||
* and under whatever status line the user configured, so five lines reach it with
|
||||
* room to spare. The ceiling is the point of the constant: the transcript above the
|
||||
* composer quotes arbitrary text, and a session that PRINTS the words "1 monitor"
|
||||
* must not be read as running one.
|
||||
*/
|
||||
export const WATCHING_TAIL_LINES = 5;
|
||||
|
||||
/**
|
||||
* What a pane says is still running in the background, e.g. `1 monitor` or `2 shells`.
|
||||
*
|
||||
* The CLI writes that chip while a monitor, a backgrounded shell or a cloud session it
|
||||
* started is still going, which is exactly the case where the agent has ended its turn
|
||||
* without wanting anything from the user. `pattern` comes from the CLI's own registry
|
||||
* entry (`capabilities.workDetect.watchingLine`); group 1 is the label when the pattern
|
||||
* declares one, and the whole match stands in when it does not.
|
||||
*
|
||||
* @returns the label, or null when the pane shows no background work
|
||||
*/
|
||||
export function watchingLabel(paneText: string | null | undefined, pattern: RegExp): string | null {
|
||||
if (!paneText) return null;
|
||||
const lines = paneText
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line !== '');
|
||||
if (lines.length === 0) return null;
|
||||
// A pattern compiled by compileVersionRegex() never carries the `g` flag, but a caller
|
||||
// reaching in from a test or a config reload might, and a stale lastIndex would make
|
||||
// the same screen match every other call.
|
||||
pattern.lastIndex = 0;
|
||||
const match = pattern.exec(lines.slice(-WATCHING_TAIL_LINES).join('\n'));
|
||||
if (!match) return null;
|
||||
const label = (match[1] ?? match[0]).trim();
|
||||
return label === '' ? null : label;
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
trackActivityStreak,
|
||||
isSustainedActivity,
|
||||
isPaneQuiet,
|
||||
watchingLabel,
|
||||
IDLE_RECHECK_MS,
|
||||
PANE_PROBE_MIN_INTERVAL_MS,
|
||||
PANE_PROBE_RECHECK_MS,
|
||||
@@ -497,8 +498,11 @@ 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
|
||||
/** 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;
|
||||
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
|
||||
@@ -1118,6 +1122,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.
|
||||
@@ -1675,6 +1689,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: {
|
||||
@@ -2709,9 +2724,43 @@ 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();
|
||||
if (!pattern || paneText === null) return;
|
||||
this._watching = watchingLabel(paneText, pattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const src = getCli(this.mode)?.capabilities.workDetect?.watchingLine;
|
||||
this._watchingLineRe = src ? compileVersionRegex(src) : null;
|
||||
}
|
||||
return this._watchingLineRe;
|
||||
}
|
||||
|
||||
/**
|
||||
* The regex matching this CLI's "a turn is running" status line.
|
||||
*
|
||||
@@ -3963,6 +4012,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();
|
||||
|
||||
|
||||
+13
-1
@@ -4574,6 +4574,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,
|
||||
};
|
||||
@@ -4609,6 +4614,13 @@ 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.
|
||||
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 —
|
||||
@@ -4646,7 +4658,7 @@ 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}`;
|
||||
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;
|
||||
|
||||
@@ -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,35 @@ 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;
|
||||
badge.title = watchingBadgeTitle(label);
|
||||
return badge;
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Age stamps: started / how long in this state
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -2961,6 +2961,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);
|
||||
|
||||
@@ -16249,6 +16249,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. */
|
||||
@@ -18311,6 +18322,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.
|
||||
|
||||
|
||||
@@ -122,6 +122,24 @@ 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('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))];
|
||||
}
|
||||
|
||||
@@ -485,3 +485,50 @@ 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 in the tooltip', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,3 +73,35 @@ 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('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,220 @@
|
||||
/**
|
||||
* 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, 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!)!;
|
||||
|
||||
/** 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]);
|
||||
}
|
||||
|
||||
/** Run one turn and let it end, which is when the probe reads the screen. */
|
||||
function runAndSettle(session: Session): void {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
feed(session, COMPOSER_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 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.
|
||||
expect(watchingLabel(`${chip}\n\n\n\n\n\n`, CLAUDE_WATCHING)).toBe('1 monitor');
|
||||
expect(watchingLabel(`${chip}\n${blanks}\n`, CLAUDE_WATCHING)).toBeNull();
|
||||
});
|
||||
|
||||
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('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('reports nothing for a CLI whose footer nobody has characterised', () => {
|
||||
vi.useFakeTimers();
|
||||
expect(getCli('codex')?.capabilities.workDetect?.watchingLine).toBeUndefined();
|
||||
// Codex draws its own composer glyph, so this session settles the same way; what it
|
||||
// must not do is read Claude's footer on a screen that is not Claude's.
|
||||
const session = withFakePane(WITH_MONITOR, 'codex');
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
feed(session, '\x1b[31;1H\x1b[38;5;246m›\xa0\x1b[39m\x1b[0m');
|
||||
vi.advanceTimersByTime(1000);
|
||||
}
|
||||
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
|
||||
|
||||
expect(session.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 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user