Focus mode (part 2)
How Focus mode is built: the CLI entry points, the rule engine and its state machine, the banner count the main process owns, and the contracts the suppression and overlay layers answer to — the implementation detail behind the behaviour on part 1.
What it is
This is part 2 of the Focus mode page. It carries how the feature is built and what guards it, moved here because a single page is capped at 40,000 characters.
Where to find it
Nothing on this page is a surface — part 1 covers the chord, the popover and the rule editor. What follows is the code behind them, so it is for a reader with the repository open.
How it behaves
The rule is compiled once and read by every suppression layer, so the sidebar, the main panel, the overlays and the badge cannot disagree about whether Focus mode is on. Below is the state machine, the layers it drives, and the contracts that lock them.
For agents
CLI access
Focus Mode arm/disarm and rule CRUD are reachable from the CLI control server.
Current state is GET /focus-mode/state; the arm/disarm toggle
(FOCUS_MODE_TOGGLE) is POST /focus-mode/toggle; rule list/upsert/delete
(FOCUS_MODE_LIST_RULES / FOCUS_MODE_UPSERT_RULE / FOCUS_MODE_DELETE_RULE)
are GET /focus-mode/rules, POST /focus-mode/rules and
DELETE /focus-mode/rules/:id. The session whitelist is the one part still
exposed only via Electron IPC. The unrelated
/focus CLI endpoint (which simply brings the Omniscio window to the foreground) is
described in tray-and-window.md and is not part of Focus
Mode (batch alerts).
How it works (technical)
Storage. Per-project rules live in the focus_mode_rules table (migration v136) — one row keyed by project_id with enabled, count_threshold, time_threshold_minutes, operator, plus the seeded 'global' sentinel row. Whitelist + enabled-state + per-project last-batch cookies live on the focusModeState field of AppSettings (so they survive restart but are not in the database).
Decision flow. Notification-eligible items pass through focusModeService.evaluate(item) in src/main/services/focus/focus-mode-service.ts before notification-service fires anything. The decision is one of:
| Decision | Reason | When |
|---|---|---|
fire-immediately |
pierce |
Item is in a pierce state (auth/api/rate-limit/error). |
fire-immediately |
whitelist |
Session is on the user's whitelist, OR the resolved per-project focus rule has enabled: false (the 'whitelist' reason is reused for the rule-disabled path so they share a downstream branch). |
fire-immediately |
focus-off |
Focus Mode is currently off. |
fire-immediately |
feature-off |
Hard-kill switch is off. |
fire-immediately |
service-error |
Something threw inside evaluate (fail-open). |
suppress |
rule-not-met |
Queue is below threshold(s). |
fire-batch |
count / time / count+time |
Threshold tripped — emit FOCUS_BATCH_FIRED push to the renderer with { projectId, batchSize, reason }. |
Bucketing. Each item carries a projectId (or null → the 'global' bucket). When no per-project rule exists for the item's project, resolveFocusModeRule falls back to 'global', and the queue/cookie aggregate on 'global' instead of the project id. So in v1 (where only the global rule is editable), every item rolls up to the global bucket regardless of which project it came from.
Crash safety. When the rule trips, the per-bucket cookie (perProjectLastBatchAt[bucket]) is persisted before the FOCUS_BATCH_FIRED push is emitted. A crash between persist and emit costs at most one missed alert; persisting after emit would risk double-firing on relaunch.
Emit is internal to evaluate(). The FOCUS_BATCH_FIRED push is fired from inside evaluate() itself (after persist, after trackEvent) — it is NOT the caller's job to emit. Real-time notification paths (notifySms, daily-digest, approval handlers, CLI-pending) only consume the returned decision to play their chime / OS notification; they do not separately push. The periodic timer (evaluatePending) also relies on the internal emit. This means the renderer's _onBatchFired handler runs the moment any path trips a batch — without it, the chime fires but the revealed flag never flips, so the banner stays in armed (accent) state instead of transitioning to tripped (amber). Bug 2026-05-10 surfaced because the emit was caller-side (only evaluatePending emitted) and real-time paths silently skipped the renderer transition.
Periodic timer. A 60-second interval re-runs evaluation in case a time threshold tripped without any new arrivals. The timer is only scheduled when at least one rule has a non-null time threshold — pure count rules don't need the timer and skip it to save power.
Push events. Three push channels announce state changes to the renderer:
FOCUS_MODE_CHANGED—{ enabled, startedAt }— fired on toggle.FOCUS_RULES_CHANGED—{ projectId: string | null }(currently alwaysnullsince rule upserts/deletes aren't yet bucketed per-project) — fired on rule upsert/delete; renderer re-hydrates the full rule set.FOCUS_BATCH_FIRED—{ projectId, batchSize, reason }— fired when a batch trips; renderer raises the toast.
Hard-kill behaviour. When focusModeFeatureEnabled === false, every public method on the service short-circuits, the toolbar pill returns null (rendered nothing), and evaluate() returns fire-immediately/feature-off. Use this to fully disable the feature without losing your rule configuration.
Banner count — authoritative on main, renderer is a thin consumer
The "Focus Mode — N waiting" banner count reflects the SERVICE's queue, NOT the inbox-tab attention badge. The two numbers differ because they're computed from different source sets and different floor rules:
| Surface | Source set | Pre-existing-item handling | Source |
|---|---|---|---|
| Inbox-tab badge | All inbox sources + plugin attention items | All items count | useInboxAttentionCount() in inbox-items.ts |
| Focus banner / queue | 7 sources | F4 floor excludes them | useFocusQueueMaxSize() in focus-queue-count.ts |
Source set. The 7 focus-eligible sources are: needs-you/stalled sessions (per-project bucket), SMS unread conversations (global), unread daily digests (global), pending CLI actions (global), pending cron approvals (per-project bucket), pending automation approvals (global), and pending recipe approvals (global). The inbox tab also counts non-alerting sources (Gmail, RSS, channel-unreads, etc.) which never flow through Focus Mode's evaluation — including them in the banner would over-report and confuse the user about why their rule isn't tripping.
Single source of truth. focusModeService.getQueueMaxSize() in src/main/services/focus/focus-mode-service.ts walks each enabled rule, calls computeFocusQueue(rule.projectId, state) per bucket, and returns the LARGEST size. This is the same computeFocusQueue the service uses for its evaluate/fire-batch decision, so the banner and the rule that trips it are reading the EXACT SAME number. (Multi-bucket semantics match the service's first-fire behaviour — the worst-case bucket is the one closest to tripping, so it's what the banner advertises. With only the global rule enabled in v1, "max across buckets" collapses to the total post-floor queue.)
Renderer hook. useFocusQueueMaxSize() in focus-queue-count.ts is a ~50-line shell:
- On mount it calls
ipc.invoke(IPC.FOCUS_MODE_GET_QUEUE_COUNT, {})and stores the returnedmaxSize. - It subscribes to
IPC.FOCUS_MODE_QUEUE_COUNT_CHANGEDpushes and overwrites the count on every payload. - IPC errors are swallowed so the banner keeps showing the last-known count rather than flickering to zero on a transient failure.
There is NO renderer-side iteration of source stores and NO renderer-side floor computation. If you add a new notification-eligible source, you only wire it into collectAlertEligibleItems in src/main/services/focus/focus-mode-queue.ts — the banner picks it up automatically through the IPC query.
Push channel — FOCUS_MODE_QUEUE_COUNT_CHANGED. The service emits this push ({ maxSize: number }) from every chokepoint where the count could have changed:
enable()/disable()— seed/clear cookies shift every bucket.evaluate()after afire-batchdecision — cookie shift drops items below the new floor.evaluatePending()— the 60s periodic tick. Also serves as the reconciliation safety net for source-data mutations the service never observed (user resolved a needs-you session, marked an SMS read, dismissed a daily digest, etc.) — the banner converges within at most one cycle.rulesChanged()— rule upsert/delete changes which buckets exist and which are enabled.
Drift tradeoff. User-driven source mutations (resolving a session, archiving an SMS) are NOT individually wired to the push. Doing so would require an emit at 7+ data-source services, which the audit fix brief explicitly warned against: "STOP and report rather than sprawling across the IPC layer." The 60s periodic tick is the reconciliation rail — up to one minute of stale banner count is acceptable for an informational surface that isn't a hard gate.
Related
- Focus mode — part 1 of this page, with the surfaces and the user-facing behaviour.
Last verified 2026-10-02