diff --git a/config/scripts/generate-skill-bundle-manifest.mjs b/config/scripts/generate-skill-bundle-manifest.mjs
index bdcc0aecca4..c3542d6f0e2 100644
--- a/config/scripts/generate-skill-bundle-manifest.mjs
+++ b/config/scripts/generate-skill-bundle-manifest.mjs
@@ -4,6 +4,7 @@ import { constants } from 'node:fs'
import { access, lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
+import { isDeepStrictEqual } from 'node:util'
const SCHEMA_VERSION = 1
const SCRIPT_DIR = import.meta.dirname
@@ -424,15 +425,17 @@ function assertReleasedHistoryPreserved(committedRegistry, artifacts) {
for (const [name, committedSnapshots] of Object.entries(committedRegistry.skills ?? {})) {
const releasedCount = artifacts.releasedSnapshotCounts[name] ?? 0
const regenerated = artifacts.snapshotRegistry.skills[name] ?? []
+ if (releasedCount < Math.max(0, committedSnapshots.length - 1)) {
+ throw new Error(
+ `Released snapshot history is incomplete for ${name}. ` +
+ 'Fetch all release tags before regenerating skill artifacts.'
+ )
+ }
const protectedCount = Math.min(committedSnapshots.length, releasedCount)
for (let index = 0; index < protectedCount; index += 1) {
const committed = committedSnapshots[index]
const rebuilt = regenerated[index]
- if (
- !rebuilt ||
- rebuilt.releaseRevision !== committed.releaseRevision ||
- rebuilt.packageDigest !== committed.packageDigest
- ) {
+ if (!rebuilt || !isDeepStrictEqual(rebuilt, committed)) {
throw new Error(
`Released snapshot history changed for ${name} at revision ${committed.releaseRevision}. ` +
'Released snapshots are append-only; a deliberate identity migration must update this check.'
diff --git a/config/scripts/generate-skill-bundle-manifest.test.mjs b/config/scripts/generate-skill-bundle-manifest.test.mjs
index 063b30c57f3..cf4a8a100e5 100644
--- a/config/scripts/generate-skill-bundle-manifest.test.mjs
+++ b/config/scripts/generate-skill-bundle-manifest.test.mjs
@@ -111,6 +111,28 @@ describe('skill bundle manifest generator', () => {
artifacts
)
).toThrow('Released snapshot history changed for orca-cli at revision 2')
+ expect(() =>
+ assertReleasedHistoryPreserved(
+ {
+ schemaVersion: 1,
+ skills: {
+ 'orca-cli': [snapshot(1, 'aaa'), { ...snapshot(2, 'bbb'), gitTreeSha: 'rewritten' }]
+ }
+ },
+ artifacts
+ )
+ ).toThrow('Released snapshot history changed for orca-cli at revision 2')
+ expect(() =>
+ assertReleasedHistoryPreserved(
+ {
+ schemaVersion: 1,
+ skills: {
+ 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')]
+ }
+ },
+ { ...artifacts, releasedSnapshotCounts: { 'orca-cli': 1 } }
+ )
+ ).toThrow('Released snapshot history is incomplete for orca-cli')
expect(() => assertReleasedHistoryPreserved(null, artifacts)).not.toThrow()
})
diff --git a/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx b/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx
index d6dd054e453..ca0090a6c68 100644
--- a/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx
+++ b/src/renderer/src/components/skills/SkillFreshnessNudge.test.tsx
@@ -16,7 +16,8 @@ const mocks = vi.hoisted(() => ({
toastDismiss: vi.fn(),
requestDialog: vi.fn(),
settingsLoaded: true,
- inventory: null as SkillFreshnessInventory | null
+ inventory: null as SkillFreshnessInventory | null,
+ error: null as string | null
}))
function placement(
@@ -58,7 +59,7 @@ vi.mock('@/hooks/useSkillFreshness', () => ({
useSkillFreshness: () => ({
inventory: mocks.inventory,
loading: false,
- error: null,
+ error: mocks.error,
refresh: vi.fn()
})
}))
@@ -106,6 +107,7 @@ describe('SkillFreshnessNudge', () => {
mocks.dismissed = []
mocks.settingsLoaded = true
mocks.inventory = eligibleInventory()
+ mocks.error = null
mocks.updateSettings.mockReset()
mocks.updateSettings.mockResolvedValue(undefined)
mocks.toastInfo.mockReset()
@@ -162,6 +164,19 @@ describe('SkillFreshnessNudge', () => {
expect(mocks.updateSettings).not.toHaveBeenCalled()
})
+ it('retracts a stale nudge when re-inventory fails', async () => {
+ await renderNudge()
+ const options = mocks.toastInfo.mock.calls[0]?.[1]
+
+ mocks.inventory = null
+ mocks.error = 'scan failed'
+ await rerenderNudge()
+ options.onDismiss()
+
+ expect(mocks.toastDismiss).toHaveBeenCalledWith('freshness-toast')
+ expect(mocks.updateSettings).not.toHaveBeenCalled()
+ })
+
it('persists the exact placement/revision key once on explicit dismissal', async () => {
await renderNudge()
diff --git a/src/renderer/src/components/skills/SkillFreshnessNudge.tsx b/src/renderer/src/components/skills/SkillFreshnessNudge.tsx
index c4ea551a5c4..1d1b4fd668b 100644
--- a/src/renderer/src/components/skills/SkillFreshnessNudge.tsx
+++ b/src/renderer/src/components/skills/SkillFreshnessNudge.tsx
@@ -35,7 +35,18 @@ export function SkillFreshnessNudge(): null {
useEffect(() => {
const inventory = state.inventory
- if (!settingsLoaded || !inventory) {
+ if (!settingsLoaded) {
+ return
+ }
+ if (!inventory) {
+ const active = activeNudgeRef.current
+ if (state.error && active) {
+ // Why: a failed re-check cannot keep advertising authority derived from
+ // old bytes; retract without turning the scan failure into a dismissal.
+ active.persistDismissal = false
+ activeNudgeRef.current = null
+ toast.dismiss(active.id)
+ }
return
}
const eligibleNames = new Set(inventory.eligibleUpdateNames)
@@ -152,7 +163,7 @@ export function SkillFreshnessNudge(): null {
}
)
activeNudgeRef.current = nextActive
- }, [dismissed, settingsLoaded, state.inventory, updateSettings])
+ }, [dismissed, settingsLoaded, state.error, state.inventory, updateSettings])
return null
}
diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx
index d9af5daf44a..5c14b94182d 100644
--- a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx
+++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx
@@ -16,6 +16,7 @@ import {
const mocks = vi.hoisted(() => ({
inventory: null as SkillFreshnessInventory | null,
loading: false,
+ error: null as string | null,
refresh: vi.fn(),
terminalProps: [] as {
command: string
@@ -30,7 +31,7 @@ vi.mock('@/hooks/useSkillFreshness', () => ({
useSkillFreshness: () => ({
inventory: mocks.inventory,
loading: mocks.loading,
- error: null,
+ error: mocks.error,
refresh: mocks.refresh
})
}))
@@ -139,6 +140,7 @@ describe('SkillFreshnessUpdateDialog', () => {
consumeSkillFreshnessUpdateDialogRequest()
mocks.inventory = eligibleInventory()
mocks.loading = false
+ mocks.error = null
mocks.refresh.mockReset()
mocks.notifyChanged.mockReset()
mocks.terminalProps.length = 0
@@ -210,6 +212,14 @@ describe('SkillFreshnessUpdateDialog', () => {
await act(async () => {
mocks.terminalProps.at(-1)?.onInteracted?.('keyboard', { key: 'Enter' })
})
+ mocks.inventory = null
+ mocks.loading = true
+ await rerender()
+
+ expect(container?.querySelector('[data-testid="update-terminal"]')?.textContent).toBe(
+ 'npx skills update orca-cli --global'
+ )
+
mocks.inventory = {
schemaVersion: 1,
installations: [
@@ -218,6 +228,7 @@ describe('SkillFreshnessUpdateDialog', () => {
eligibleUpdateNames: [],
scannedAt: 2
}
+ mocks.loading = false
await rerender()
expect(container?.textContent).toContain('All installed Orca skills are up to date.')
@@ -226,6 +237,30 @@ describe('SkillFreshnessUpdateDialog', () => {
)
})
+ it('removes an unsubmitted draft as soon as its inventory is invalidated', async () => {
+ await renderDialog()
+ await openViaRequest()
+ expect(container?.querySelector('[data-testid="update-terminal"]')).not.toBeNull()
+
+ mocks.inventory = null
+ mocks.loading = true
+ await rerender()
+
+ expect(container?.textContent).toContain('Checking installed Orca skills')
+ expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull()
+ })
+
+ it('shows a failed scan as an error instead of indefinite progress', async () => {
+ mocks.inventory = null
+ mocks.error = 'Could not inspect installed skills.'
+ await renderDialog()
+ await openViaRequest()
+
+ expect(container?.textContent).toContain('Could not inspect installed skills.')
+ expect(container?.textContent).not.toContain('Checking installed Orca skills')
+ expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull()
+ })
+
it('creates a fresh draft after a terminal exits and the rescan still finds an update', async () => {
await renderDialog()
await openViaRequest()
diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx
index 2b0a4ec97ce..61b56fee950 100644
--- a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx
+++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.tsx
@@ -149,7 +149,15 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element {
}, [])
useEffect(() => {
- if (!open || state.loading || state.error) {
+ if (!open) {
+ return
+ }
+ if (state.loading || state.error || !inventory) {
+ // Why: a scan invalidates the authorization behind an unsubmitted draft.
+ // A running command keeps its PTY until exit, but stale drafts fail closed.
+ if (!terminalSubmittedRef.current && terminalCommand !== null) {
+ setTerminalCommand(null)
+ }
return
}
if (awaitingExitRefresh) {
@@ -226,9 +234,11 @@ export function SkillFreshnessUpdateDialog(): React.JSX.Element {
- {state.error ?
{state.error}
: null}
-
-
+ {state.error ? (
+ {state.error}
+ ) : (
+
+ )}
{terminalCommand ? (
(): {
promise: Promise
resolve: (value: T) => void
+ reject: (cause: unknown) => void
} {
let resolve!: (value: T) => void
- const promise = new Promise((complete) => {
+ let reject!: (cause: unknown) => void
+ const promise = new Promise((complete, fail) => {
resolve = complete
+ reject = fail
})
- return { promise, resolve }
+ return { promise, resolve, reject }
}
function inventory(scannedAt: number): SkillFreshnessInventory {
@@ -28,9 +31,11 @@ function inventory(scannedAt: number): SkillFreshnessInventory {
let root: Root | null = null
let container: HTMLDivElement | null = null
let state: SkillFreshnessState | null = null
+const states = new Map()
-function Probe(): null {
+function Probe({ id = 'default' }: { id?: string }): null {
state = useSkillFreshness()
+ states.set(id, state)
return null
}
@@ -38,6 +43,7 @@ describe('useSkillFreshness', () => {
beforeEach(() => {
_skillFreshnessCacheForTests.reset()
state = null
+ states.clear()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
@@ -118,4 +124,90 @@ describe('useSkillFreshness', () => {
await act(async () => second.resolve(inventory(2)))
expect(freshnessInventory).toHaveBeenCalledTimes(2)
})
+
+ it('publishes a manual refresh to every consumer', async () => {
+ const second = deferred()
+ const freshnessInventory = vi
+ .fn()
+ .mockResolvedValueOnce(inventory(1))
+ .mockReturnValueOnce(second.promise)
+ window.api = { skills: { freshnessInventory } } as never
+
+ await act(async () =>
+ root?.render(
+ <>
+
+
+ >
+ )
+ )
+ expect(states.get('one')?.inventory?.scannedAt).toBe(1)
+ expect(states.get('two')?.inventory?.scannedAt).toBe(1)
+
+ let refresh: Promise | undefined
+ await act(async () => {
+ refresh = states.get('one')?.refresh()
+ await Promise.resolve()
+ })
+ expect(states.get('one')?.inventory).toBeNull()
+ expect(states.get('two')?.inventory).toBeNull()
+
+ await act(async () => second.resolve(inventory(2)))
+ await refresh
+ expect(states.get('one')?.inventory?.scannedAt).toBe(2)
+ expect(states.get('two')?.inventory?.scannedAt).toBe(2)
+ expect(freshnessInventory).toHaveBeenCalledTimes(2)
+ })
+
+ it('fails closed when an invalidation scan rejects', async () => {
+ const second = deferred()
+ const freshnessInventory = vi
+ .fn()
+ .mockResolvedValueOnce(inventory(1))
+ .mockReturnValueOnce(second.promise)
+ window.api = { skills: { freshnessInventory } } as never
+
+ await act(async () => root?.render())
+ expect(state?.inventory?.scannedAt).toBe(1)
+
+ await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed')))
+ expect(state?.inventory).toBeNull()
+ expect(state?.loading).toBe(true)
+
+ await act(async () => second.reject(new Error('scan failed')))
+ expect(state?.inventory).toBeNull()
+ expect(state?.loading).toBe(false)
+ expect(state?.error).toBe('scan failed')
+ })
+
+ it('installs one event-listener pair for multiple consumers and cleans it up', async () => {
+ const addEventListener = vi.spyOn(window, 'addEventListener')
+ const removeEventListener = vi.spyOn(window, 'removeEventListener')
+ window.api = {
+ skills: { freshnessInventory: vi.fn().mockResolvedValue(inventory(1)) }
+ } as never
+
+ await act(async () =>
+ root?.render(
+ <>
+
+
+ >
+ )
+ )
+
+ expect(addEventListener.mock.calls.filter(([name]) => name === 'focus')).toHaveLength(1)
+ expect(
+ addEventListener.mock.calls.filter(([name]) => name === 'orca:installed-agent-skills-changed')
+ ).toHaveLength(1)
+
+ await act(async () => root?.unmount())
+ root = null
+ expect(removeEventListener.mock.calls.filter(([name]) => name === 'focus')).toHaveLength(1)
+ expect(
+ removeEventListener.mock.calls.filter(
+ ([name]) => name === 'orca:installed-agent-skills-changed'
+ )
+ ).toHaveLength(1)
+ })
})
diff --git a/src/renderer/src/hooks/useSkillFreshness.ts b/src/renderer/src/hooks/useSkillFreshness.ts
index 382787bf258..ad2ad5c088f 100644
--- a/src/renderer/src/hooks/useSkillFreshness.ts
+++ b/src/renderer/src/hooks/useSkillFreshness.ts
@@ -1,7 +1,6 @@
-import { useCallback, useEffect, useState } from 'react'
+import { useEffect, useSyncExternalStore } from 'react'
import type { SkillFreshnessInventory } from '../../../shared/skill-freshness'
import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event'
-import { useMountedRef } from './useMountedRef'
// Why: window focus fires on every alt-tab, and each scan re-reads and re-hashes
// every installed package; a just-completed scan stays authoritative briefly.
@@ -11,7 +10,34 @@ let pendingInventory: Promise | null = null
let invalidationRevision = 0
let completedRevision = -1
let lastCompletedScanAt = 0
-let sharedInvalidationPending = false
+let refreshSequence = 0
+
+type SkillFreshnessSnapshot = {
+ inventory: SkillFreshnessInventory | null
+ loading: boolean
+ error: string | null
+}
+
+let snapshot: SkillFreshnessSnapshot = {
+ inventory: null,
+ loading: false,
+ error: null
+}
+const subscribers = new Set<() => void>()
+
+function publishSnapshot(next: SkillFreshnessSnapshot): void {
+ if (
+ snapshot.inventory === next.inventory &&
+ snapshot.loading === next.loading &&
+ snapshot.error === next.error
+ ) {
+ return
+ }
+ snapshot = next
+ for (const subscriber of subscribers) {
+ subscriber()
+ }
+}
async function loadInventory(force: boolean): Promise {
if (force) {
@@ -43,71 +69,77 @@ async function loadInventory(force: boolean): Promise {
}
}
-export type SkillFreshnessState = {
- inventory: SkillFreshnessInventory | null
- loading: boolean
- error: string | null
+async function refreshSkillFreshness(force = true): Promise {
+ const sequence = ++refreshSequence
+ // Why: eligibility is write authority for the draft command. Once invalidated,
+ // stale bytes must stop authorizing UI even if the replacement scan fails.
+ publishSnapshot({ inventory: null, loading: true, error: null })
+ try {
+ const inventory = await loadInventory(force)
+ if (sequence === refreshSequence) {
+ publishSnapshot({ inventory, loading: false, error: null })
+ }
+ } catch (cause) {
+ if (sequence === refreshSequence) {
+ publishSnapshot({
+ inventory: null,
+ loading: false,
+ error: cause instanceof Error ? cause.message : 'Could not inspect Orca skills.'
+ })
+ }
+ }
+}
+
+function onWindowFocus(): void {
+ if (Date.now() - lastCompletedScanAt < FOCUS_RESCAN_COOLDOWN_MS) {
+ return
+ }
+ void refreshSkillFreshness(true)
+}
+
+function onInstalledSkillsChanged(): void {
+ void refreshSkillFreshness(true)
+}
+
+function subscribe(subscriber: () => void): () => void {
+ subscribers.add(subscriber)
+ if (subscribers.size === 1) {
+ // Why: every consumer reads one external snapshot, so focus/install events
+ // install one listener and trigger one shared IPC scan regardless of UI count.
+ window.addEventListener('focus', onWindowFocus)
+ window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged)
+ }
+ return () => {
+ subscribers.delete(subscriber)
+ if (subscribers.size === 0) {
+ window.removeEventListener('focus', onWindowFocus)
+ window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged)
+ }
+ }
+}
+
+function getSnapshot(): SkillFreshnessSnapshot {
+ return snapshot
+}
+
+function ensureInventoryLoaded(): void {
+ if (!snapshot.inventory && !snapshot.loading) {
+ void refreshSkillFreshness(false)
+ }
+}
+
+export type SkillFreshnessState = SkillFreshnessSnapshot & {
refresh: () => Promise
}
export function useSkillFreshness(): SkillFreshnessState {
- const [inventory, setInventory] = useState(cachedInventory)
- const [loading, setLoading] = useState(cachedInventory === null)
- const [error, setError] = useState(null)
- const mountedRef = useMountedRef()
-
- const refresh = useCallback(
- async (force = true): Promise => {
- setLoading(true)
- try {
- const next = await loadInventory(force)
- if (mountedRef.current) {
- setInventory(next)
- setError(null)
- }
- } catch (cause) {
- if (mountedRef.current) {
- setError(cause instanceof Error ? cause.message : 'Could not inspect Orca skills.')
- }
- } finally {
- if (mountedRef.current) {
- setLoading(false)
- }
- }
- },
- [mountedRef]
- )
+ const current = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
useEffect(() => {
- void refresh(false)
- }, [refresh])
+ ensureInventoryLoaded()
+ }, [])
- useEffect(() => {
- const invalidate = (respectCooldown: boolean): void => {
- if (respectCooldown && Date.now() - lastCompletedScanAt < FOCUS_RESCAN_COOLDOWN_MS) {
- return
- }
- // Why: the nudge and the panel both listen for the same events; one forced
- // rescan per event refreshes every consumer through the shared request.
- const alreadyInvalidated = sharedInvalidationPending
- sharedInvalidationPending = true
- queueMicrotask(() => {
- sharedInvalidationPending = false
- })
- cachedInventory = null
- void refresh(!alreadyInvalidated)
- }
- const onFocus = (): void => invalidate(true)
- const onInstalledSkillsChanged = (): void => invalidate(false)
- window.addEventListener('focus', onFocus)
- window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged)
- return () => {
- window.removeEventListener('focus', onFocus)
- window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged)
- }
- }, [refresh])
-
- return { inventory, loading, error, refresh: () => refresh(true) }
+ return { ...current, refresh: refreshSkillFreshness }
}
export const _skillFreshnessCacheForTests = {
@@ -117,6 +149,7 @@ export const _skillFreshnessCacheForTests = {
invalidationRevision = 0
completedRevision = -1
lastCompletedScanAt = 0
- sharedInvalidationPending = false
+ refreshSequence = 0
+ snapshot = { inventory: null, loading: false, error: null }
}
}