mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(approvals): merge-time fixes for the watching badge (#473)
- session.ts: a pane capture that fails now CLEARS the watching label (and emits watchingChanged so pages drop the badge) instead of keeping the last one, so a failed capture degrades toward an alert rather than pre-acknowledging the next real idle prompt. Test updated; invariant noted in architecture-invariants. - approvals-ui.js: the header bell counts only unacknowledged items (pendingApprovalsCount), matching codeman tui's pendingApprovalCount(); pinned in watching-no-alert.test.ts. - mobile-overview.js: move the orphaned "Pill copy per state" JSDoc back onto MOBILE_OVERVIEW_PILL_LABEL. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -92,7 +92,7 @@ Tests: `test/docker-hosts.test.ts`, `test/docker-exec-options.test.ts`, `test/do
|
||||
|
||||
### The watching signal (a quiet pane that is not waiting for you)
|
||||
|
||||
**A session that armed a monitor, backgrounded a shell or started a background terminal ends its turn and goes quiet, and a minute later Claude Code's idle notification arrives.** Before this existed, that prompt became an approval item like any other, so every surface filed the session under NEEDS YOU with nothing for a human to answer. The CLI says which kind of quiet it is on its own screen, and reading that row is the whole mechanism: `capabilities.workDetect.watchingLine` (optional, per CLI) plus `watchingLines` (how many non-blank rows at the foot of the screen may hold it, default `WATCHING_TAIL_LINES` = 1). `_confirmIdle()` already captures the pane at the moment a turn ends, so `_readWatching()` runs `watchingLabel()` (pure, `session-activity.ts`) over that same capture; the label lands on `Session.watching` and rides `toLightDetailedState()` out to every payload. ⚠️ It is cached BESIDE `_lastPaneProbeWorking` and goes stale with it, because the probe returns its cached boolean without re-capturing inside `PANE_PROBE_MIN_INTERVAL_MS` and a label from a capture nobody took is a guess. ⚠️ It then FREEZES once `_confirmIdle()` concludes — nothing looks at the pane again until it produces output — which is correct rather than tolerable, since work ending repaints the pane either way (a monitor firing wakes the agent; codex drops its background-terminal row by itself); a timer to keep it fresh would spend a `capture-pane` per idle session per tick to learn nothing. ⚠️ A server restart looks like a hole in that and is not one: the field is live state and starts empty, but reconciliation re-attaches the pane and the attach repaint arms the idle confirmation, which probes and re-reads the label with no input from anyone (measured 2026-09-23, back within ~20 s). A restored session showing no label has no chip on its screen.
|
||||
**A session that armed a monitor, backgrounded a shell or started a background terminal ends its turn and goes quiet, and a minute later Claude Code's idle notification arrives.** Before this existed, that prompt became an approval item like any other, so every surface filed the session under NEEDS YOU with nothing for a human to answer. The CLI says which kind of quiet it is on its own screen, and reading that row is the whole mechanism: `capabilities.workDetect.watchingLine` (optional, per CLI) plus `watchingLines` (how many non-blank rows at the foot of the screen may hold it, default `WATCHING_TAIL_LINES` = 1). `_confirmIdle()` already captures the pane at the moment a turn ends, so `_readWatching()` runs `watchingLabel()` (pure, `session-activity.ts`) over that same capture; the label lands on `Session.watching` and rides `toLightDetailedState()` out to every payload. ⚠️ It is cached BESIDE `_lastPaneProbeWorking` and goes stale with it, because the probe returns its cached boolean without re-capturing inside `PANE_PROBE_MIN_INTERVAL_MS` and a label from a capture nobody took is a guess. ⚠️ A capture that FAILS clears the label (and announces the change) rather than keeping the last one: a stale label opens the next idle prompt already acknowledged, so keeping it would turn a failed `capture-pane` into a missed alert, while clearing it costs at most an alert the next readable capture takes back. ⚠️ It then FREEZES once `_confirmIdle()` concludes — nothing looks at the pane again until it produces output — which is correct rather than tolerable, since work ending repaints the pane either way (a monitor firing wakes the agent; codex drops its background-terminal row by itself); a timer to keep it fresh would spend a `capture-pane` per idle session per tick to learn nothing. ⚠️ A server restart looks like a hole in that and is not one: the field is live state and starts empty, but reconciliation re-attaches the pane and the attach repaint arms the idle confirmation, which probes and re-reads the label with no input from anyone (measured 2026-09-23, back within ~20 s). A restored session showing no label has no chip on its screen.
|
||||
|
||||
**The fix is the alert that does not fire; the badge is cosmetic.** `hook-event-routes` passes the label to `notePrompt()`, which opens the idle item ALREADY acknowledged (`acknowledgedAt` + `acknowledgedReason`). Nothing new suppresses anything: `acknowledge()` has always meant "the alert this prompt armed is spent", so the item stays pending, answerable and available as Read My Mind context, and a wrong label costs a card that does not blink rather than an alert that was never created. Every surface follows from that one flag — the broadcast carries `acknowledgedReason` so a live page declines to arm (`_onHookIdlePrompt`, settings-ui.js), the push is skipped, a reloading page reads `acknowledgedAt` in `seedApprovals()` as it always did, `classifySession()` and `pendingApprovalCount()` (tui-model.ts, tui-render.ts) ignore an acknowledged item, and the TUI card drops to the `info` tone and says why. It re-arms for free: the next idle prompt supersedes the item and is built fresh. ⚠️ Only `idle` is eligible, so a permission or question dialog still goes red whatever else the agent started — but a prose question is NOT a dialog, so an agent that arms a monitor and then asks "which branch?" in plain text is silenced along with the false alarms. That is the accepted cost of the design and the reason the kind gate sits at the single place items are created.
|
||||
|
||||
|
||||
+9
-6
@@ -2991,15 +2991,18 @@ export class Session extends EventEmitter {
|
||||
* 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.
|
||||
* A capture that could not be read CLEARS the label rather than keeping the last one.
|
||||
* The two wrong answers are not symmetric: a stale label opens the next idle prompt
|
||||
* already acknowledged, so a failed capture would silence a real alert, while a dropped
|
||||
* label only costs a card and an alert that the next readable capture takes back.
|
||||
* Degrading toward the alert is the rule the whole signal is built on.
|
||||
*/
|
||||
private _readWatching(paneText: string | null): void {
|
||||
const pattern = this._watchingLinePattern();
|
||||
// Called only from the probe, and only with what a capture returned: `null` is
|
||||
// "the screen could not be read", which is not evidence that nothing is running.
|
||||
if (!pattern || paneText === null) return;
|
||||
const label = watchingLabel(paneText, pattern, this._watchingWindow);
|
||||
if (!pattern) return;
|
||||
// `null` is "the screen could not be read". That is no evidence either way, so the
|
||||
// label falls to null (and the change is announced below like any other).
|
||||
const label = paneText === null ? null : watchingLabel(paneText, pattern, this._watchingWindow);
|
||||
if (label === this._watching) return;
|
||||
this._watching = label;
|
||||
// ⚠️ This CHANGES while the session's status does not, so it needs an event of its
|
||||
|
||||
@@ -194,8 +194,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.querySelector('.btn-approvals')?.setAttribute('aria-expanded', 'false');
|
||||
},
|
||||
|
||||
/**
|
||||
* Items still waiting on a human. An acknowledged item (a human already looked, or the
|
||||
* session opened it acknowledged because it is watching its own background work) keeps
|
||||
* its card but arms no alert, so it must not light the bell either; this is the same
|
||||
* count `pendingApprovalCount()` gives `codeman tui`.
|
||||
*/
|
||||
pendingApprovalsCount() {
|
||||
if (!this.approvals) return 0;
|
||||
let count = 0;
|
||||
for (const item of this.approvals.values()) if (!item.acknowledgedAt) count++;
|
||||
return count;
|
||||
},
|
||||
|
||||
renderApprovals() {
|
||||
const count = this.approvals ? this.approvals.size : 0;
|
||||
const count = this.pendingApprovalsCount();
|
||||
const btn = document.querySelector('.btn-approvals');
|
||||
if (btn) {
|
||||
// Marker-class visibility (base header rules are display !important):
|
||||
|
||||
@@ -60,7 +60,6 @@ const MOBILE_OVERVIEW_RUN_MODES = [
|
||||
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
|
||||
];
|
||||
|
||||
/** 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
|
||||
@@ -69,6 +68,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [
|
||||
const WATCHING_BADGE_TEXT = 'watching';
|
||||
const watchingBadgeTitle = (label) => 'Still running in the background: ' + label;
|
||||
|
||||
/** Pill copy per state. Kept short: a phone row has ~90px for it. */
|
||||
const MOBILE_OVERVIEW_PILL_LABEL = {
|
||||
needs: 'needs you',
|
||||
error: 'error',
|
||||
|
||||
@@ -279,19 +279,23 @@ describe('Session.watching', () => {
|
||||
expect(changes).toEqual(['1 monitor']);
|
||||
});
|
||||
|
||||
it('keeps its last answer when the screen cannot be read', () => {
|
||||
it('drops its answer when the screen cannot be read, and says so', () => {
|
||||
vi.useFakeTimers();
|
||||
const screen: { text: string | null } = { text: WITH_MONITOR };
|
||||
const session = withFakePane(() => screen.text as string);
|
||||
const changes: (string | null)[] = [];
|
||||
session.on('watchingChanged', () => changes.push(session.watching));
|
||||
|
||||
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.
|
||||
// A stale label would open the next idle prompt already acknowledged, so a failed
|
||||
// capture must degrade toward the alert, not toward silence. The page is told too,
|
||||
// or every open tab would go on drawing the badge.
|
||||
screen.text = null;
|
||||
runAndSettle(session);
|
||||
expect(session.watching).toBe('1 monitor');
|
||||
expect(session.watching).toBeNull();
|
||||
expect(changes).toEqual(['1 monitor', null]);
|
||||
});
|
||||
|
||||
it('reads Codex own row, three up from the bottom of its screen', () => {
|
||||
|
||||
@@ -160,6 +160,13 @@ describe('a watching session raises no alert on any surface', () => {
|
||||
expect(app.approvals.get(watched.id)?.acknowledgedReason).toBe('watching 1 monitor');
|
||||
});
|
||||
|
||||
it('the header bell does not count the card, matching codeman tui', () => {
|
||||
const app = loadFrontend() as FrontendApp & { pendingApprovalsCount(): number };
|
||||
app.approvals.set('watched', watched);
|
||||
app.approvals.set('plain', itemFor(null));
|
||||
expect(app.pendingApprovalsCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('so both home screens classify the session as plainly idle', () => {
|
||||
const app = loadFrontend();
|
||||
app._onHookIdlePrompt({ sessionId: SESSION, acknowledgedReason: watched.acknowledgedReason });
|
||||
|
||||
Reference in New Issue
Block a user