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
+23 -6
View File
@@ -176,6 +176,12 @@ export function registerHookEventRoutes(
// identity beyond the shared per-instance secret, so a prompt claimed for a
// session that can never show one must not create an answerable item).
let approvalId: string | undefined;
// Set when the item opened ALREADY acknowledged, which today means the session is
// watching work it started itself. It rides the broadcast so a live page declines to
// arm the alert (a reloading page learns the same thing from `acknowledgedAt` when it
// seeds from /api/approvals), and it suppresses the push: an alert nobody can answer
// is worth even less on a phone than in a tab.
let acknowledgedReason: string | undefined;
const approvalKind = APPROVAL_KIND_BY_EVENT[event];
if (session && hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
if (approvalKind) {
@@ -194,6 +200,10 @@ export function registerHookEventRoutes(
toolSummary: typeof toolSummary === 'string' ? toolSummary : undefined,
message: typeof safeData.message === 'string' ? safeData.message : undefined,
cwd: typeof safeData.cwd === 'string' ? safeData.cwd : undefined,
// What the pane says is still running in the background. An idle prompt from a
// session that is watching its own work opens acknowledged, so it never arms an
// alert nobody can answer; notePrompt() carries the whole reasoning.
watching: session.watching,
// Visible tmux frame first (it IS the dialog); raw byte-buffer tail as
// the fallback for direct-PTY sessions and the no-op test mux.
capture: () => {
@@ -203,6 +213,7 @@ export function registerHookEventRoutes(
},
});
approvalId = item.id;
acknowledgedReason = item.acknowledgedReason;
} else if (APPROVAL_RESOLVING_EVENTS.has(event)) {
approvalInbox.resolveForSession(sessionId, 'resolved_in_terminal');
}
@@ -213,6 +224,7 @@ export function registerHookEventRoutes(
timestamp: Date.now(),
...safeData,
...(approvalId && { approvalId }),
...(acknowledgedReason && { acknowledgedReason }),
});
// Full state ride-along, same shape as the working/idle handlers: the home
// screens rank the blocked group on lastActivityAt, and without this a
@@ -224,12 +236,17 @@ export function registerHookEventRoutes(
// on approvalId, and the answer route refuses keystrokes for dsh dialogs
// (third-party TUI, unmeasured contract) — so a dsh push stays a plain
// notification instead of offering buttons whose answer would be refused.
ctx.sendPushNotifications(`hook:${event}`, {
sessionId,
sessionName,
...safeData,
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
});
// Nothing to push for a prompt that opened acknowledged: the agent is waiting for its
// own monitor or backgrounded shell, and a phone buzzing about it is the same false
// alarm as the tab alert, delivered where it is hardest to ignore.
if (!acknowledgedReason) {
ctx.sendPushNotifications(`hook:${event}`, {
sessionId,
sessionName,
...safeData,
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
});
}
// Track in run summary. `prompt_submitted` fires on EVERY prompt of every
// Claude pane; only the ones where the conversation actually moved (a /clear