---
title: App Update Notifications (auto-update banner + error self-recovery)
---

# App Update Notifications (auto-update banner + error self-recovery)

## What it is

Omniscio auto-updates itself silently when a new release is published. Every 4 hours (and 30 seconds after each launch) the packaged app polls the Cloudflare R2 releases feed (with GitHub as a single-shot fallback if R2 is unreachable), downloads the new installer in the background, and surfaces a small banner near the top of the window so the user can either restart immediately ("Restart Now") or defer ("Later" — the install runs on next quit). When the check itself fails — private-repo authentication problems, a network outage, a draft-release glitch in the publish pipeline, an expired GitHub Personal Access Token — the banner now flips to a visible error notice instead of silently disappearing, with a link to the public releases page so the user can self-recover by downloading a fresh installer manually.

The banner has five user-visible states (in addition to the hidden "idle" state):

- **Downloading** — hidden for an ordinary automatic update: the new installer downloads **silently** in the background (it still installs on the next quit), so a routine update never interrupts you with a progress bar. The small accent-colored bar with "Updating to vX.X.X..." now appears **only for a user-initiated rollback** (Version History → "Roll back to this version") — a deliberate, awaited restore you're actively waiting on, so its progress stays visible right up to the restart it triggers.
- **Ready** — accent-colored "vX.X.X ready" plus a "Restart Now" button and a muted "Later" button. Clicking "Restart Now" quits the app and opens a small branded install window with a REAL progress bar (live percentage + megabytes as the new version unpacks, each step logged) and then relaunches Omniscio by itself — zero clicks, no wizard pages (2026-08-14 installer re-engineering; see [windows-installer.md](windows-installer.md)). Clicking "Later" hides the banner until the next update; the install still runs the next time you quit Omniscio (that quit-time install is silent — no window).
- **Error** — a red recovery banner with the "Download latest from releases" link ([…/releases/latest](https://github.com/jlstradingco/Agent-Orchestrator-releases/releases/latest), `target="_blank"` + `rel="noopener noreferrer"`) and a Dismiss button. The message is now HONEST about the cause: when the failure is a network/DNS block that can't reach the update server — the class where the feed host is DNS-blocked wholesale (bug f9d5e07b, e.g. `*.r2.dev` blocked by an ISP/national resolver) — it reads **"Can't reach the update server — your network or DNS may be blocking it. A different DNS or a VPN can help."** instead of the dead-end "Update check failed."; any other (reachable-server) failure keeps the plain "Update check failed." The store carries an `errorReason` (`'network-blocked' | 'generic'`, classified by `classifyUpdateErrorReason` in [app-update-error-classify.ts](../../src/main/services/app/app-update-error-classify.ts)) that the banner branches on. This state was previously hidden — its arrival is the fix described in this page.
- **Duplicate copy** — amber text "A newer version of Omniscio (vX.X.X) is already installed on this computer — this copy can't update itself," plus a "Switch to it" button when the newer copy is verified-safe to launch (or the folder path to open it manually when it isn't), and a "Later" button. This appears when a machine has more than one Omniscio install and the copy that was launched is out of date. See ["When you have more than one copy installed"](#when-you-have-more-than-one-copy-installed) below.
- **Hidden** — when status is `idle`, `checking`, or `dismissed`, nothing renders. The banner returns automatically on the next successful check that finds a new version, or on the next failure if the user dismissed an earlier error.

The auto-update system only runs in **NSIS-installed packaged builds**. Three conditions must all hold: `app.isPackaged === true` (dev builds skip), the launched binary is NOT a portable EXE (electron-builder sets `process.env.PORTABLE_EXECUTABLE_FILE` only on the portable target — its presence disables the updater), and `<resourcesPath>/app-update.yml` exists (manually-unpacked builds without it are skipped). During `npm run dev`, the service is inert — the developer updates by pulling git, not by downloading a binary. **Portable EXE users** (small-team distribution via Google Drive) never see the update banner: portable builds bundle `app-update.yml` like NSIS, but the GitHub-Releases credential path isn't available outside the installer flow, so polling would surface a recurring red "Update check failed" banner every 4 hours. Portable distribution replaces itself by re-downloading the new EXE from Google Drive, not by in-place update.

## Where to find it

### Checking manually ("Check for updates")

The **Check for updates** item — in the header's overflow ("More") menu, or as a pinned toolbar button — runs the same check on demand. It always tells you how it turned out. A spinner toast ("Checking for updates…") goes up the moment you click, and is replaced by exactly one of:

- **"You're up to date."** — the feed answered and there is nothing newer. (A feed with no published release yet counts as up to date: it was reachable, it just had nothing to offer.)
- **Nothing at all — the banner takes over** — an update was found. The download starts and the banner shows it, so no toast talks over it.
- **"This build doesn't update itself. Download the latest to upgrade."** with a **Download** button — this copy of Omniscio can't update in place (a dev build, a portable EXE, or a manually-unpacked build with no `app-update.yml`). **No check was performed.** The button opens the download page.
- **"Couldn't check for updates. Check your connection and try again."** — the check genuinely failed. The raw network/HTTP cause is written to the log, never to the toast.

That third case is the one worth knowing about: because a portable build can never update in place, this is the only signal that you may be sitting on an old version, and upgrading means downloading a fresh copy yourself.

**Rescue when the feed host is blocked (bug f9d5e07b).** The normal feed is served from a first-party Cloudflare R2 domain (`https://releases.omniscio.com/`), which replaced the shared `*.r2.dev` host after some ISP/national resolvers were found to DNS-block `*.r2.dev` wholesale (Indonesia's `aduankonten.id`), stranding every install behind such a resolver on `ERR_CONNECTION_REFUSED`. If the feed host is unreachable, a **user-initiated** "Check for updates" now runs the single-shot GitHub emergency fallback **before** surfacing the error (previously only the 4-h background tick fell back), so a user whose feed host is blocked but who can reach GitHub is rescued instead of stranded — that rescue is also what carries an already-blocked install onto the new address. When the BACKGROUND check keeps failing on that class AND the fallback can't reach a feed either — the install is genuinely stranded — one deduped, actionable inbox card ("Updates aren't getting through" → "Download the latest version") is raised so the silent failure is detectable, not only via a user complaint ([alert-actions/app-update.ts](../../src/shared/alert-actions/app-update.ts)).

### How to use it

1. **Default behavior — nothing to configure.** Once you've installed Omniscio from a real installer (`.exe` on Windows, `.dmg` on macOS, `.AppImage` on Linux), the periodic checker is on by default. New releases arrive within 4 hours of being published.
2. **Restart immediately when ready.** When the banner shows "vX.X.X ready", click **Restart Now**. Omniscio quits, a small install window shows real progress (live percentage + MB unpacked, each step logged) while the new version installs, and the new version launches itself — zero clicks. Your sessions, accounts, and settings are preserved (the SQLite database lives outside the app bundle).
3. **Defer the install.** Click **Later** to hide the banner. The downloaded installer auto-runs on the next quit — no second prompt, no extra step.
4. **Recover from a stuck binary.** If the banner shows "Update check failed.", click **Download latest from releases**. That opens the releases page in your default browser; download the matching installer for your platform, run it, and the next launch will be on the new version. The error banner stays visible until you click **Dismiss** so you don't miss the recovery path.
5. **Diagnose silent failures from logs.** If the banner says nothing but you suspect updates aren't reaching you, open Settings → Logs → Reveal log folder, then open `main.log` in a text editor and search for `[app-update]`. Every lifecycle event now writes a line — periodic checks, manual checks, errors, and successful downloads. A clean log with no `[app-update]` entries since the last reboot indicates the periodic checker is not running (regression). A log with `[app-update] error from electron-updater: HTTPError 404 …` confirms an authentication or pipeline problem (next step: ping the developer).

### Rolling back to a previous version

Settings → Diagnostics → **Version History** lists every release. Next to any older
version you still have a **data snapshot** for, a **"Roll back to this version"** link
appears. Clicking it shows a plain-English confirmation ("This restarts Omniscio and returns
it to version X, including your data from just before that update. Your current data is
saved first, so you can move forward again."), then: saves a fresh snapshot of your
_current_ data (so you can move forward again), downloads that older version's installer,
restarts onto it, and restores that version's data on the way up.

**Why a data snapshot, not just the old .exe:** Omniscio's database only migrates _forward_,
so an older app can misread a newer database. Right after every update finishes
downloading, Omniscio quietly snapshots the _outgoing_ version's data (tagged with that
version) and keeps the last 3. Rollback restores the matching snapshot — which is why
**the list is empty until future updates have laid snapshots down**; the screen says so
rather than looking broken. If the older installer is no longer on the releases feed, the
rollback fails cleanly with "Your data was not changed" — nothing is touched.

Backend: `rollbackToVersion` / `listRollbackTargets` in
[app-update-service.ts](../../src/main/services/app/app-update-service.ts) (transient
`allowDowngrade` + a generic feed pointed at the target release), the `'pre-update'`
snapshot ring in [backup-service.ts](../../src/main/services/backup/backup-service.ts),
and the `targetVersion`-gated restore in
[backup-mirror-restore-service.ts](../../src/main/services/backup/backup-mirror-restore-service.ts).
Full invariants: [app-update-rollback-contract.md](../../.claude/memory/contracts/app-update-rollback-contract.md).

### Gentle nudge + "apply while I'm away"

Because Omniscio is left running all day (and close-to-tray means ✕ doesn't quit), a ready
update can sit unapplied for days. Two things help, neither ever yanks the rug:

- **Gentle nudge** — the store now carries `readySince`; once an update has been _ready_
  for more than 8 hours the "Restart Now / Later" banner strengthens into a calmer
  reminder. Still dismissible, never auto-restarts.
- **Apply while I'm away** (Settings → Diagnostics → Version History, **off by default**) —
  when on, Omniscio installs a waiting update on its own _only_ when it's genuinely safe: no
  sessions running and the machine has been idle for a while
  ([auto-install-idle.ts](../../src/main/services/app/auto-install-idle.ts), conservative —
  any uncertainty means it waits).

The store now holds `{ status, version, downloadPercent, releaseNotes, error, readySince, duplicate, rollbackInFlight }`.

## How it behaves

### When you have more than one copy installed

A machine can end up with **more than one copy of Omniscio** in different folders — the Windows installer lets you choose the install location (so running it twice can land two installs), and there is also a portable build people download separately. When that happens, one copy auto-updates fine while the _other_ copy stays on an old version. If the shortcut you click opens the old copy, it keeps noticing the new release, downloading it, and showing "Restart Now" — but restarting only updates/relaunches the _other_ copy, so the one you open never changes. From the user's side it looks like "it says it updated but it still says restart now," forever. (`autoUpdater.quitAndInstall()` never downgrades, so a running version that drops from new back to old is proof of a second, separate binary — not a botched update.)

Omniscio now detects this and tells the truth instead of nagging. On startup (Windows installed builds only) it records each copy it runs — the executable's path and the version — in a small file in its user-data folder, then checks whether a **strictly newer** copy exists at another path that is still on disk. If so, this copy is a _stale duplicate_: Omniscio stops the false download → "Restart Now" loop and shows the amber "Duplicate copy" banner. The banner offers a one-click **"Switch to it"** that:

1. **Verifies the newer copy is genuinely ours** — it must exist, share our program's exe filename, and carry the **same code-signing publisher** as the running copy. If any check fails, Omniscio refuses to launch it and just shows the folder path so the user can open it manually. (It never launches an arbitrary path from that file.)
2. **Relaunches into the newer copy** using `app.relaunch({ execPath })`, which starts the new binary only after this one exits — so there's no window where nothing is running.
3. **Repoints your shortcuts** (best-effort) so the _next_ time you open Omniscio from the Start Menu / desktop, you get the up-to-date copy — a permanent fix, not a one-time hop. Only the shortcut's target changes; its icon is left alone.

Why the record is keyed by install path (not a single global "did the update stick?" flag): both copies share the same user-data folder, so a global flag would be cleared by whichever copy launched last — masking the stale one. Keying by path is what lets Omniscio see "_this_ copy is behind _that_ copy."

Scope for v1: Windows installed builds. macOS/Linux and the "the portable copy is the stale one" case are documented gaps. Backend: [install-registry-decision.ts](../../src/main/services/app/install-registry-decision.ts) (pure detection + validation), [install-registry.ts](../../src/main/services/app/install-registry.ts) (the on-disk record), [switch-to-canonical.ts](../../src/main/services/app/switch-to-canonical.ts) (the verified relaunch + shortcut repoint), and `reconcileInstallLocationOnStartup` + the sticky `'duplicate'` status in [app-update-service.ts](../../src/main/services/app/app-update-service.ts). Full invariants: [app-update-apply-contract.md](../../.claude/memory/contracts/app-update-apply-contract.md).

### Selective rollout (deliver to selected users, not the whole fleet)

By default a published stable release reaches every install within ~4 hours. Three controls deliver an update to a SUBSET instead (added 2026-06-29 — full invariants in [targeted-update-contract.md](../../.claude/memory/contracts/targeted-update-contract.md)):

- **Pre-release hand-delivery.** Running `.github/workflows/release.yml` with the `delivery: manual` input publishes a shareable installer the fleet's updater IGNORES (electron-updater takes only the "latest" stable, and `allowPrerelease` is off). Hand the `.exe` to testers; promote to everyone later with `gh release edit <tag> --prerelease=false --latest`.
- **Staged % rollout.** The `staging_percentage: <N>` input injects `stagingPercentage` into the published `latest*.yml` ([inject-staging-percentage.mjs](../../scripts/inject-staging-percentage.mjs)); electron-updater self-buckets each install on a stable per-machine id so only ~N% update. Bump N on a later run to widen.
- **Per-user targeting by login email.** A super-admin assigns a specific published version to a specific signed-in user in the admin dashboard's **Targeted Updates** tab; only that person's install picks it up. `runTargetedUpdateCheck` in [app-update-service.ts](../../src/main/services/app/app-update-service.ts) asks the `globalAuthProfile` Cloud Function — keyed on the VERIFIED token email, so a user can never claim another's targeting — whether this install is targeted, and if so points the updater at that release's feed via the same `setFeedURL` mechanism rollback uses ([app-targeted-update.ts](../../src/main/services/app/app-targeted-update.ts) holds the pure decision logic). It is fail-safe (any error or signed-out state → normal updates continue), forward-only (never downgrades), admin-gated, and inert behind `AMC_DISABLE_TARGETED_UPDATE`. Reach is limited to users signed in to the account gate — the admin tab shows per-email reachability so that limit is visible. The server side (the shares Firebase project: the new globalAuth actions + the `targeted_updates` collection deny-rule + the dashboard tab) needs a one-time, gated `firebase deploy`.
- **Test groups.** The same idea for a NAMED LIST of people: a super-admin creates a group in the admin dashboard's **Test Groups** tab (a name + a pasted set of emails + a version), and everyone in the group gets that version. It is resolved entirely SERVER-SIDE — `checkTargetedUpdate` folds the caller's per-user target with the target of every enabled group they're in and returns the HIGHEST version ([update-groups.ts](../../firebase/functions/src/update-delivery/update-groups.ts) + the shared [targeting-shared.ts](../../firebase/functions/src/global-auth/targeting-shared.ts)), so NO app change is needed — groups work for any app build that already has the targeting check. Groups are a dedicated `update_groups` collection (NOT the heavyweight `organizations`/tenancy structure), admin-SDK-only, and the admin list shows member counts with per-member reachability on expand. Full invariants: [targeted-update-contract.md](../../.claude/memory/contracts/targeted-update-contract.md).

### Release notes in your inbox after an update

After Omniscio updates itself and relaunches into a new version, it drops a one-time card into your **Inbox** — "What's new in Omniscio vX.Y.Z" — showing that release's full "What's New" highlights **right in the card** (shown in full, no truncation and no extra button to click). It appears **once per version, only on a real upgrade**: a fresh install, the first launch after this feature ships, a rollback to an older version, and ordinary restarts all post nothing. It is inbox-only (no toast) and always on (no toggle to disable it), gated only by the master "agent alerts" inbox switch.

The card's text is whatever is written in that release's `## What's New` section on GitHub — editing that section (even after the release is published) flows through to users' cards the next time their app fetches the notes, so the content is fully author-controlled per release. No release-workflow change is needed; the card reuses the same release-notes fetch the banner uses, plus the inbox-alert primitive. A release published without a `## What's New` header falls back to a short title-only card (the technical "Details" section never lands in the card).

Only **published** releases are ever shown. The release ritual deliberately writes each set of
notes as a draft for a human to review before publishing, and the in-app "New release" dialog
defaults to draft too — so at any moment the top of the releases feed is usually a stack of
unpublished stubs. GitHub hands those drafts back only to a caller signed in with access to the
repo, which is why this misfired for signed-in users only: to everyone else the same request
already returned the right release. The effect was a "What's New" that named an older version than
the app you were running, reading as though the current release's notes were never written. The
fetch now drops drafts outright, and also drops a published release whose notes are empty, so the
newest thing you see is always the newest thing the team actually shipped.

Backend: [release-notes-inbox.ts](../../src/main/services/app/release-notes-inbox.ts) (a launch-time version-marker compare → fetch the new version's notes → post one deduped card), wired as the `'Release notes inbox'` startup task (packaged builds only, fire-and-forget so it never blocks startup). The full "What's New" is rendered inline in the card body, so the card has no action button of its own — but, like every alert, it now offers the universal "Start session" button (the old renderer carve-out in [AlertInboxViewer.tsx](../../src/renderer/src/features/alerts/AlertInboxViewer.tsx) that suppressed it was removed — see [inbox-alert-contract.md](../../.claude/memory/contracts/inbox-alert-contract.md) I8). Full invariants: [release-notes-inbox-contract.md](../../.claude/memory/contracts/release-notes-inbox-contract.md).

## For agents

### How it works

The implementation lives in [app-update-service.ts](../../src/main/services/app/app-update-service.ts) (main process) and [UpdateBanner.tsx](../../src/renderer/src/features/app-update/UpdateBanner.tsx) (renderer).

The service uses [electron-updater](https://www.npmjs.com/package/electron-updater) with the generic provider pointed at the Cloudflare R2 releases feed (the Stage 2 Part B 4d feed cutover); GitHub stays live the whole transition window as the single-shot emergency fallback and as the rollback / targeted-update per-tag feed. A pure-function gate in [app-update-availability.ts](../../src/main/services/app/app-update-availability.ts) (`isAutoUpdateAvailable`) decides at module load whether to wire the updater at all — the three-condition check above. When the gate returns false, no event listeners register, no periodic task ticks call `checkForUpdates()`, and one info-level log line records WHY (dev / portable / yml-missing) instead of every 4-hour cycle spamming `ENOENT app-update.yml` or "Update check failed" banners. When the gate returns true, the service sets `autoUpdater.requestHeaders = { Authorization: 'token <pat>' }` (in-memory, NOT `process.env.GH_TOKEN` — child spawns inherit env and would leak the token to Claude CLI / ContextDock / MemPalace), configures `autoDownload = true` and `autoInstallOnAppQuit = true`, sets `autoUpdater.logger = null` to suppress electron-updater's verbose binary-metadata firehose, and registers four event listeners. Each listener calls `log.info` or `log.error` with an `[app-update]` prefix before updating the cached `AutoUpdateStatus` and pushing through the IPC bus — that explicit-but-quiet logging pattern is what makes silent failures diagnosable from `amc-mission-control/main.log`. The periodic check runs via `createPeriodicTask({ intervalMs: 4 * 60 * 60 * 1000, delayMs: 30_000 })` and logs its tick before calling `autoUpdater.checkForUpdates()`. Manual checks (`APP_UPDATE_CHECK` IPC) and the install path (`APP_UPDATE_INSTALL` → `installUpdate()` → `autoUpdater.quitAndInstall()`) log too. `installUpdate` is **async** since report b8676eb7: it first awaits the in-flight `pre-update` rollback snapshot (`awaitPreUpdateSnapshot`, bounded at 2 min and fail-open) so the update cannot quit out from under the half-written backup it exists to protect against — a snapshot that overruns is logged as ABANDONED and the install proceeds regardless.

Every check — periodic, manual, targeted, the GitHub emergency fallback and a rollback — goes through `checkForUpdatesOwningDownload()`, never `autoUpdater.checkForUpdates()` directly. With `autoDownload` on, electron-updater starts the installer download inside the check and hands its promise back un-awaited; a failed download (a signature check that came back empty, a dropped connection, a checksum mismatch) emits `'error'` — logged once as `[app-update] background update check error …` — and then rejects that promise. The helper owns the rejection, so the failure is never reported as a crash; before it existed, the orphaned rejection reached `process.on('unhandledRejection')` and was filed as one (Sentry 7751751106). A source guard in [app-update-download-ownership.test.ts](../../tests/unit/services/app-update-download-ownership.test.ts) fails the build on a direct call, and on `checkForUpdatesAndNotify()`, which leaks the same promise.

A **manual** check reports its result rather than leaving it to be inferred. `checkForUpdateNow()` returns an `UpdateCheckOutcome` — `'unavailable' | 'up-to-date' | 'update-available'` ([auto-update.ts](../../src/shared/types/auto-update.ts)) — which the `APP_UPDATE_CHECK` handler puts on the envelope's `data`, and `handleCheckForUpdates` in [useToolbarActions.ts](../../src/renderer/src/app/useToolbarActions.ts) turns into the toast copy listed above. A genuine failure is not an outcome: it still throws, so `wrapHandler` produces `{ success: false }` and the error toast fires. The renderer previously guessed the outcome by reading the update store after the await, which cannot distinguish "checked, nothing newer" from "no check ran at all" — so a portable build, which can never update in place, reported "You're up to date" without having checked anything. The spinner→outcome pair shares one `coalesceKey` (`app-update:manual-check`) so repeat clicks collapse into a single toast rather than stacking (see [toast-coalesce-contract.md](../../.claude/memory/contracts/toast-coalesce-contract.md)).

The renderer subscribes via `useAutoUpdateListener` which listens on five push channels — `APP_UPDATE_AVAILABLE`, `APP_UPDATE_PROGRESS`, `APP_UPDATE_DOWNLOADED`, `APP_UPDATE_ERROR`, `APP_UPDATE_DUPLICATE` — and feeds the [app-update-store.ts](../../src/renderer/src/stores/app-update-store.ts) Zustand store. The store holds `{ status, version, downloadPercent, releaseNotes, error, readySince, duplicate, rollbackInFlight }` and exposes `setAvailable`, `setProgress`, `setReady`, `setError`, `setDuplicate`, `setRollbackInFlight`, and `dismiss` actions. `UpdateBanner.tsx` reads the store and branches on `status`: `'idle' | 'checking' | 'dismissed'` returns `null` (banner hidden); `'error'` renders the red-text recovery banner with an external link to `https://github.com/jlstradingco/Agent-Orchestrator-releases/releases/latest`; `'downloading'` renders the progress bar **only when `rollbackInFlight` is set** (a user-initiated rollback) — an ordinary automatic download stays silent and renders `null`, since it applies on the next quit regardless; `'duplicate'` renders the amber "newer copy exists elsewhere" banner with the one-click switch; `'ready'` renders the Restart Now / Later pair. `rollbackInFlight` is a renderer-only flag set at the rollback click (in `VersionHistorySettings.tsx`) and cleared when the rollback ends — on `setError` (an async downgrade failure), on `dismiss`, or when the rollback IPC resolves `null` (a synchronous precondition failure such as no snapshot / low disk emits no `APP_UPDATE_ERROR`, so nothing else would clear it); a successful rollback restarts the app, which resets the store.

The error branch in `UpdateBanner.tsx` is the user-visible part of the silent-failure fix from 2026-04-27. Before that fix, the banner returned `null` for `'error'` too — the failure was real, the store held the error message, but the user saw nothing. The matching backend change in `app-update-service.ts` is the addition of explicit `log.info` / `log.error` calls at every lifecycle event (the file used to suppress electron-updater's logger and emit nothing of its own, so a failing update check left zero entries in `main.log`). See [auto-update-distribution-postmortem.md](../../.claude/memory/postmortems/auto-update-distribution-postmortem.md) Bug 3 for the full diagnosis, the timeline, and the explicit instruction to keep `autoUpdater.logger = null` AND emit our own logs (the "do not change" rule was misread by an earlier agent as "log nothing", which is exactly what caused this bug).

The release pipeline that produces the binaries lives in `.github/workflows/release.yml`. Since 2026-09-29 the app is built ONCE (`build-app`), packaged by a 3-OS matrix off that one artifact (`package-matrix`), and only then is the version minted (`version-bump`) — so a broken build or package can no longer leave a tag pointing at a release nobody can install. The release uploads and the R2 mirror sit in `stage-release-assets`, and `publish` ends with a channel-aware `gh release edit` so the new release is visible to electron-updater. Cutting one is now a single command: `npm run release:cut`. See `.claude/memory/contracts/release-build-once-contract.md`. The earlier draft-stuck bug is documented in the same postmortem (Bug 1). One operational note: `gh release edit --draft=false` resets `published_at`, which can flip GitHub's "Latest" pointer to a stale older release. After any un-drafting, run `gh release list` and re-assert `gh release edit v<current> --latest` if needed.

## Related

- [logs-and-debugging.md](logs-and-debugging.md) — where `main.log` lives and how to read `[app-update]` lifecycle entries
- [toolchain-update-v3.md](toolchain-update-v3.md) — different system: updates the bundled CLIs (Claude Code, gh, git) at the OS level, not the Omniscio binary itself
- [notifications-and-silence.md](notifications-and-silence.md) — the update banner is purely visual and does not route through the OS notification stack
