Merge pull request #473 from irisitymichaelgrundberg/feat/session-watching-badge

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

# Conflicts:
#	src/config/cli-registry/stock.ts
This commit is contained in:
Codeman maintainer
2026-09-23 11:32:11 +02:00
35 changed files with 1650 additions and 26 deletions
+52
View File
@@ -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);
+43
View File
@@ -122,6 +122,49 @@ describe('workDetect.workingLine is guarded like every other config regex', () =
expect(compileVersionRegex(src), `${entry.id} declares a workingLine the guard refuses`).not.toBeNull();
}
});
it('holds the optional watchingLine to the same guard', () => {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
watchingLine: '(a+)+b',
};
}, 'this one runs over a pane capture every time a session settles, so it can freeze the event loop the same way');
});
it('bounds how far up the screen a config file may search', () => {
// The window is the injection guard: every row it adds is another row the agent
// itself may be able to write, and the label is what silences an idle alert.
for (const lines of [0, 9, 2.5]) {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
watchingLine: 'chip (\\d+)',
watchingLines: lines,
};
}, 'a config file must not be able to widen the search to the whole pane');
}
});
it('refuses a window with no pattern to bound', () => {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
watchingLines: 3,
};
}, 'a window with nothing to search is a typo whose failure is otherwise silent');
});
it('accepts every shipped watchingLine', () => {
for (const entry of STOCK_CLIS) {
const src = entry.capabilities.workDetect?.watchingLine;
if (!src) continue;
expect(compileVersionRegex(src), `${entry.id} declares a watchingLine the guard refuses`).not.toBeNull();
}
});
});
describe('no shell text can reach the command line', () => {
+47
View File
@@ -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))];
}
+54 -1
View File
@@ -19,8 +19,11 @@ function fakeElement(): any {
type: '',
dataset: {},
style: {},
attrs: {} as Record<string, string>,
children: [] as any[],
setAttribute() {},
setAttribute(name: string, value: string) {
el.attrs[name] = value;
},
appendChild(child: any) {
el.children.push(child);
return child;
@@ -485,3 +488,53 @@ describe('mobile overview run picker (CLI availability gating)', () => {
expect(gate).toContain('isCliAvailable');
});
});
describe('mobile overview watching badge', () => {
it('carries what the pane says is running in the background', () => {
const app = loadOverviewApp();
const model = app.buildMobileOverviewModel({
sessions: [session({ id: 'a', status: 'idle', watching: '1 monitor' }), session({ id: 'b', status: 'idle' })],
cases: CASES,
});
const rows = Object.fromEntries(model.current.map((r: any) => [r.id, r.watching]));
expect(rows).toEqual({ a: '1 monitor', b: '' });
});
it('leaves a session that is ALSO blocked on a dialog in NEEDS YOU', () => {
// An agent can arm a monitor and ask the user a question in the same breath, so the
// badge adds a fact to the row and never moves it out of the group that says a human
// is needed. Only the row's own pill decides that.
const app = loadOverviewApp();
const model = app.buildMobileOverviewModel({
sessions: [session({ id: 'a', status: 'idle', watching: '2 shells' })],
cases: CASES,
pendingHooks: new Map([['a', new Set(['permission_prompt'])]]),
});
expect(model.needsYou.map((r: any) => r.id)).toEqual(['a']);
expect(model.needsYou[0].pill).toBe('needs you');
expect(model.needsYou[0].watching).toBe('2 shells');
});
it('says one word and puts the detail where every surface can reach it', () => {
const app = loadOverviewApp();
const badge = app._buildWatchingBadge('1 monitor', 'mobile-overview-pill');
expect(badge.className).toBe('mobile-overview-pill mobile-overview-pill--watching');
expect(badge.textContent).toBe('watching');
expect(badge.title).toBe('Still running in the background: 1 monitor');
// A phone has no hover target and a screen reader reads neither the class nor the
// tooltip, so the label has to be here too or this surface says only "watching".
expect(badge.attrs['aria-label']).toBe('Still running in the background: 1 monitor');
});
it('takes the pill class of whichever surface asks for it', () => {
// The phone's pill styles live inside a media query the desktop rail never enters,
// so the rail passes its own base class and gets the same badge in its own clothes.
const app = loadOverviewApp();
expect(app._buildWatchingBadge('1 shell', 'home-sessions-pill').className).toBe(
'home-sessions-pill home-sessions-pill--watching'
);
});
});
+6
View File
@@ -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[] = [];
+59
View File
@@ -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
View File
@@ -22,6 +22,21 @@ describe('session listener wiring', () => {
expect(registerAttachment).toHaveBeenNthCalledWith(2, 'wiring-attach-source-test', '/tmp/report.pdf', 'external');
});
it('pushes the session state when the watching label changes on its own', () => {
// The badge appears on the idle transition, which broadcasts anyway. It goes AWAY
// when the background work ends, and a CLI can do that without taking a turn — codex
// repaints its background-terminal row away and stays idle — so nothing else fires
// and every open page would keep drawing a badge the server had already dropped.
const session = new Session({ id: 'wiring-watching-test', workingDir: '/tmp', mode: 'codex' });
const broadcastSessionStateDebounced = vi.fn();
const deps = { broadcastSessionStateDebounced } as unknown as Parameters<typeof createSessionListeners>[1];
const refs = createSessionListeners(session, deps);
refs.watchingChanged();
expect(broadcastSessionStateDebounced).toHaveBeenCalledWith('wiring-watching-test');
});
/** The listener reads the setting asynchronously; let its promise chain settle. */
const flush = () => new Promise((resolve) => setTimeout(resolve, 5));
+50
View File
@@ -73,3 +73,53 @@ describe('vertical session navigation UX contract', () => {
expect(i18n).toContain("'Adjust only session names in the vertical sidebar.':");
});
});
describe('watching badge on a rich session row', () => {
it('reads the label off the session payload', () => {
expect(app).toContain("watching: typeof session.watching === 'string' ? session.watching : ''");
});
it('renders it beside the state pill rather than in place of it', () => {
// A session can be watching a monitor AND holding a question for the user, so the
// pill that says which one still decides the row; this badge only adds a fact.
const meta = app.slice(app.indexOf('_sidebarRichMetaHTML(row) {'));
const body = meta.slice(0, meta.indexOf('_sidebarRichStampText(timestamp, format) {'));
expect(body).toContain('tab-pill tab-pill--${escapeHtml(row.state)}');
expect(body).toContain('tab-pill tab-pill--watching');
expect(body).toContain('Still running in the background:');
});
it('re-renders the row when the background work changes', () => {
// The meta line is rebuilt only when this signature moves, so a badge left out of
// it would appear and disappear a render late, or not at all.
expect(app).toContain(
'const sig = `${row.state}:${row.since ? row.since.at : 0}:${row.createdAt}:${row.watching}`'
);
});
it('escapes the label everywhere it reaches markup', () => {
// `watching` is pane-derived and a config-supplied pattern decides what its capture
// group holds, so every interpolation of it into HTML has to go through escapeHtml().
// The row is installed with innerHTML, which makes an unescaped quote in that
// attribute an injection rather than a cosmetic bug.
expect(app).toContain('${richRow.createdAt}:${escapeHtml(richRow.watching)}"');
expect(app).not.toContain('${richRow.createdAt}:${richRow.watching}"');
});
it('words the tooltip exactly as the phone overview does', () => {
// Both files build this sentence themselves, deliberately, so that a stale cached
// module still renders a complete row. Substring-matching the prefix would let the
// two drift; the whole sentence is what has to agree.
const overview = readFileSync(resolve(publicDir, 'mobile-overview.js'), 'utf8');
expect(overview).toContain("'Still running in the background: ' + label");
expect(app).toContain('`Still running in the background: ${row.watching}`');
});
it('colours it with the accent, never with the two colours that mean a human is needed', () => {
const rule = styles.slice(styles.indexOf('.tab-pill--watching'));
const block = rule.slice(0, rule.indexOf('}'));
expect(block).toContain('var(--accent)');
expect(block).not.toContain('var(--red)');
expect(block).not.toContain('var(--yellow)');
});
});
+396
View File
@@ -0,0 +1,396 @@
/**
* The badge a session wears while work it started in the background is still running.
*
* The bug this pins: an agent that arms a monitor, backgrounds a shell or hands work to a
* cloud session is told to end its turn, so the pane falls quiet, Claude Code's idle
* notification arrives a minute later, and every Codeman surface files the session under
* NEEDS YOU. Nothing wants the user there. The CLI itself says so on the last row of its
* screen (`⏵⏵ bypass permissions on · 1 monitor · ← for agents`), and reading that row is
* what tells a session waiting for its own background work from one waiting for a human.
*
* The pane fixtures below are verbatim captures (`tmux -L codeman capture-pane -p`) from a
* live Claude Code 2.1.278 session on 2026-09-21.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { Session } from '../src/session.js';
import { getCli } from '../src/config/cli-registry/index.js';
import { compileVersionRegex } from '../src/config/cli-registry/patterns.js';
import {
watchingLabel,
WATCHING_TAIL_LINES,
MAX_WATCHING_LABEL_CHARS,
IDLE_SILENCE_MS,
} from '../src/session-activity.js';
/** The registry's own patterns, which are what every consumer runs. */
const CLAUDE_WATCHING = compileVersionRegex(getCli('claude')!.capabilities.workDetect!.watchingLine!)!;
const CODEX_WATCHING = compileVersionRegex(getCli('codex')!.capabilities.workDetect!.watchingLine!)!;
const CODEX_TAIL = getCli('codex')!.capabilities.workDetect!.watchingLines!;
/**
* The foot of a Codex pane, verbatim (codex-cli 0.154.0, 2026-09-22). Codex does not
* write on its last row: the status line is there, the composer above it, and the
* background-terminal row above that, which is why codex declares its own window.
*/
const CODEX_STATUS =
' gpt-5.6-sol medium · Context 98% left · ~/codeman-cases/codex-probe · 5h 99% left · weekly 94% left';
const CODEX_WITH_TERMINAL = [
'• OK',
'',
' 1 background terminal running · /ps to view · /stop to close',
'',
'',
'› Ask Codex to do anything',
'',
CODEX_STATUS,
'',
].join('\n');
const CODEX_STOPPED = [
'• OK',
'',
'• Stopping all background terminals.',
'',
'',
'› Ask Codex to do anything',
'',
CODEX_STATUS,
'',
].join('\n');
/** The bottom of a Claude pane: composer, the user's status line, the footer row. */
function pane(footer: string, body = ''): string {
return (
body +
'╭──────────────────────────────────────╮\n' +
'│ ❯ │\n' +
'╰──────────────────────────────────────╯\n' +
' ~/innovi/gtd-board [main] Opus 5 ctx: 11%\n' +
` ${footer}\n`
);
}
const WITH_MONITOR = pane('⏵⏵ bypass permissions on · 1 monitor · ← for agents');
const WITH_SHELL = pane('⏵⏵ bypass permissions on · 1 shell · ← for agents');
const NOTHING_RUNNING = pane('⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents');
/** A composer repaint: the frame Claude ships roughly once a second while working. */
const COMPOSER_REPAINT =
'\x1b[31;1H\x1b[38;5;246m❯\xa0\x1b[39m\x1b[0m\x1b[33;1H \x1b[38;5;246mOpus 5 in:143,699 out:669 ctx:14%\x1b[39m';
type SessionInternals = {
_handleTerminalOutput(data: string): void;
_detectInteractiveActivity(data: string): void;
};
/** One PTY chunk, exactly as the interactive handler sees it. */
function feed(session: Session, data: string): void {
const internals = session as unknown as SessionInternals;
internals._handleTerminalOutput(data);
internals._detectInteractiveActivity(data);
}
/** A session whose mux reports a fixed (or scripted) screen for the pane probe to read. */
function withFakePane(screen: string | (() => string), mode: 'claude' | 'codex' = 'claude'): Session {
const read = typeof screen === 'function' ? screen : () => screen;
const mux = {
isAvailable: () => true,
capturePaneText: () => read(),
} as unknown as NonNullable<ConstructorParameters<typeof Session>[0]>['mux'];
return new Session({
workingDir: '/tmp',
mode,
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
} as ConstructorParameters<typeof Session>[0]);
}
/** Codex's own composer repaint, the frame that arms its idle confirmation. */
const CODEX_COMPOSER_REPAINT = '\x1b[31;1H\x1b[38;5;246m›\xa0\x1b[39m\x1b[0m';
/** Run one turn and let it end, which is when the probe reads the screen. */
function runAndSettle(session: Session, repaint: string = COMPOSER_REPAINT): void {
for (let i = 0; i < 3; i++) {
feed(session, repaint);
vi.advanceTimersByTime(1000);
}
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
}
describe('watchingLabel', () => {
it('reads the label off the footer row', () => {
expect(watchingLabel(WITH_MONITOR, CLAUDE_WATCHING)).toBe('1 monitor');
expect(watchingLabel(WITH_SHELL, CLAUDE_WATCHING)).toBe('1 shell');
});
it('reads every kind of background work the CLI names', () => {
const labels = [
'2 monitors',
'3 shells',
'1 cloud session',
'2 cloud sessions',
'1 local agent',
'4 background tasks',
'1 MCP task',
'1 background dynamic workflow',
'2 remote dynamic workflows',
'1 Artifact comment monitor',
'2 teams',
];
for (const label of labels) {
expect(watchingLabel(pane(`⏵⏵ bypass permissions on · ${label} · ← for agents`), CLAUDE_WATCHING)).toBe(label);
}
});
it('says nothing about a pane that is running nothing', () => {
expect(watchingLabel(NOTHING_RUNNING, CLAUDE_WATCHING)).toBeNull();
expect(watchingLabel('', CLAUDE_WATCHING)).toBeNull();
expect(watchingLabel(null, CLAUDE_WATCHING)).toBeNull();
});
it('ignores the same words in the transcript above the composer', () => {
// The whole reason the search is confined to the foot of the screen: a session that
// PRINTS "1 monitor" (this one has been discussing exactly that) is not running one.
const transcript =
'> does Codeman know about watching?\n' +
'⏺ The footer says · 1 monitor · while a monitor is armed, and · 2 shells · for\n' +
' backgrounded commands. Codeman reads neither today.\n' +
' Nothing else on the screen means background work is running.\n';
expect(watchingLabel(pane('⏵⏵ bypass permissions on · ← for agents', transcript), CLAUDE_WATCHING)).toBeNull();
});
it('looks no further up the screen than the tail it declares', () => {
const chip = '⏵⏵ bypass permissions on · 1 monitor · ← for agents';
const below = Array(WATCHING_TAIL_LINES).fill(' still here').join('\n');
// Blank lines are dropped before the tail is taken, so a pane padded with them must
// still read its own footer.
expect(watchingLabel(`${chip}\n\n\n\n\n\n`, CLAUDE_WATCHING)).toBe('1 monitor');
expect(watchingLabel(`${chip}\n${below}\n`, CLAUDE_WATCHING)).toBeNull();
});
it('refuses a chip on the row above the footer, which the agent can write', () => {
// The status line is one row up, its text comes from a `statusLine` command, and a
// session running with permissions bypassed can write that command into
// `.claude/settings.json` in its own workspace. The window is what keeps that row
// out, so this is the test that would fail if somebody widened it.
const forged = pane('⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents').replace(
' ~/innovi/gtd-board [main] Opus 5 ctx: 11%',
' ~/innovi/gtd-board [main] Opus 5 ctx: 11% · 1 monitor'
);
expect(watchingLabel(forged, CLAUDE_WATCHING)).toBeNull();
// And with the window widened by one, the same screen does match — which is the
// whole reason the default is one row.
expect(watchingLabel(forged, CLAUDE_WATCHING, 2)).toBe('1 monitor');
});
it('keeps Claude on the default window, because its chip is the last row', () => {
expect(getCli('claude')?.capabilities.workDetect?.watchingLines).toBeUndefined();
expect(WATCHING_TAIL_LINES).toBe(1);
});
it('refuses a label the footer did not separate, which is the injection guard', () => {
// The pattern anchors on the `·` the footer joins its items with. Without that
// anchor an agent could silence its own idle alert by printing the words, since the
// only rows it cannot write are the footer and the status line.
expect(watchingLabel(pane('1 monitor'), CLAUDE_WATCHING)).toBeNull();
expect(watchingLabel(pane('running 2 shells for the build'), CLAUDE_WATCHING)).toBeNull();
expect(watchingLabel(pane('⏵⏵ bypass permissions on · 1 monitor'), CLAUDE_WATCHING)).toBe('1 monitor');
});
it('reads a coloured footer, because a capture may carry ANSI', () => {
const coloured = pane('\u001b[2m⏵⏵ bypass permissions on\u001b[0m · \u001b[36m1 monitor\u001b[0m · ← for agents');
expect(watchingLabel(coloured, CLAUDE_WATCHING)).toBe('1 monitor');
});
it('caps the label, because it ends up on a badge and in an approval card', () => {
const long = `· ${'9'.repeat(MAX_WATCHING_LABEL_CHARS * 2)} monitors`;
const label = watchingLabel(pane(`⏵⏵ bypass permissions on ${long} · ← for agents`), CLAUDE_WATCHING);
expect(label?.length).toBe(MAX_WATCHING_LABEL_CHARS);
});
it('survives a pattern handed to it with the global flag set', () => {
// compileVersionRegex() never sets `g`, but a test or a reloaded config might, and a
// sticky lastIndex would make the same screen match every other call.
const global = new RegExp(CLAUDE_WATCHING.source, 'g');
expect(watchingLabel(WITH_MONITOR, global)).toBe('1 monitor');
expect(watchingLabel(WITH_MONITOR, global)).toBe('1 monitor');
});
});
describe('Session.watching', () => {
afterEach(() => {
vi.useRealTimers();
});
it('carries what the pane reported once the turn ends', () => {
vi.useFakeTimers();
const session = withFakePane(WITH_MONITOR);
expect(session.watching).toBeNull();
runAndSettle(session);
expect(session.status).toBe('idle');
expect(session.watching).toBe('1 monitor');
});
it('lets the badge go when the background work is over', () => {
vi.useFakeTimers();
const screen = { text: WITH_MONITOR };
const session = withFakePane(() => screen.text);
runAndSettle(session);
expect(session.watching).toBe('1 monitor');
screen.text = NOTHING_RUNNING;
runAndSettle(session);
expect(session.watching).toBeNull();
});
it('announces the change, because the session status does not move with it', () => {
// Measured on codex: a background terminal finishing repaints the row away and the
// session is idle before and after, so no other event fires. Without this one the
// server drops the label and every open page goes on drawing the badge.
vi.useFakeTimers();
const screen = { text: WITH_MONITOR };
const session = withFakePane(() => screen.text);
const changes: (string | null)[] = [];
session.on('watchingChanged', () => changes.push(session.watching));
runAndSettle(session);
expect(changes).toEqual(['1 monitor']);
// A repaint that carries the composer glyph but no chip: the pane went quiet again
// without a turn, which is exactly the case the event exists for.
screen.text = NOTHING_RUNNING;
feed(session, COMPOSER_REPAINT);
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
expect(changes).toEqual(['1 monitor', null]);
expect(session.status).toBe('idle');
});
it('says nothing while the answer stays the same', () => {
vi.useFakeTimers();
const session = withFakePane(WITH_MONITOR);
const changes: (string | null)[] = [];
session.on('watchingChanged', () => changes.push(session.watching));
runAndSettle(session);
runAndSettle(session);
runAndSettle(session);
expect(changes).toEqual(['1 monitor']);
});
it('keeps its last answer when the screen cannot be read', () => {
vi.useFakeTimers();
const screen: { text: string | null } = { text: WITH_MONITOR };
const session = withFakePane(() => screen.text as string);
runAndSettle(session);
expect(session.watching).toBe('1 monitor');
// A capture that fails is not evidence that nothing is running, which is the same
// rule the working probe applies to its own null.
screen.text = null;
runAndSettle(session);
expect(session.watching).toBe('1 monitor');
});
it('reads Codex own row, three up from the bottom of its screen', () => {
vi.useFakeTimers();
const session = withFakePane(CODEX_WITH_TERMINAL, 'codex');
runAndSettle(session, CODEX_COMPOSER_REPAINT);
expect(session.status).toBe('idle');
expect(session.watching).toBe('1 background terminal');
});
it('reports nothing for a CLI whose screen nobody has characterised', () => {
vi.useFakeTimers();
expect(getCli('gemini')?.capabilities.workDetect).toBeUndefined();
const session = withFakePane(WITH_MONITOR, 'gemini');
runAndSettle(session);
expect(session.watching).toBeNull();
});
it('never reads another CLI screen', () => {
vi.useFakeTimers();
// Each pattern is anchored on chrome its own CLI draws, so neither can fire on the
// other's pane. A shared fallback would have both reading a screen nobody measured.
const codexOnClaudeScreen = withFakePane(WITH_MONITOR, 'codex');
runAndSettle(codexOnClaudeScreen, CODEX_COMPOSER_REPAINT);
expect(codexOnClaudeScreen.watching).toBeNull();
const claudeOnCodexScreen = withFakePane(CODEX_WITH_TERMINAL, 'claude');
runAndSettle(claudeOnCodexScreen);
expect(claudeOnCodexScreen.watching).toBeNull();
});
it('rides along on the payload every session surface reads', () => {
vi.useFakeTimers();
const session = withFakePane(WITH_SHELL);
runAndSettle(session);
expect(session.toLightDetailedState().watching).toBe('1 shell');
});
});
describe('the row Codex draws', () => {
it('reads the label, and only while a terminal is running', () => {
expect(watchingLabel(CODEX_WITH_TERMINAL, CODEX_WATCHING, CODEX_TAIL)).toBe('1 background terminal');
expect(watchingLabel(CODEX_STOPPED, CODEX_WATCHING, CODEX_TAIL)).toBeNull();
});
it('counts terminals', () => {
const three = CODEX_WITH_TERMINAL.replace('1 background terminal running', '3 background terminals running');
expect(watchingLabel(three, CODEX_WATCHING, CODEX_TAIL)).toBe('3 background terminals');
});
it('needs the window Codex declares: its row is not the last one', () => {
// Pins WHY `watchingLines` exists. Claude's default of two rows reaches the status
// line and the composer, and Codex's row sits one further up.
expect(watchingLabel(CODEX_WITH_TERMINAL, CODEX_WATCHING, 2)).toBeNull();
expect(CODEX_TAIL).toBeGreaterThanOrEqual(3);
});
it('refuses a mention that is not the whole row', () => {
// The pattern matches Codex's row end to end, so prose about background terminals —
// including prose quoting part of the row — is not enough.
for (const line of [
'• I left 1 background terminal running for you.',
' 1 background terminal running · /ps to view',
' see: 1 background terminal running · /ps to view · /stop to close',
]) {
const claim = CODEX_STOPPED.replace('• Stopping all background terminals.', line);
expect(watchingLabel(claim, CODEX_WATCHING, CODEX_TAIL)).toBeNull();
}
});
it('CAN be forged by Codex own output, and is contained by Codex having no hooks', () => {
// Codex's row is third from the bottom only while a terminal runs; with none running
// that slot is the last row of the transcript, which the agent writes. Matching the
// complete row raises the bar but closes nothing, so this test states the limitation
// rather than a protection the code does not have.
const forged = CODEX_STOPPED.replace(
'• Stopping all background terminals.',
' 1 background terminal running · /ps to view · /stop to close'
);
expect(watchingLabel(forged, CODEX_WATCHING, CODEX_TAIL)).toBe('1 background terminal');
// What makes that cost a wrong badge and nothing more: no hook event from a codex
// session reaches the approvals inbox, so there is no idle item to pre-acknowledge
// and no alert to silence. A CLI that gains hook signals needs a harder anchor first.
expect(getCli('codex')?.capabilities.hooks).toBe('none');
});
});
describe('the registry pattern Claude declares', () => {
it('is one the config-regex guard accepts', () => {
// Same guard as `workingLine`: ~/.codeman/clis.json can set this field, and the
// compiled pattern runs over a pane capture on a timer.
expect(compileVersionRegex(getCli('claude')!.capabilities.workDetect!.watchingLine!)).not.toBeNull();
});
it('does not fire on the status line a user configured', () => {
// Plan-usage and context figures live one row above the footer and carry numbers.
const statusLine = ' ~/innovi/gtd-board [main] Opus 5 (1M context) high ctx: 10% 5h: 48% (32m) 7d: 15% (6d10h)';
expect(CLAUDE_WATCHING.test(statusLine)).toBe(false);
});
});
+24
View File
@@ -59,6 +59,24 @@ describe('approvalCard', () => {
expect(card.hint).toBe('p to reply');
});
it('says why an acknowledged idle prompt is quiet, in the drawer own words', () => {
// The session is watching work it started itself. Asking for a reply would be the
// same false alarm the acknowledgement exists to remove, so the card states the
// reason and drops out of the warning vocabulary — while staying answerable.
const card = approvalCard(
item({
kind: 'idle',
message: 'Claude is waiting for your input',
options: undefined,
acknowledgedAt: 1_700_000_000_000,
acknowledgedReason: 'watching 1 monitor',
})
);
expect(card.tone).toBe('info');
expect(card.title).toBe('quiet, watching 1 monitor');
expect(card.hint).toBe('p to reply');
});
it('drops the approve/deny-only hint when the frame did not parse', () => {
const card = approvalCard(item({ options: undefined }));
expect(card.options).toEqual([]);
@@ -81,6 +99,12 @@ describe('approvalTone', () => {
expect(approvalTone(item({ kind: 'question' }))).toBe('err');
expect(approvalTone(item({ kind: 'idle' }))).toBe('warn');
});
it('is neither for a prompt that opened acknowledged', () => {
expect(approvalTone(item({ kind: 'idle', acknowledgedReason: 'watching 2 shells' }))).toBe('info');
// A dialog stays red whatever else the session started.
expect(approvalTone(item({ kind: 'permission', acknowledgedReason: 'watching 2 shells' }))).toBe('err');
});
});
describe('approvalAnswerForKey', () => {
+66
View File
@@ -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']);
+32
View File
@@ -11,6 +11,7 @@ import { charWidth, stripStyles, toDisplayLines, visibleWidth } from '../../src/
import { composerMove, createComposer } from '../../src/tui/tui-composer.js';
import { computeLayout, needsBanner } from '../../src/tui/tui-layout.js';
import { createTuiModel, type TuiModelStore } from '../../src/tui/tui-model.js';
import type { ApprovalItem } from '../../src/web/approval-inbox.js';
import {
composerCursorCell,
detectGlyphTier,
@@ -19,6 +20,7 @@ import {
formatPlanUsage,
formatTokens,
glyphsFor,
pendingApprovalCount,
renderFrame,
rowLabel,
type TuiRenderOptions,
@@ -682,3 +684,33 @@ describe('the unicode glyph set is safe to render', () => {
expect(every.join('')).not.toContain('\u270B');
});
});
describe('pendingApprovalCount', () => {
const prompt = (over: Partial<ApprovalItem>): ApprovalItem => ({
id: 'bbb2:1',
sessionId: 'bbb2',
sessionName: 'w6-docs',
kind: 'idle',
createdAt: NOW - 30_000,
...over,
});
it('counts a prompt nobody has seen', () => {
const model = fixture();
model.setApprovals([prompt({})]);
expect(pendingApprovalCount(model)).toBe(1);
});
it('does not count one whose alert is already spent', () => {
// Both ways an item gets acknowledged: a human opening the session elsewhere, and the
// inbox opening it that way for a session watching its own background work. The row
// has left NEEDS YOU by the same flag, so a number in the header would point at a
// group the reader can see is empty.
const model = fixture();
model.setApprovals([prompt({ acknowledgedAt: NOW - 20_000 })]);
expect(pendingApprovalCount(model)).toBe(0);
model.setApprovals([prompt({ acknowledgedAt: NOW - 20_000, acknowledgedReason: 'watching 1 monitor' })]);
expect(pendingApprovalCount(model)).toBe(0);
});
});
+226
View File
@@ -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');
});
});