diff --git a/hub/src/sync/overseerEventRecorder.test.ts b/hub/src/sync/overseerEventRecorder.test.ts index cccf1f290..fb96d1c05 100644 --- a/hub/src/sync/overseerEventRecorder.test.ts +++ b/hub/src/sync/overseerEventRecorder.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'bun:test' +import { OVERSEER_STALE_SILENCE_MS } from '@hapi/protocol' import type { Session } from '@hapi/protocol/types' import { Store } from '../store' import { OverseerEventRecorder, toSessionSnapshot } from './overseerEventRecorder' @@ -97,6 +98,30 @@ describe('OverseerEventRecorder', () => { expect(event?.attentionCandidate).toBe(0) }) + it('records hub-inferred stale silence as captured-only (not inbox-promoted)', () => { + const store = new Store(':memory:') + const recorder = new OverseerEventRecorder(store.events, store.inbox) + const session = store.sessions.getOrCreateSession('idle', { flavor: 'claude', path: '/tmp', host: 'local' }, null, 'default') + + const now = Date.now() + const silentSince = now - OVERSEER_STALE_SILENCE_MS - 60_000 + const live = makeSession(session.id, 'claude', { + active: true, + activeAt: silentSince, + updatedAt: silentSince + }) + + const emitted = recorder.checkStaleSessions([live], now) + + expect(emitted).toHaveLength(1) + expect(emitted[0]?.eventType).toBe('stale') + expect(emitted[0]?.attentionCandidate).toBe(0) + expect(store.events.count()).toBe(1) + // captured-only: the silence event is recorded for the Overseer/replay to query, + // but it must NOT flood the operator inbox (one item per idle session). + expect(store.inbox.count()).toBe(0) + }) + it('synthesizes approval_requested from permission prompts', () => { const store = new Store(':memory:') const recorder = new OverseerEventRecorder(store.events, store.inbox) diff --git a/hub/src/sync/overseerEventRecorder.ts b/hub/src/sync/overseerEventRecorder.ts index 0c0e4583f..90cd5d571 100644 --- a/hub/src/sync/overseerEventRecorder.ts +++ b/hub/src/sync/overseerEventRecorder.ts @@ -232,7 +232,15 @@ export class OverseerEventRecorder { sourceKind: 'system', sourceRef: session.id, eventType: 'stale', - attentionCandidate: 1, + // Captured-only: hub-inferred silence is ambient awareness, not an + // operator-action signal (operatorActionRequired stays 0). Auto-promoting + // it floods the inbox with one item per idle session ("narrating log file" + // failure mode); the framing doc mandates under-surface > over-surface. + // The event is still recorded so the Overseer/replay can synthesize a + // coalesced view ("N sessions idle") when it judges it worth attention. + // A worker that EXPLICITLY self-reports `stalled` keeps attention_candidate=1 + // via deriveAttentionCandidate; only this inferred sweep is captured-only. + attentionCandidate: 0, operatorActionRequired: 0, summary: `No agent output for ${Math.round((now - lastAt) / 60_000)} minutes`, relatedSessionId: session.id,