support orca

This commit is contained in:
Neil 2026-04-18 00:10:27 -07:00
parent b659fb339f
commit 3a67eb0f9c
12 changed files with 566 additions and 2 deletions

View file

@ -33,6 +33,7 @@ import { createMainWindow } from './window/createMainWindow'
import { CodexAccountService } from './codex-accounts/service'
import { CodexRuntimeHomeService } from './codex-accounts/runtime-home-service'
import { openCodeHookService } from './opencode/hook-service'
import { StarNagService } from './star-nag/service'
let mainWindow: BrowserWindow | null = null
/** Whether a manual app.quit() (Cmd+Q, etc.) is in progress. Shared with the
@ -48,6 +49,7 @@ let codexRuntimeHome: CodexRuntimeHomeService | null = null
let runtime: OrcaRuntimeService | null = null
let rateLimits: RateLimitService | null = null
let runtimeRpc: OrcaRuntimeRpcServer | null = null
let starNag: StarNagService | null = null
installUncaughtPipeErrorGuard()
patchPackagedProcessPath()
@ -137,6 +139,9 @@ app.whenReady().then(async () => {
codexAccounts = new CodexAccountService(store, rateLimits, codexRuntimeHome)
rateLimits.setCodexHomePathResolver(() => codexRuntimeHome!.prepareForRateLimitFetch())
runtime = new OrcaRuntimeService(store, stats)
starNag = new StarNagService(store, stats)
starNag.start()
starNag.registerIpcHandlers()
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
registerAppMenu({
onCheckForUpdates: () => checkForUpdatesFromMenu(),
@ -242,6 +247,7 @@ app.on('will-quit', () => {
// so without this ordering, running agents would produce orphaned
// agent_start events with no matching stops.
openCodeHookService.stop()
starNag?.stop()
stats?.flush()
killAllPty()
// Why: in daemon mode, killAllPty is a no-op (daemon sessions survive app

View file

@ -0,0 +1,172 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import { STAR_NAG_INITIAL_THRESHOLD } from '../../shared/constants'
import { checkOrcaStarred } from '../github/client'
import type { Store } from '../persistence'
import type { StatsCollector } from '../stats/collector'
/**
* Service that decides when to prompt the user with the "star Orca on GitHub"
* notification. Counts agents spawned since the current app version was first
* seen; crosses a doubling threshold (default 50 100 200 ) to fire the
* renderer notification via 'star-nag:show'.
*
* State lives in PersistedUIState so it survives restarts alongside the rest
* of the UI preferences (dismissed update versions, etc).
*/
export class StarNagService {
private store: Store
private stats: StatsCollector
private disposeStatsListener: (() => void) | null = null
// Why: once we broadcast the card, the renderer owns the UI until the user
// dismisses or stars. Without this in-memory guard, every subsequent
// agent_start past the threshold would re-enter maybeShow() and spawn a new
// `gh api` subprocess on each spawn — cheap individually, but a power user
// at 55 agents with threshold 50 would fork gh on every spawn until they
// act on the card.
private promptVisible = false
// Why: prevent concurrent gh invocations if agents spawn rapidly during the
// tiny window between crossing the threshold and the first gh check
// resolving.
private evaluating = false
constructor(store: Store, stats: StatsCollector) {
this.store = store
this.stats = stats
}
start(): void {
// Why: capture the baseline eagerly on first boot after an update so the
// "agents since update" counter doesn't include pre-update spawns. We do
// this here instead of waiting for the next agent_start so that a brand
// new install with a pre-existing stats file (unusual, but possible via
// copy of userData) starts from a sensible baseline.
this.ensureBaseline()
this.disposeStatsListener = this.stats.onAgentStarted((total) => {
this.handleAgentSpawned(total)
})
}
stop(): void {
this.disposeStatsListener?.()
this.disposeStatsListener = null
}
registerIpcHandlers(): void {
ipcMain.handle('star-nag:dismiss', () => this.dismiss())
ipcMain.handle('star-nag:complete', () => this.markCompleted())
ipcMain.handle('star-nag:forceShow', () => this.forceShow())
}
// ── State helpers ─────────────────────────────────────────────────
private ensureBaseline(): void {
const ui = this.store.getUI()
const currentVersion = app.getVersion()
if (ui.starNagAppVersion === currentVersion && ui.starNagBaselineAgents != null) {
return
}
// Why: reset both the baseline and the threshold so the user gets a fresh
// nag countdown after each update. Past dismissal state is intentionally
// discarded — shipping new value is the whole reason we bother asking
// again. `starNagCompleted` is preserved so we never re-ask someone who
// already starred.
this.store.updateUI({
starNagAppVersion: currentVersion,
starNagBaselineAgents: this.stats.getTotalAgentsSpawned(),
starNagNextThreshold: STAR_NAG_INITIAL_THRESHOLD
})
}
private handleAgentSpawned(total: number): void {
if (this.promptVisible || this.evaluating) {
return
}
const ui = this.store.getUI()
if (ui.starNagCompleted) {
return
}
// Guard against drift: if the version changed since last boot but we
// haven't rehydrated yet (e.g. in-process update on Linux AppImage), fix
// the baseline before evaluating the threshold so we don't instantly fire.
const currentVersion = app.getVersion()
if (ui.starNagAppVersion !== currentVersion) {
this.ensureBaseline()
return
}
const baseline = ui.starNagBaselineAgents ?? total
const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD
const sinceBaseline = total - baseline
if (sinceBaseline < threshold) {
return
}
void this.maybeShow()
}
private async maybeShow(): Promise<void> {
if (this.promptVisible || this.evaluating) {
return
}
this.evaluating = true
try {
// Why: the notification is only useful for users whose gh CLI can
// actually perform the star. Calling checkOrcaStarred both gates on gh
// availability and skips users who already starred outside the app.
// Errors (network, gh missing) map to null — skip silently and leave
// state unchanged so we retry on the next spawn without racing forward
// to the next threshold.
const starred = await checkOrcaStarred()
if (starred === null) {
return
}
if (starred) {
// Already starred somewhere — lock in the permanent suppression so we
// stop recomputing thresholds on every spawn.
this.markCompleted()
return
}
this.promptVisible = true
this.broadcastShow()
} finally {
this.evaluating = false
}
}
private broadcastShow(): void {
const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed())
if (!win) {
return
}
win.webContents.send('star-nag:show')
}
// ── Public actions (invoked from IPC) ─────────────────────────────
/**
* User closed the notification without starring double the threshold and
* rebase the baseline so the next fire is "threshold more agents since this
* dismissal" (not "threshold total since install"). This matches the
* product intent of exponential back-off: 50 more, then 100 more, then 200
* more, etc.
*/
private dismiss(): void {
const ui = this.store.getUI()
const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD
this.store.updateUI({
starNagNextThreshold: threshold * 2,
starNagBaselineAgents: this.stats.getTotalAgentsSpawned()
})
this.promptVisible = false
}
/** User successfully starred → never nag again. */
private markCompleted(): void {
this.store.updateUI({ starNagCompleted: true })
this.promptVisible = false
}
/** Dev-only entry point: skip all gating and fire the notification. */
private forceShow(): void {
this.promptVisible = true
this.broadcastShow()
}
}

View file

@ -55,6 +55,10 @@ export class StatsCollector {
private aggregates: StatsAggregates
private liveAgents = new Map<string, number>() // ptyId → startTimestamp
private writeTimer: ReturnType<typeof setTimeout> | null = null
// Why: star-nag lives in its own service but needs to observe the running
// agent-spawned counter. A lightweight listener avoids cyclic imports and
// keeps StatsCollector unaware of how the counter is consumed.
private agentStartListeners: ((totalAgentsSpawned: number) => void)[] = []
constructor() {
const data = this.load()
@ -62,6 +66,17 @@ export class StatsCollector {
this.aggregates = data.aggregates
}
onAgentStarted(listener: (totalAgentsSpawned: number) => void): () => void {
this.agentStartListeners.push(listener)
return () => {
this.agentStartListeners = this.agentStartListeners.filter((l) => l !== listener)
}
}
getTotalAgentsSpawned(): number {
return this.aggregates.totalAgentsSpawned
}
// ── Recording ──────────────────────────────────────────────────────
record(event: StatsEvent): void {
@ -173,6 +188,17 @@ export class StatsCollector {
switch (event.type) {
case 'agent_start':
this.aggregates.totalAgentsSpawned++
// Why: notify listeners synchronously AFTER increment so observers
// see the post-increment count. Listener errors are swallowed to
// keep stat recording robust — a buggy listener must not lose the
// event from the on-disk log.
for (const listener of this.agentStartListeners) {
try {
listener(this.aggregates.totalAgentsSpawned)
} catch (err) {
console.error('[stats] agent-start listener threw:', err)
}
}
break
case 'pr_created':
this.aggregates.totalPRsCreated++

View file

@ -369,6 +369,12 @@ export type PreloadApi = {
checkOrcaStarred: () => Promise<boolean | null>
starOrca: () => Promise<boolean>
}
starNag: {
onShow: (callback: () => void) => () => void
dismiss: () => Promise<void>
complete: () => Promise<void>
forceShow: () => Promise<void>
}
settings: {
get: () => Promise<GlobalSettings>
set: (args: Partial<GlobalSettings>) => Promise<GlobalSettings>

View file

@ -408,6 +408,17 @@ const api = {
starOrca: (): Promise<boolean> => ipcRenderer.invoke('gh:starOrca')
},
starNag: {
onShow: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent): void => callback()
ipcRenderer.on('star-nag:show', listener)
return () => ipcRenderer.removeListener('star-nag:show', listener)
},
dismiss: (): Promise<void> => ipcRenderer.invoke('star-nag:dismiss'),
complete: (): Promise<void> => ipcRenderer.invoke('star-nag:complete'),
forceShow: (): Promise<void> => ipcRenderer.invoke('star-nag:forceShow')
},
settings: {
get: (): Promise<unknown> => ipcRenderer.invoke('settings:get'),

View file

@ -24,6 +24,7 @@ import WorktreeJumpPalette from './components/WorktreeJumpPalette'
import NewWorkspaceComposerModal from './components/NewWorkspaceComposerModal'
import { StatusBar } from './components/status-bar/StatusBar'
import { UpdateCard } from './components/UpdateCard'
import { StarNagCard } from './components/StarNagCard'
import { ZoomOverlay } from './components/ZoomOverlay'
import { SshPassphraseDialog } from './components/settings/SshPassphraseDialog'
import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling'
@ -884,6 +885,7 @@ function App(): React.JSX.Element {
<QuickOpen />
<WorktreeJumpPalette />
<UpdateCard />
<StarNagCard />
<ZoomOverlay />
<SshPassphraseDialog />
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />

View file

@ -87,7 +87,12 @@ function GitHubStarButton({ hasRepos }: { hasRepos: boolean }): React.JSX.Elemen
const ok = await window.api.gh.starOrca()
if (!ok) {
setState('not-starred')
return
}
// Why: starring from any entry point mutes the threshold-based nag.
// Without this the background notification could still fire on the next
// threshold crossing, which would feel like a bug to the user.
await window.api.starNag.complete()
}
// Hide if gh CLI is unavailable, or if the user has already starred and added a repo

View file

@ -0,0 +1,134 @@
import { useEffect, useState } from 'react'
import { Star, X } from 'lucide-react'
import { Card } from './ui/card'
import { Button } from './ui/button'
import { useAppStore } from '../store'
/**
* Persistent "star Orca on GitHub" notification card.
*
* Rendered at the bottom-right of the app (alongside UpdateCard). It is
* intentionally non-auto-dismissing: the user must either click Star or the
* close button. Dismissing doubles the next-trigger threshold in the main
* process so the nag backs off exponentially.
*
* Visibility is driven by the main-process 'star-nag:show' IPC event this
* component does no threshold math or gh-CLI checks locally.
*/
export function StarNagCard(): React.JSX.Element | null {
const [visible, setVisible] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState(false)
// Why: UpdateCard lives at the same bottom-right slot. When it is visible
// (any non-idle / non-not-available state), stack the star-nag card above
// it instead of overlapping — we must not cover a pending update prompt
// because that's a higher-priority action.
const updateStatus = useAppStore((s) => s.updateStatus)
const updateCardVisible = updateStatus.state !== 'idle' && updateStatus.state !== 'not-available'
useEffect(() => {
return window.api.starNag.onShow(() => {
setError(false)
setVisible(true)
})
}, [])
const handleClose = (): void => {
setVisible(false)
// Why: fire-and-forget. If persisting the dismissal fails the worst case
// is we re-fire the same threshold on next launch — not worth blocking
// the close animation on.
void window.api.starNag.dismiss()
}
useEffect(() => {
if (!visible) {
return
}
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
handleClose()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
// eslint-disable-next-line react-hooks/exhaustive-deps -- handleClose closes
// over stable refs; re-binding on each render is unnecessary.
}, [visible])
if (!visible) {
return null
}
const handleStar = async (): Promise<void> => {
if (busy) {
return
}
setBusy(true)
setError(false)
const ok = await window.api.gh.starOrca()
setBusy(false)
if (!ok) {
setError(true)
return
}
await window.api.starNag.complete()
setVisible(false)
}
return (
<div
// Why: when UpdateCard is up, it occupies bottom-10. Raise the star-nag
// card above it so both are visible — the update action stays on top
// visually (it's the higher-priority one) and the star-nag sits above.
className={`fixed right-4 z-40 w-[360px] max-w-[calc(100vw-32px)]
max-[480px]:left-4 max-[480px]:right-4 max-[480px]:w-auto ${
updateCardVisible ? 'bottom-[220px]' : 'bottom-10'
}`}
>
<Card className="py-0 gap-0" role="complementary" aria-labelledby="star-nag-heading">
<div className="flex flex-col gap-2.5 p-3.5">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Star className="size-4 fill-amber-400/60 text-amber-400/80" />
<h3 id="star-nag-heading" className="text-sm font-semibold">
Enjoying Orca?
</h3>
</div>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
onClick={handleClose}
aria-label="Dismiss"
>
<X className="size-3.5" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
If Orca has saved you time, a GitHub star goes a long way. It helps other developers
discover the project and keeps the team motivated to ship improvements.
</p>
{error ? (
<p className="text-xs text-destructive">
Could not star the repo. Make sure <code>gh</code> is authenticated and try again.
</p>
) : null}
<Button
variant="default"
size="sm"
onClick={() => void handleStar()}
disabled={busy}
className="mt-0.5 w-full gap-1.5"
>
<Star className="size-3.5" />
{busy ? 'Starring…' : 'Star on GitHub'}
</Button>
</div>
</Card>
</div>
)
}

View file

@ -8,7 +8,7 @@ import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Separator } from '../ui/separator'
import { Download, FolderOpen, Loader2, Plus, RefreshCw, Timer, Trash2 } from 'lucide-react'
import { Download, FolderOpen, Loader2, Plus, RefreshCw, Star, Timer, Trash2 } from 'lucide-react'
import { useAppStore } from '../../store'
import { CliSection } from './CliSection'
import { toast } from 'sonner'
@ -24,6 +24,7 @@ import {
GENERAL_CLI_SEARCH_ENTRIES,
GENERAL_EDITOR_SEARCH_ENTRIES,
GENERAL_PANE_SEARCH_ENTRIES,
GENERAL_SUPPORT_SEARCH_ENTRIES,
GENERAL_UPDATE_SEARCH_ENTRIES,
GENERAL_WORKSPACE_SEARCH_ENTRIES
} from './general-search'
@ -107,11 +108,56 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}`
>('idle')
const [removeAccountId, setRemoveAccountId] = useState<string | null>(null)
// Why: the star state is derived from gh, not from settings, so it does not
// live in the global settings store. 'hidden' covers the gh-unavailable and
// already-starred-on-a-previous-session cases so the section drops out for
// users who can't or don't need to act.
//
// We start in 'loading' and render a placeholder at the exact same
// dimensions as the resolved section. When gh resolves to 'hidden', the
// placeholder collapses with a grid-rows transition so content above it
// doesn't shift; anything below (nothing today, but future-proof) eases up.
const [starState, setStarState] = useState<
'loading' | 'not-starred' | 'starred' | 'starring' | 'hidden' | 'error'
>('loading')
useEffect(() => {
window.api.updater.getVersion().then(setAppVersion)
}, [])
useEffect(() => {
let cancelled = false
void window.api.gh.checkOrcaStarred().then((result) => {
if (cancelled) {
return
}
if (result === null) {
setStarState('hidden')
} else {
setStarState(result ? 'starred' : 'not-starred')
}
})
return () => {
cancelled = true
}
}, [])
const handleStarClick = async (): Promise<void> => {
if (starState !== 'not-starred' && starState !== 'error') {
return
}
setStarState('starring')
const ok = await window.api.gh.starOrca()
if (!ok) {
setStarState('error')
return
}
setStarState('starred')
// Why: clicking star anywhere should also permanently mute the
// threshold-based nag so the user isn't re-prompted via the popup.
await window.api.starNag.complete()
}
useEffect(() => {
setAutoSaveDelayDraft(String(settings.editorAutoSaveDelayMs))
}, [settings.editorAutoSaveDelayMs])
@ -802,6 +848,10 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
</SearchableSetting>
</section>
) : null
// Note: the Support section is rendered outside this array so it can own
// its own loading placeholder and its own collapsing Separator. Without
// that separation, a dangling divider would remain above the collapsed
// section.
].filter(Boolean)
return (
@ -846,6 +896,127 @@ export function GeneralPane({ settings, updateSettings }: GeneralPaneProps): Rea
{section}
</div>
))}
{matchesSettingsSearch(searchQuery, GENERAL_SUPPORT_SEARCH_ENTRIES) ? (
<SupportSection
state={starState}
hasPrecedingSections={visibleSections.length > 0}
onStarClick={handleStarClick}
/>
) : null}
</div>
)
}
type SupportSectionProps = {
state: 'loading' | 'not-starred' | 'starring' | 'starred' | 'hidden' | 'error'
hasPrecedingSections: boolean
onStarClick: () => void | Promise<void>
}
function SupportSection({
state,
hasPrecedingSections,
onStarClick
}: SupportSectionProps): React.JSX.Element {
// Why: 'hidden' means gh is unavailable or the user had already starred on a
// previous session — in both cases we collapse the entire section (including
// its leading Separator) so the settings pane doesn't carry an empty strip.
// For every other state we render the full row so the initial layout is
// stable: the skeleton-to-live swap happens in place and a post-click
// "Starred" confirmation does not shift anything above or below it.
const collapsed = state === 'hidden'
return (
<section
className={`grid transition-[grid-template-rows,opacity] duration-300 ease-out ${
collapsed ? 'grid-rows-[0fr] opacity-0' : 'grid-rows-[1fr] opacity-100'
}`}
aria-hidden={collapsed}
>
<div className="min-h-0 overflow-hidden">
<div className="space-y-8">
{hasPrecedingSections ? <Separator /> : null}
<div className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Support Orca</h3>
</div>
{state === 'loading' ? <SupportRowSkeleton /> : null}
{state !== 'loading' && state !== 'hidden' ? (
<SupportRow state={state} onStarClick={onStarClick} />
) : null}
</div>
</div>
</div>
</section>
)
}
function SupportRowSkeleton(): React.JSX.Element {
return (
<div className="flex items-center justify-between gap-4 px-1 py-2" aria-hidden="true">
<div className="h-4 w-36 rounded bg-muted/50 animate-pulse" />
<div className="h-8 w-24 rounded-md bg-muted/50 animate-pulse" />
</div>
)
}
function SupportRow({
state,
onStarClick
}: {
state: 'not-starred' | 'starring' | 'starred' | 'error'
onStarClick: () => void | Promise<void>
}): React.JSX.Element {
// Why: the left-hand label is the setting's identity and must not change
// when the user clicks — the row should still read "Star Orca on GitHub"
// afterwards. The right-hand control is what changes: before starring it
// is a button; after a successful star we swap in a small inline "Thanks"
// confirmation so the row keeps the same shape without showing a stale,
// disabled button.
return (
<SearchableSetting
title="Star Orca on GitHub"
description="Support the project with a GitHub star via the gh CLI."
keywords={['star', 'github', 'support', 'feedback', 'like']}
className="flex items-center justify-between gap-4 px-1 py-2"
>
<Label>Star Orca on GitHub</Label>
{state === 'starred' ? (
<SupportRowThanks />
) : (
<Button
variant="default"
size="sm"
onClick={() => void onStarClick()}
disabled={state === 'starring'}
className="shrink-0 gap-1.5"
>
{state === 'starring' ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Star className="size-3.5" />
)}
{state === 'starring' ? 'Starring…' : state === 'error' ? 'Try Again' : 'Star'}
</Button>
)}
</SearchableSetting>
)
}
function SupportRowThanks(): React.JSX.Element {
// Why: match the size="sm" button's h-8 / gap-1.5 / px-3 dimensions so the
// row height stays identical when the button is swapped out. Without the
// fixed height, the text baseline collapses ~6px and the entire row
// shrinks, shifting everything below.
return (
<div
className="shrink-0 inline-flex h-8 items-center gap-1.5 px-3 text-sm font-medium
text-amber-400/90 animate-in fade-in slide-in-from-right-1 duration-300"
role="status"
aria-live="polite"
>
<Star className="size-3.5 fill-amber-400/80 text-amber-400/80" aria-hidden="true" />
Thanks for the support!
</div>
)
}

View file

@ -86,11 +86,20 @@ export const GENERAL_AGENT_SEARCH_ENTRIES: SettingsSearchEntry[] = [
}
]
export const GENERAL_SUPPORT_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Star Orca on GitHub',
description: 'Support the project with a GitHub star via the gh CLI.',
keywords: ['star', 'github', 'support', 'feedback', 'like']
}
]
export const GENERAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
...GENERAL_WORKSPACE_SEARCH_ENTRIES,
...GENERAL_EDITOR_SEARCH_ENTRIES,
...GENERAL_CLI_SEARCH_ENTRIES,
...GENERAL_CACHE_TIMER_SEARCH_ENTRIES,
...GENERAL_CODEX_ACCOUNTS_SEARCH_ENTRIES,
...GENERAL_UPDATE_SEARCH_ENTRIES
...GENERAL_UPDATE_SEARCH_ENTRIES,
...GENERAL_SUPPORT_SEARCH_ENTRIES
]

View file

@ -43,6 +43,12 @@ export const DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS = 1000
export const MIN_EDITOR_AUTO_SAVE_DELAY_MS = 250
export const MAX_EDITOR_AUTO_SAVE_DELAY_MS = 10_000
// Why: initial threshold of agents spawned (since last update) before we show
// the star-on-GitHub notification. Doubles each time the user dismisses
// without starring — e.g. 50 → 100 → 200 → 400. Past dismissals are encoded
// in starNagNextThreshold, so this constant is only the first-time seed.
export const STAR_NAG_INITIAL_THRESHOLD = 50
export const DEFAULT_WORKTREE_CARD_PROPERTIES: WorktreeCardProperty[] = [
'status',
'unread',

View file

@ -722,6 +722,22 @@ export type PersistedUIState = {
* migration never re-fires allowing users to intentionally select the
* new 'recent' (creation-time) sort without it being clobbered on restart. */
_sortBySmartMigrated?: boolean
/** Snapshot of totalAgentsSpawned captured the first time we see the current
* app version. Why: the nag threshold counts agents spawned *since the
* user's last update* so a fresh install or new release does not trigger
* the notification immediately. Reset whenever starNagAppVersion changes. */
starNagBaselineAgents?: number | null
/** The app version that set the current baseline. When the live app version
* differs from this value, the baseline is re-captured on next agent
* spawn effectively restarting the nag countdown after each update. */
starNagAppVersion?: string | null
/** Next threshold (agents spawned since baseline) at which the star-nag
* notification should fire. Starts at 50 and doubles each time the user
* dismisses the notification without starring. */
starNagNextThreshold?: number
/** Once the user has starred Orca (from any entry point) we permanently
* suppress the nag no further thresholds, no notifications. */
starNagCompleted?: boolean
}
// ─── Persistence shape ──────────────────────────────────────────────