Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions hub/src/sync/overseerEventRecorder.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion hub/src/sync/overseerEventRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading