From 422a4a98e94ee6b17e17c30d5d962e35384c7dd3 Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:19:39 +0000 Subject: [PATCH] fix(alert): emit onStateChange when WATCHING turns off on command finish --- lib/src/lib/alert-manager.test.ts | 23 +++++++++++++++++++++++ lib/src/lib/alert-manager.ts | 8 ++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/lib/src/lib/alert-manager.test.ts b/lib/src/lib/alert-manager.test.ts index 97c007c9..1ce5b67a 100644 --- a/lib/src/lib/alert-manager.test.ts +++ b/lib/src/lib/alert-manager.test.ts @@ -492,6 +492,29 @@ describe('AlertManager in isolation', () => { expect(manager.getState(id).watchingEnabled).toBe(false); }); + it('notifies subscribers when WATCHING turns off as a watched command finishes', () => { + const id = 'rule-finish-notify'; + manager.setWatchedCommands(['claude']); + + manager.applyTerminalSemanticEvents(id, [ + { type: 'commandLine', commandLine: 'claude' }, + { type: 'commandStart', source: 'osc633_E', startedAt: Date.now() }, + ]); + expect(manager.getState(id).watchingEnabled).toBe(true); + + // Subscribe after the command has started so we only capture the finish. + const watching: boolean[] = []; + manager.onStateChange((_id, state) => { + if (_id === id) watching.push(state.watchingEnabled); + }); + + manager.applyTerminalSemanticEvents(id, [{ type: 'commandFinish', exitCode: 0 }]); + + expect(manager.getState(id).watchingEnabled).toBe(false); + // The off-transition must reach subscribers, not just live getState reads. + expect(watching).toContain(false); + }); + it('matches on the bare program name, not the whole command line', () => { const id = 'rule-argv0'; manager.setWatchedCommands(['claude']); diff --git a/lib/src/lib/alert-manager.ts b/lib/src/lib/alert-manager.ts index b3d5a2ce..dd6dfa44 100644 --- a/lib/src/lib/alert-manager.ts +++ b/lib/src/lib/alert-manager.ts @@ -397,14 +397,18 @@ export class AlertManager { const watch = entry.commandExitWatch; entry.commandExitWatch = null; entry.pendingCommandLine = null; - this.applyWatchingRule(id, entry); + // Disposing the WATCHING monitor flips `watchingEnabled`/status, so its + // change must propagate even when command-exit never armed — otherwise a + // watched command that finishes leaves subscribers on stale WATCHING state + // until the next command starts. + const watchingChanged = this.applyWatchingRule(id, entry); const wasArmed = entry.commandExitStatus === 'COMMAND_EXIT_ARMED'; if (entry.commandExitStatus !== 'ALERT_RINGING') { entry.commandExitStatus = 'IDLE'; } - if (!watch || !wasArmed) return wasArmed; + if (!watch || !wasArmed) return watchingChanged; if (this.hasAttention(id)) return true; if (Date.now() - watch.startedAt < this.inactivityTimeoutMs) return true;