From 7c524aca68d61c5e18a3a6af61a17593d6f926cc Mon Sep 17 00:00:00 2001 From: Jorge de los Santos Date: Tue, 14 Jul 2026 12:10:35 -0300 Subject: [PATCH 01/13] Detect outdated Orca agent skills and prompt per-skill updates App updates do not refresh globally installed agent skills, so agents can keep stale workflow docs after an Orca upgrade. Compare installed SKILL.md hashes against app-bundled references, surface one UpdateCard-style prompt per outdated skill, and show amber Outdated badges in Settings. --- config/electron-builder.config.cjs | 14 +- src/main/ipc/skills.test.ts | 48 ++++- src/main/ipc/skills.ts | 51 ++++-- src/main/runtime/rpc/methods/skills.ts | 11 ++ src/main/skills/bundled-skills-root.ts | 36 ++++ src/main/skills/freshness.test.ts | 97 ++++++++++ src/main/skills/freshness.ts | 164 +++++++++++++++++ src/preload/api-types.ts | 2 + src/preload/index.ts | 5 +- src/renderer/src/App.tsx | 9 + .../settings/AgentSkillSetupPanel.tsx | 17 +- .../src/components/settings/CliSection.tsx | 5 + .../settings/ComputerUseSkillSetupPanel.tsx | 5 + .../components/settings/EphemeralVmsPane.tsx | 5 + .../settings/OrchestrationSetupCard.tsx | 6 + .../src/components/settings/Settings.tsx | 35 +++- .../settings/SettingsSidebar.test.tsx | 7 + .../components/settings/SettingsSidebar.tsx | 10 +- .../skills/OutdatedSkillUpdateDialog.tsx | 128 +++++++++++++ .../skills/OutdatedSkillUpdateHost.tsx | 101 +++++++++++ .../skills/outdated-skill-reminder.test.ts | 52 ++++++ .../skills/outdated-skill-reminder.ts | 55 ++++++ .../src/hooks/useOrcaSkillFreshness.ts | 168 ++++++++++++++++++ .../src/lib/settings-navigation-types.ts | 2 +- src/renderer/src/web/web-preload-api.ts | 11 +- src/shared/orca-managed-skills.ts | 112 ++++++++++++ src/shared/skill-freshness.ts | 23 +++ 27 files changed, 1144 insertions(+), 35 deletions(-) create mode 100644 src/main/skills/bundled-skills-root.ts create mode 100644 src/main/skills/freshness.test.ts create mode 100644 src/main/skills/freshness.ts create mode 100644 src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx create mode 100644 src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx create mode 100644 src/renderer/src/components/skills/outdated-skill-reminder.test.ts create mode 100644 src/renderer/src/components/skills/outdated-skill-reminder.ts create mode 100644 src/renderer/src/hooks/useOrcaSkillFreshness.ts create mode 100644 src/shared/orca-managed-skills.ts create mode 100644 src/shared/skill-freshness.ts diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 0dd6a3d85c7..ecfc7456a06 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -18,6 +18,14 @@ const featureWallResources = { from: 'resources/onboarding/feature-wall', to: 'onboarding/feature-wall' } +// Why: packaged apps compare installed agent skills against these references +// after an Orca update. Ship SKILL.md only (not full skill packages) under +// process.resourcesPath/orca-skills so freshness checks work offline. +const orcaSkillsReferenceResources = { + from: 'skills', + to: 'orca-skills', + filter: ['**/SKILL.md'] +} // Why: SSH relay deploy resolves bundles from process.resourcesPath in packaged // apps. Keeping relay assets as extraResources makes them real directories // instead of paths hidden inside app.asar. @@ -31,7 +39,11 @@ const relayExtraResource = { // do not fall through to a developer checkout's node_modules. const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources() -const commonExtraResources = [relayExtraResource, ...packagedRuntimeNodeModuleResources] +const commonExtraResources = [ + relayExtraResource, + orcaSkillsReferenceResources, + ...packagedRuntimeNodeModuleResources +] const macSpeechNativeResource = { from: 'node_modules/sherpa-onnx-darwin-${arch}', to: 'node_modules/sherpa-onnx-darwin-${arch}' diff --git a/src/main/ipc/skills.test.ts b/src/main/ipc/skills.test.ts index 680163f8b5d..aa3d2f4ec88 100644 --- a/src/main/ipc/skills.test.ts +++ b/src/main/ipc/skills.test.ts @@ -1,13 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { handleMock, discoverSkillsMock, getDefaultWslDistroMock, getWslHomeMock } = vi.hoisted( - () => ({ - handleMock: vi.fn(), - discoverSkillsMock: vi.fn(), - getDefaultWslDistroMock: vi.fn(), - getWslHomeMock: vi.fn() - }) -) +const { + handleMock, + discoverSkillsMock, + checkFreshnessMock, + getDefaultWslDistroMock, + getWslHomeMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + discoverSkillsMock: vi.fn(), + checkFreshnessMock: vi.fn(), + getDefaultWslDistroMock: vi.fn(), + getWslHomeMock: vi.fn() +})) vi.mock('electron', () => ({ ipcMain: { @@ -19,6 +24,10 @@ vi.mock('../skills/discovery', () => ({ discoverSkills: discoverSkillsMock })) +vi.mock('../skills/freshness', () => ({ + checkOrcaSkillFreshness: checkFreshnessMock +})) + vi.mock('../wsl', () => ({ getDefaultWslDistro: getDefaultWslDistroMock, getWslHome: getWslHomeMock @@ -36,9 +45,15 @@ describe('registerSkillsHandlers', () => { beforeEach(() => { handleMock.mockReset() discoverSkillsMock.mockReset() + checkFreshnessMock.mockReset() getDefaultWslDistroMock.mockReset() getWslHomeMock.mockReset() discoverSkillsMock.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 }) + checkFreshnessMock.mockResolvedValue({ + skills: [], + scannedAt: 1, + referenceRoot: null + }) getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\alice') Object.defineProperty(process, 'platform', { configurable: true, @@ -61,6 +76,23 @@ describe('registerSkillsHandlers', () => { return call[1] as (_event: unknown, target?: unknown) => Promise } + function getFreshnessHandler() { + registerSkillsHandlers(store as never) + const call = handleMock.mock.calls.find( + (entry: unknown[]) => entry[0] === 'skills:checkFreshness' + ) + if (!call) { + throw new Error('skills:checkFreshness handler was not registered') + } + return call[1] as (_event: unknown, target?: unknown) => Promise + } + + it('registers a host freshness check against the shared scan context', async () => { + const handler = getFreshnessHandler() + await handler(null, undefined) + expect(checkFreshnessMock).toHaveBeenCalledWith({ repos }) + }) + it('uses host skill discovery when resolved project runtime overrides stale WSL target state', async () => { const handler = getDiscoverHandler() diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index b81fd695790..31471bac7db 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -1,7 +1,9 @@ import { ipcMain } from 'electron' import type { Store } from '../persistence' import { discoverSkills } from '../skills/discovery' +import { checkOrcaSkillFreshness } from '../skills/freshness' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills' +import type { SkillFreshnessResult } from '../../shared/skill-freshness' import { getDefaultWslDistro, getWslHome } from '../wsl' type SkillDiscoveryRuntimeTarget = @@ -32,27 +34,42 @@ function getSkillDiscoveryRuntimeTarget( } export function registerSkillsHandlers(store: Store): void { + const resolveScanContext = async ( + target?: SkillDiscoveryTarget + ): Promise<{ homeDir?: string; cwd?: string; repos: ReturnType }> => { + const runtimeTarget = getSkillDiscoveryRuntimeTarget(target) + if (runtimeTarget.runtime === 'wsl') { + if (process.platform !== 'win32') { + throw new Error('WSL skill discovery is only available on Windows.') + } + const distro = runtimeTarget.wslDistro?.trim() || getDefaultWslDistro() + if (!distro) { + throw new Error('No WSL distribution is available for skill discovery.') + } + const homeDir = getWslHome(distro) + if (!homeDir) { + throw new Error(`Could not resolve the WSL home directory for ${distro}.`) + } + return { homeDir, cwd: homeDir, repos: [] } + } + + const cwd = target?.cwd?.trim() || undefined + return cwd ? { cwd, repos: [] } : { repos: store.getRepos() } + } + ipcMain.handle( 'skills:discover', async (_event, target?: SkillDiscoveryTarget): Promise => { - const runtimeTarget = getSkillDiscoveryRuntimeTarget(target) - if (runtimeTarget.runtime === 'wsl') { - if (process.platform !== 'win32') { - throw new Error('WSL skill discovery is only available on Windows.') - } - const distro = runtimeTarget.wslDistro?.trim() || getDefaultWslDistro() - if (!distro) { - throw new Error('No WSL distribution is available for skill discovery.') - } - const homeDir = getWslHome(distro) - if (!homeDir) { - throw new Error(`Could not resolve the WSL home directory for ${distro}.`) - } - return discoverSkills({ repos: [], homeDir, cwd: homeDir }) - } + const context = await resolveScanContext(target) + return discoverSkills(context) + } + ) - const cwd = target?.cwd?.trim() || undefined - return cwd ? discoverSkills({ repos: [], cwd }) : discoverSkills({ repos: store.getRepos() }) + ipcMain.handle( + 'skills:checkFreshness', + async (_event, target?: SkillDiscoveryTarget): Promise => { + const context = await resolveScanContext(target) + return checkOrcaSkillFreshness(context) } ) } diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 8719cc528ab..72bba1f1bfb 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { discoverSkills } from '../../../skills/discovery' +import { checkOrcaSkillFreshness } from '../../../skills/freshness' const SkillDiscoveryParams = z.object({ cwd: z.string().optional().nullable() @@ -16,5 +17,15 @@ export const SKILL_METHODS: RpcMethod[] = [ ? discoverSkills({ repos: [], cwd }) : discoverSkills({ repos: runtime.listRepos() }) } + }), + defineMethod({ + name: 'skills.checkFreshness', + params: SkillDiscoveryParams, + handler: async (params, { runtime }) => { + const cwd = params.cwd?.trim() || undefined + return cwd + ? checkOrcaSkillFreshness({ repos: [], cwd }) + : checkOrcaSkillFreshness({ repos: runtime.listRepos() }) + } }) ] diff --git a/src/main/skills/bundled-skills-root.ts b/src/main/skills/bundled-skills-root.ts new file mode 100644 index 00000000000..5899841fe60 --- /dev/null +++ b/src/main/skills/bundled-skills-root.ts @@ -0,0 +1,36 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { app } from 'electron' + +/** + * Resolve the on-disk root of Orca-shipped skill references used for + * freshness checks. Packaged apps read `process.resourcesPath/orca-skills`; + * dev/unpackaged builds fall back to the repo `skills/` directory. + */ +export function resolveBundledSkillsRoot(options?: { + isPackaged?: boolean + resourcesPath?: string + appPath?: string +}): string | null { + const isPackaged = options?.isPackaged ?? app.isPackaged + const resourcesPath = options?.resourcesPath ?? process.resourcesPath + const appPath = options?.appPath ?? app.getAppPath() + + if (isPackaged) { + const packagedRoot = join(resourcesPath, 'orca-skills') + return existsSync(packagedRoot) ? packagedRoot : null + } + + // electron-vite may set getAppPath() to the project root or out/. + const candidates = [ + join(appPath, 'skills'), + join(appPath, '..', 'skills'), + join(process.cwd(), 'skills') + ] + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate + } + } + return null +} diff --git a/src/main/skills/freshness.test.ts b/src/main/skills/freshness.test.ts new file mode 100644 index 00000000000..60633aaecd6 --- /dev/null +++ b/src/main/skills/freshness.test.ts @@ -0,0 +1,97 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + checkOrcaSkillFreshness, + hashSkillMarkdown, + normalizeSkillMarkdownForHash +} from './freshness' + +const tempDirs: string[] = [] + +afterEach(async () => { + // Temp dirs are disposable process artifacts; leave them for OS cleanup. + tempDirs.length = 0 +}) + +async function makeTempDir(prefix: string): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)) + tempDirs.push(dir) + return dir +} + +async function writeSkill(root: string, skillName: string, body: string): Promise { + const skillDir = join(root, skillName) + await mkdir(skillDir, { recursive: true }) + const skillFilePath = join(skillDir, 'SKILL.md') + await writeFile(skillFilePath, body, 'utf8') + return skillFilePath +} + +describe('skill freshness hashing', () => { + it('normalizes CRLF so Windows and Unix installs compare equal', () => { + expect(hashSkillMarkdown('a\r\nb\n')).toBe(hashSkillMarkdown('a\nb\n')) + expect(normalizeSkillMarkdownForHash('x\r\ny')).toBe('x\ny') + }) +}) + +describe('checkOrcaSkillFreshness', () => { + it('marks an installed skill outdated when content diverges from the reference', async () => { + const referenceRoot = await makeTempDir('orca-skill-ref-') + const homeDir = await makeTempDir('orca-skill-home-') + await writeSkill(referenceRoot, 'orca-cli', '---\nname: orca-cli\n---\nexpected\n') + await mkdir(join(homeDir, '.agents', 'skills'), { recursive: true }) + await writeSkill( + join(homeDir, '.agents', 'skills'), + 'orca-cli', + '---\nname: orca-cli\n---\nstale\n' + ) + + const result = await checkOrcaSkillFreshness({ + repos: [], + homeDir, + referenceRoot + }) + + const orcaCli = result.skills.find((skill) => skill.skillName === 'orca-cli') + expect(orcaCli?.status).toBe('outdated') + expect(orcaCli?.expectedHash).toBeTruthy() + expect(orcaCli?.installedHash).toBeTruthy() + expect(orcaCli?.expectedHash).not.toBe(orcaCli?.installedHash) + }) + + it('marks an installed skill current when content matches the reference', async () => { + const referenceRoot = await makeTempDir('orca-skill-ref-') + const homeDir = await makeTempDir('orca-skill-home-') + const content = '---\nname: orchestration\n---\ncurrent body\n' + await writeSkill(referenceRoot, 'orchestration', content) + await mkdir(join(homeDir, '.agents', 'skills'), { recursive: true }) + await writeSkill(join(homeDir, '.agents', 'skills'), 'orchestration', content) + + const result = await checkOrcaSkillFreshness({ + repos: [], + homeDir, + referenceRoot + }) + + const orchestration = result.skills.find((skill) => skill.skillName === 'orchestration') + expect(orchestration?.status).toBe('current') + }) + + it('marks a managed skill missing when it is not installed', async () => { + const referenceRoot = await makeTempDir('orca-skill-ref-') + const homeDir = await makeTempDir('orca-skill-home-') + await writeSkill(referenceRoot, 'computer-use', '---\nname: computer-use\n---\nbody\n') + await mkdir(join(homeDir, '.agents', 'skills'), { recursive: true }) + + const result = await checkOrcaSkillFreshness({ + repos: [], + homeDir, + referenceRoot + }) + + const computerUse = result.skills.find((skill) => skill.skillName === 'computer-use') + expect(computerUse?.status).toBe('missing') + }) +}) diff --git a/src/main/skills/freshness.ts b/src/main/skills/freshness.ts new file mode 100644 index 00000000000..b8375fe1e0f --- /dev/null +++ b/src/main/skills/freshness.ts @@ -0,0 +1,164 @@ +import { createHash } from 'node:crypto' +import { open, readdir, stat } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import { ORCA_MANAGED_SKILLS } from '../../shared/orca-managed-skills' +import type { + SkillFreshnessEntry, + SkillFreshnessResult, + SkillFreshnessStatus +} from '../../shared/skill-freshness' +import { discoverSkills } from './discovery' +import { resolveBundledSkillsRoot } from './bundled-skills-root' +import type { DiscoveredSkill } from '../../shared/skills' +import type { Repo } from '../../shared/types' + +const SKILL_FILE_NAME = 'SKILL.md' +const MAX_SKILL_MARKDOWN_BYTES = 256 * 1024 + +function normalizeSkillName(value: string): string { + return value.trim().toLowerCase() +} + +function basenameFromPath(pathValue: string): string { + return pathValue.split(/[\\/]/).filter(Boolean).at(-1) ?? pathValue +} + +/** Normalize line endings so macOS/Windows installs hash the same content. */ +export function normalizeSkillMarkdownForHash(content: string): string { + return content.replace(/\r\n/g, '\n') +} + +export function hashSkillMarkdown(content: string): string { + return createHash('sha256').update(normalizeSkillMarkdownForHash(content), 'utf8').digest('hex') +} + +async function readSkillMarkdown(skillFilePath: string): Promise { + try { + const fileStat = await stat(skillFilePath) + const file = await open(skillFilePath, 'r') + try { + const buffer = Buffer.alloc(Math.min(fileStat.size, MAX_SKILL_MARKDOWN_BYTES)) + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0) + return buffer.toString('utf8', 0, bytesRead) + } finally { + await file.close() + } + } catch { + return null + } +} + +async function hashSkillFile(skillFilePath: string): Promise { + const content = await readSkillMarkdown(skillFilePath) + return content === null ? null : hashSkillMarkdown(content) +} + +function skillMatchesManagedName(skill: DiscoveredSkill, skillName: string): boolean { + const expected = normalizeSkillName(skillName) + return ( + normalizeSkillName(skill.name) === expected || + normalizeSkillName(basenameFromPath(skill.directoryPath)) === expected + ) +} + +function pickInstalledGlobalSkill( + skills: readonly DiscoveredSkill[], + skillName: string +): DiscoveredSkill | null { + // Prefer home installs so repo/plugin copies do not mask the global package. + // Match only this skill's package name — aliases are install-time synonyms + // (e.g. Linear), not interchangeable content for hash comparison. + const homeMatch = skills.find( + (skill) => + skill.installed && skill.sourceKind === 'home' && skillMatchesManagedName(skill, skillName) + ) + if (homeMatch) { + return homeMatch + } + return ( + skills.find((skill) => skill.installed && skillMatchesManagedName(skill, skillName)) ?? null + ) +} + +async function loadExpectedHashes(referenceRoot: string): Promise> { + const hashes = new Map() + let entries: string[] = [] + try { + entries = await readdir(referenceRoot) + } catch { + return hashes + } + + await Promise.all( + entries.map(async (entryName) => { + const skillFilePath = join(referenceRoot, entryName, SKILL_FILE_NAME) + const hash = await hashSkillFile(skillFilePath) + if (hash) { + hashes.set(normalizeSkillName(entryName), hash) + } + }) + ) + return hashes +} + +function resolveStatus(args: { + expectedHash: string | null + installedHash: string | null +}): SkillFreshnessStatus { + if (!args.expectedHash) { + return 'unknown' + } + if (!args.installedHash) { + return 'missing' + } + return args.expectedHash === args.installedHash ? 'current' : 'outdated' +} + +export async function checkOrcaSkillFreshness(args?: { + repos?: Repo[] + homeDir?: string + cwd?: string + referenceRoot?: string | null +}): Promise { + const referenceRoot = + args?.referenceRoot === undefined ? resolveBundledSkillsRoot() : args.referenceRoot + const expectedHashes = referenceRoot ? await loadExpectedHashes(referenceRoot) : new Map() + + const discovery = await discoverSkills({ + repos: args?.repos ?? [], + homeDir: args?.homeDir, + cwd: args?.cwd + }) + + const skills: SkillFreshnessEntry[] = [] + for (const definition of ORCA_MANAGED_SKILLS) { + const expectedHash = expectedHashes.get(normalizeSkillName(definition.skillName)) ?? null + const installed = pickInstalledGlobalSkill(discovery.skills, definition.skillName) + const installedHash = installed ? await hashSkillFile(installed.skillFilePath) : null + skills.push({ + skillName: definition.skillName, + displayName: definition.displayName, + settingsSectionId: definition.settingsSectionId, + updateCommand: definition.updateCommand, + status: resolveStatus({ expectedHash, installedHash }), + expectedHash, + installedHash, + installedPath: installed?.skillFilePath ?? null + }) + } + + return { + skills, + scannedAt: Date.now(), + referenceRoot + } +} + +/** Test helper: hash a path under a reference skills root. */ +export function referenceSkillFilePath(referenceRoot: string, skillName: string): string { + return join(referenceRoot, basename(skillName), SKILL_FILE_NAME) +} + +export function referenceSkillDirectory(referenceRoot: string, skillName: string): string { + return dirname(referenceSkillFilePath(referenceRoot, skillName)) +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 80e284bf31f..55feac55348 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -315,6 +315,7 @@ import type { ResolvedSourceControlAiGenerationParams } from '../shared/source-c import type { SourceControlAiSettings } from '../shared/source-control-ai-types' import type { ShellOpenLocalPathResult } from '../shared/shell-open-types' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills' +import type { SkillFreshnessResult } from '../shared/skill-freshness' import type { CrashReportBreadcrumbData, CrashReportCopyDiagnosticsArgs, @@ -2218,6 +2219,7 @@ export type PreloadApi = { } skills: { discover: (target?: SkillDiscoveryTarget) => Promise + checkFreshness: (target?: SkillDiscoveryTarget) => Promise } pet: { import: () => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index bfea3e27a5d..469818f8f79 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -70,6 +70,7 @@ import type { import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { ShellOpenLocalPathResult } from '../shared/shell-open-types' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../shared/skills' +import type { SkillFreshnessResult } from '../shared/skill-freshness' import type { RuntimeBrowserDriverState, RuntimeMobileSessionTabMove, @@ -2191,7 +2192,9 @@ const api = { skills: { discover: (target?: SkillDiscoveryTarget): Promise => - ipcRenderer.invoke('skills:discover', target) + ipcRenderer.invoke('skills:discover', target), + checkFreshness: (target?: SkillDiscoveryTarget): Promise => + ipcRenderer.invoke('skills:checkFreshness', target) }, pet: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 014e3950e42..3c04829db4f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -59,6 +59,7 @@ import RightSidebar from './components/right-sidebar' import { StarNagCard } from './components/StarNagCard' import { StarNagAgentValueMomentObserver } from './components/star-nag/StarNagAgentValueMomentObserver' import { StarNagToastHost } from './components/star-nag/StarNagToastHost' +import { OutdatedSkillUpdateHost } from './components/skills/OutdatedSkillUpdateHost' import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface' import { ZoomOverlay } from './components/ZoomOverlay' import { onOnboardingReopened } from './components/onboarding/show-onboarding-event' @@ -2708,6 +2709,14 @@ function App(): React.JSX.Element { > + + + {/* Why: the existing-user opt-in banner mounts at App root so it renders once per renderer session, not per view. It gates diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx index d7f4da5b619..e6b3462cdf8 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx @@ -23,6 +23,8 @@ type AgentSkillSetupPanelProps = { terminalAriaLabel: string terminalWorktreeId: string installed: boolean + /** When installed but behind the app-bundled skill reference. */ + outdated?: boolean loading: boolean error: string | null installDisabled?: boolean @@ -58,6 +60,7 @@ export function AgentSkillSetupPanel({ terminalAriaLabel, terminalWorktreeId, installed, + outdated = false, loading, error, installDisabled = false, @@ -201,7 +204,12 @@ export function AgentSkillSetupPanel({ {terminalOpening ? translate('auto.components.settings.AgentSkillSetupPanel.5f818f12ab', 'Preparing...') : installed - ? installedInstallLabel + ? outdated + ? translate( + 'auto.components.settings.AgentSkillSetupPanel.updateOutdated', + 'Update' + ) + : installedInstallLabel : installLabel} ) : null} @@ -263,6 +271,13 @@ export function AgentSkillSetupPanel({ 'Checking...' )} + ) : installed && outdated ? ( + + {translate( + 'auto.components.settings.AgentSkillSetupPanel.outdated', + 'Outdated' + )} + ) : installed ? ( {translate( diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index 20380332009..5f30c85d049 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -17,6 +17,7 @@ import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, useInstalledAgentSkill } from '@/hooks/useInstalledAgentSkills' +import { useOrcaSkillFreshness } from '@/hooks/useOrcaSkillFreshness' import { useMountedRef } from '@/hooks/useMountedRef' import { Button } from '../ui/button' import { Label } from '../ui/label' @@ -99,6 +100,9 @@ export function CliSection({ discoveryTarget: cliSkillDiscoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) + const { isSkillOutdated } = useOrcaSkillFreshness({ + discoveryTarget: cliSkillDiscoveryTarget + }) const cliSkillInstallCommand = buildSkillCommandForRuntime( ORCA_CLI_SKILL_INSTALL_COMMAND, agentRuntime @@ -377,6 +381,7 @@ export function CliSection({ terminalWorktreeId={`settings-cli-skill-terminal-${agentRuntime.runtime}`} terminalShellOverride={cliSkillTerminalShellOverride} installed={cliSkillDetected} + outdated={isSkillOutdated(ORCA_CLI_SKILL_NAME)} loading={cliSkillLoading} error={cliSkillError} preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE} diff --git a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx index 270e1785aef..3fdd32ca9cf 100644 --- a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx @@ -12,6 +12,7 @@ import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, useInstalledAgentSkill } from '@/hooks/useInstalledAgentSkills' +import { useOrcaSkillFreshness } from '@/hooks/useOrcaSkillFreshness' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { useAppStore } from '@/store' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' @@ -45,6 +46,9 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element { discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) + const { isSkillOutdated } = useOrcaSkillFreshness({ + discoveryTarget: activeSkillRuntime.discoveryTarget + }) return ( => { if (mountedRef.current) { @@ -145,6 +149,7 @@ export function EphemeralVmsPane(): React.JSX.Element { terminalWorktreeId="settings-ephemeral-vms-skill-terminal" terminalShellOverride={activeSkillRuntime.terminalShellOverride} installed={skillDetected} + outdated={isSkillOutdated(EPHEMERAL_VMS_SKILL_NAME)} loading={skillLoading} error={activeSkillRuntime.installDisabledReason ?? skillError} installDisabled={Boolean(activeSkillRuntime.installDisabledReason)} diff --git a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx index b757261bba6..cf08dd2e67f 100644 --- a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx +++ b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx @@ -8,7 +8,9 @@ import { ORCHESTRATION_SKILL_UPDATE_COMMAND } from '@/lib/orchestration-install-command' import type { InstalledAgentSkillState } from '@/hooks/useInstalledAgentSkills' +import { useOrcaSkillFreshness } from '@/hooks/useOrcaSkillFreshness' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' +import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' import { buildSkillCommandForRuntime, @@ -25,6 +27,9 @@ export function OrchestrationSetupCard(props: { }): JSX.Element { const { compact, terminalHeightPx, skill } = props const activeSkillRuntime = useActiveProjectSkillRuntime() + const { isSkillOutdated } = useOrcaSkillFreshness({ + discoveryTarget: activeSkillRuntime.discoveryTarget + }) const installCommand = !activeSkillRuntime.installDisabledReason ? buildSkillCommandForRuntime( ORCHESTRATION_SKILL_INSTALL_COMMAND, @@ -56,6 +61,7 @@ export function OrchestrationSetupCard(props: { terminalWorktreeId="feature-wall-orchestration-skill-terminal" terminalShellOverride={activeSkillRuntime.terminalShellOverride} installed={skill.installed} + outdated={isSkillOutdated(ORCHESTRATION_SKILL_NAME)} loading={skill.loading} error={activeSkillRuntime.installDisabledReason ?? skill.error} installDisabled={Boolean(activeSkillRuntime.installDisabledReason)} diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 2d5a941226c..8e46b4dd047 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -95,6 +95,7 @@ import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, useInstalledAgentSkill } from '@/hooks/useInstalledAgentSkills' +import { useOrcaSkillFreshness } from '@/hooks/useOrcaSkillFreshness' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { deriveNeededRepoIds, @@ -192,10 +193,14 @@ function getSettingsNavGroupDefinitionsForSearch( function getSkillNavInstallStatus(skill: { installed: boolean loading: boolean + outdated?: boolean }): SettingsNavInstallStatus { if (skill.loading) { return 'checking' } + if (skill.installed && skill.outdated) { + return 'outdated' + } return skill.installed ? 'installed' : 'install' } @@ -307,6 +312,9 @@ function Settings(): React.JSX.Element { discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) + const skillFreshness = useOrcaSkillFreshness({ + discoveryTarget: activeSkillRuntime.discoveryTarget + }) const [voiceModelStatesLoading, setVoiceModelStatesLoading] = useState(showDesktopOnlySettings) // Why: the Terminal settings section shares one search index with the // sidebar. We trim platform-only entries on other platforms so search never @@ -667,22 +675,43 @@ function Settings(): React.JSX.Element { orchestrationSkill const { installed: computerUseSkillInstalled, loading: computerUseSkillLoading } = computerUseSkill + const outdatedSectionIds = useMemo(() => { + const sectionIds = new Set() + for (const skill of skillFreshness.outdatedSkills) { + sectionIds.add(skill.settingsSectionId) + } + return sectionIds + }, [skillFreshness.outdatedSkills]) const capabilityInstallStatusBySectionId = useMemo(() => { const next = new Map([ [ 'orchestration', getSkillNavInstallStatus({ installed: orchestrationSkillInstalled, - loading: orchestrationSkillLoading + loading: orchestrationSkillLoading || skillFreshness.loading, + outdated: outdatedSectionIds.has('orchestration') }) ] ]) + if (outdatedSectionIds.has('general')) { + next.set('general', 'outdated') + } + if (outdatedSectionIds.has('integrations')) { + next.set('integrations', 'outdated') + } + if (outdatedSectionIds.has('experimental')) { + next.set('experimental', 'outdated') + } + if (outdatedSectionIds.has('mobile-emulator') && showDesktopOnlySettings) { + next.set('mobile-emulator', 'outdated') + } if (showDesktopOnlySettings) { next.set( 'computer-use', getSkillNavInstallStatus({ installed: computerUseSkillInstalled, - loading: computerUseSkillLoading + loading: computerUseSkillLoading || skillFreshness.loading, + outdated: outdatedSectionIds.has('computer-use') }) ) if (settings) { @@ -703,8 +732,10 @@ function Settings(): React.JSX.Element { modelStates, orchestrationSkillInstalled, orchestrationSkillLoading, + outdatedSectionIds, settings, showDesktopOnlySettings, + skillFreshness.loading, voiceModelStatesLoading ]) const navSections = useMemo( diff --git a/src/renderer/src/components/settings/SettingsSidebar.test.tsx b/src/renderer/src/components/settings/SettingsSidebar.test.tsx index 11eb49163a0..a89dfe3ee59 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.test.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.test.tsx @@ -62,6 +62,12 @@ function renderSidebar( title: 'Voice', icon: Mic, installStatus: 'installed' + }, + { + id: 'computer-use', + title: 'Computer Use', + icon: Network, + installStatus: 'outdated' } ] }, @@ -114,6 +120,7 @@ describe('SettingsSidebar', () => { expect(markup).toContain('Not installed') expect(markup).toContain('Installed') + expect(markup).toContain('Outdated') expect(markup).toContain('Optional') }) diff --git a/src/renderer/src/components/settings/SettingsSidebar.tsx b/src/renderer/src/components/settings/SettingsSidebar.tsx index 7bbcea65371..ab4081df16f 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.tsx @@ -157,6 +157,8 @@ export function SettingsSidebar({ return translate('auto.components.settings.AgentSkillSetupPanel.9fcebceb2a', 'Installed') case 'checking': return translate('auto.components.settings.AgentSkillSetupPanel.68a468752e', 'Checking...') + case 'outdated': + return translate('auto.components.settings.SettingsSidebar.skillOutdated', 'Outdated') } } const installStatusClassName = (status: SettingsNavInstallStatus): string => @@ -164,9 +166,11 @@ export function SettingsSidebar({ 'ml-auto shrink-0 rounded-full border px-1.5 py-0.5 text-[10px] font-medium leading-none', status === 'installed' ? 'border-status-success-border bg-status-success-background text-status-success' - : status === 'install' - ? 'border-foreground/15 bg-foreground/10 text-foreground' - : 'border-border/50 bg-muted/30 text-muted-foreground' + : status === 'outdated' + ? 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300' + : status === 'install' + ? 'border-foreground/15 bg-foreground/10 text-foreground' + : 'border-border/50 bg-muted/30 text-muted-foreground' ) return ( diff --git a/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx b/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx new file mode 100644 index 00000000000..97bd3e67c56 --- /dev/null +++ b/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx @@ -0,0 +1,128 @@ +import { useEffect } from 'react' +import { X } from 'lucide-react' +import { toast } from 'sonner' +import type { SkillFreshnessEntry } from '../../../../shared/skill-freshness' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' + +/** + * Compact bottom-right skill update prompt — same shell and option pattern as + * UpdateCard's SimpleCardContent: title + dismiss, short body, text link, + * full-width primary Update. + */ +export function OutdatedSkillUpdateDialog(props: { + skill: SkillFreshnessEntry + onDismiss: () => void + onUpdate: () => void +}): React.JSX.Element { + const { skill, onDismiss, onUpdate } = props + const updateStatus = useAppStore((s) => s.updateStatus) + // Why: UpdateCard owns bottom-10; raise this card so both remain readable. + const updateCardVisible = updateStatus.state !== 'idle' && updateStatus.state !== 'not-available' + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + onDismiss() + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [onDismiss]) + + const copyCommand = async (): Promise => { + try { + await window.api.ui.writeClipboardText(skill.updateCommand) + toast.success( + translate( + 'auto.components.skills.OutdatedSkillUpdateDialog.copied', + 'Copied update command.' + ) + ) + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : translate( + 'auto.components.skills.OutdatedSkillUpdateDialog.copyFailed', + 'Failed to copy update command.' + ) + ) + } + } + + return ( +
+ +
+
+

+ {translate( + 'auto.components.skills.OutdatedSkillUpdateDialog.title', + 'Your Orca skills are outdated' + )} +

+ +
+ +

+ {translate( + 'auto.components.skills.OutdatedSkillUpdateDialog.readyLine', + 'The {{skillName}} skill needs an update.', + { skillName: skill.displayName } + )} +

+ +

+ {translate( + 'auto.components.skills.OutdatedSkillUpdateDialog.hint', + 'Update them so agents keep the latest Orca workflows.' + )} +

+ + + + +
+
+
+ ) +} diff --git a/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx b/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx new file mode 100644 index 00000000000..630f1166a53 --- /dev/null +++ b/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx @@ -0,0 +1,101 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { SkillFreshnessEntry } from '../../../../shared/skill-freshness' +import type { SettingsNavTarget } from '@/lib/settings-navigation-types' +import { useOrcaSkillFreshness } from '@/hooks/useOrcaSkillFreshness' +import { useAppStore } from '@/store' +import { + dismissOutdatedSkillForHash, + shouldPromptOutdatedSkill, + snoozeOutdatedSkillForSession +} from './outdated-skill-reminder' +import { OutdatedSkillUpdateDialog } from './OutdatedSkillUpdateDialog' + +/** + * Queues one outdated-skill card at a time after app open. Skills that the + * user snoozed this session or dismissed for the current expected hash are + * skipped so only remaining outdated packages surface. + */ +export function OutdatedSkillUpdateHost(): React.JSX.Element | null { + const { outdatedSkills, loading } = useOrcaSkillFreshness() + const openSettingsPage = useAppStore((s) => s.openSettingsPage) + const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) + const [activeSkillName, setActiveSkillName] = useState(null) + const [suppressedNames, setSuppressedNames] = useState([]) + + const promptQueue = useMemo(() => { + const suppressed = new Set(suppressedNames.map((name) => name.toLowerCase())) + return outdatedSkills.filter( + (skill) => shouldPromptOutdatedSkill(skill) && !suppressed.has(skill.skillName.toLowerCase()) + ) + }, [outdatedSkills, suppressedNames]) + + const activeSkill: SkillFreshnessEntry | null = useMemo(() => { + if (activeSkillName) { + return ( + promptQueue.find((skill) => skill.skillName === activeSkillName) ?? promptQueue[0] ?? null + ) + } + return promptQueue[0] ?? null + }, [activeSkillName, promptQueue]) + + useEffect(() => { + if (loading || promptQueue.length === 0) { + if (!loading && promptQueue.length === 0) { + setActiveSkillName(null) + } + return + } + setActiveSkillName((current) => { + if (current && promptQueue.some((skill) => skill.skillName === current)) { + return current + } + return promptQueue[0]?.skillName ?? null + }) + }, [loading, promptQueue]) + + const suppressCurrent = useCallback((skillName: string) => { + setSuppressedNames((prev) => + prev.includes(skillName.toLowerCase()) ? prev : [...prev, skillName.toLowerCase()] + ) + setActiveSkillName(null) + }, []) + + const handleDismiss = useCallback(() => { + if (!activeSkill) { + return + } + // Why: X matches UpdateCard dismiss — hide this expected hash until the + // next Orca release changes the reference skill content. + if (activeSkill.expectedHash) { + dismissOutdatedSkillForHash(activeSkill.skillName, activeSkill.expectedHash) + } else { + snoozeOutdatedSkillForSession(activeSkill.skillName) + } + suppressCurrent(activeSkill.skillName) + }, [activeSkill, suppressCurrent]) + + const handleUpdate = useCallback(() => { + if (!activeSkill) { + return + } + openSettingsTarget({ + pane: activeSkill.settingsSectionId as SettingsNavTarget, + repoId: null + }) + openSettingsPage() + snoozeOutdatedSkillForSession(activeSkill.skillName) + suppressCurrent(activeSkill.skillName) + }, [activeSkill, openSettingsPage, openSettingsTarget, suppressCurrent]) + + if (!activeSkill) { + return null + } + + return ( + + ) +} diff --git a/src/renderer/src/components/skills/outdated-skill-reminder.test.ts b/src/renderer/src/components/skills/outdated-skill-reminder.test.ts new file mode 100644 index 00000000000..6221b488e1d --- /dev/null +++ b/src/renderer/src/components/skills/outdated-skill-reminder.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + _outdatedSkillReminderInternalsForTests, + dismissOutdatedSkillForHash, + isOutdatedSkillDismissedForHash, + isOutdatedSkillSnoozedForSession, + shouldPromptOutdatedSkill, + snoozeOutdatedSkillForSession +} from './outdated-skill-reminder' + +describe('outdated skill reminder', () => { + const memory = new Map() + + beforeEach(() => { + memory.clear() + vi.stubGlobal('localStorage', { + getItem: (key: string) => memory.get(key) ?? null, + setItem: (key: string, value: string) => { + memory.set(key, value) + }, + removeItem: (key: string) => { + memory.delete(key) + }, + clear: () => { + memory.clear() + } + }) + }) + + afterEach(() => { + _outdatedSkillReminderInternalsForTests.reset() + vi.unstubAllGlobals() + }) + + it('prompts by default when a hash is present', () => { + expect(shouldPromptOutdatedSkill({ skillName: 'orca-cli', expectedHash: 'abc' })).toBe(true) + }) + + it('stops prompting after session snooze', () => { + snoozeOutdatedSkillForSession('orca-cli') + expect(isOutdatedSkillSnoozedForSession('orca-cli')).toBe(true) + expect(shouldPromptOutdatedSkill({ skillName: 'orca-cli', expectedHash: 'abc' })).toBe(false) + }) + + it('stops prompting after dismissing the expected hash', () => { + dismissOutdatedSkillForHash('orca-cli', 'abc') + expect(isOutdatedSkillDismissedForHash('orca-cli', 'abc')).toBe(true) + expect(shouldPromptOutdatedSkill({ skillName: 'orca-cli', expectedHash: 'abc' })).toBe(false) + // Why: a new Orca release changes the reference hash and should re-prompt. + expect(shouldPromptOutdatedSkill({ skillName: 'orca-cli', expectedHash: 'def' })).toBe(true) + }) +}) diff --git a/src/renderer/src/components/skills/outdated-skill-reminder.ts b/src/renderer/src/components/skills/outdated-skill-reminder.ts new file mode 100644 index 00000000000..ea5ffc77a4c --- /dev/null +++ b/src/renderer/src/components/skills/outdated-skill-reminder.ts @@ -0,0 +1,55 @@ +const DISMISS_STORAGE_PREFIX = 'orca:outdated-skill-dismissed:' +const sessionSnoozedSkillNames = new Set() + +function dismissStorageKey(skillName: string, expectedHash: string): string { + return `${DISMISS_STORAGE_PREFIX}${skillName.trim().toLowerCase()}:${expectedHash}` +} + +export function isOutdatedSkillDismissedForHash( + skillName: string, + expectedHash: string | null +): boolean { + if (!expectedHash || typeof localStorage === 'undefined') { + return false + } + try { + return localStorage.getItem(dismissStorageKey(skillName, expectedHash)) === '1' + } catch { + return false + } +} + +export function dismissOutdatedSkillForHash(skillName: string, expectedHash: string): void { + if (typeof localStorage === 'undefined') { + return + } + try { + localStorage.setItem(dismissStorageKey(skillName, expectedHash), '1') + } catch { + // Private mode / quota — treat as session-only via snooze caller. + } +} + +export function snoozeOutdatedSkillForSession(skillName: string): void { + sessionSnoozedSkillNames.add(skillName.trim().toLowerCase()) +} + +export function isOutdatedSkillSnoozedForSession(skillName: string): boolean { + return sessionSnoozedSkillNames.has(skillName.trim().toLowerCase()) +} + +export function shouldPromptOutdatedSkill(entry: { + skillName: string + expectedHash: string | null +}): boolean { + if (isOutdatedSkillSnoozedForSession(entry.skillName)) { + return false + } + return !isOutdatedSkillDismissedForHash(entry.skillName, entry.expectedHash) +} + +export const _outdatedSkillReminderInternalsForTests = { + reset(): void { + sessionSnoozedSkillNames.clear() + } +} diff --git a/src/renderer/src/hooks/useOrcaSkillFreshness.ts b/src/renderer/src/hooks/useOrcaSkillFreshness.ts new file mode 100644 index 00000000000..6d89164faf1 --- /dev/null +++ b/src/renderer/src/hooks/useOrcaSkillFreshness.ts @@ -0,0 +1,168 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { SkillDiscoveryTarget } from '../../../shared/skills' +import type { SkillFreshnessEntry, SkillFreshnessResult } from '../../../shared/skill-freshness' +import { useMountedRef } from './useMountedRef' + +const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed' +const FRESHNESS_CHANGED_EVENT = 'orca:skill-freshness-changed' + +let cachedFreshness: SkillFreshnessResult | null = null +let pendingFreshness: Promise | null = null + +export type OrcaSkillFreshnessState = { + loading: boolean + error: string | null + skills: readonly SkillFreshnessEntry[] + outdatedSkills: readonly SkillFreshnessEntry[] + refresh: () => Promise + isSkillOutdated: (skillName: string) => boolean + getSkillEntry: (skillName: string) => SkillFreshnessEntry | undefined +} + +function normalizeSkillName(value: string): string { + return value.trim().toLowerCase() +} + +export function notifyOrcaSkillFreshnessChanged(): void { + cachedFreshness = null + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(FRESHNESS_CHANGED_EVENT)) + } +} + +async function loadSkillFreshness( + force: boolean, + target?: SkillDiscoveryTarget +): Promise { + if (!force && cachedFreshness) { + return cachedFreshness + } + if (pendingFreshness) { + return pendingFreshness + } + const request = window.api.skills + .checkFreshness(target) + .then((result) => { + cachedFreshness = result + return result + }) + .finally(() => { + if (pendingFreshness === request) { + pendingFreshness = null + } + }) + pendingFreshness = request + return request +} + +export const _orcaSkillFreshnessInternalsForTests = { + reset(): void { + cachedFreshness = null + pendingFreshness = null + }, + setCached(result: SkillFreshnessResult | null): void { + cachedFreshness = result + } +} + +export function useOrcaSkillFreshness(options?: { + enabled?: boolean + discoveryTarget?: SkillDiscoveryTarget +}): OrcaSkillFreshnessState { + const enabled = options?.enabled ?? true + const discoveryTarget = options?.discoveryTarget + const mountedRef = useMountedRef() + const [result, setResult] = useState(cachedFreshness) + const [loading, setLoading] = useState(enabled && !cachedFreshness) + const [error, setError] = useState(null) + const generationRef = useRef(0) + + const refresh = useCallback(async (): Promise => { + const generation = ++generationRef.current + if (!enabled) { + if (mountedRef.current) { + setLoading(false) + } + return null + } + if (mountedRef.current) { + setLoading(true) + } + try { + const next = await loadSkillFreshness(true, discoveryTarget) + if (mountedRef.current && generation === generationRef.current) { + setResult(next) + setError(null) + } + return next + } catch (refreshError) { + if (mountedRef.current && generation === generationRef.current) { + setError( + refreshError instanceof Error ? refreshError.message : 'Could not check skill freshness.' + ) + } + return null + } finally { + if (mountedRef.current && generation === generationRef.current) { + setLoading(false) + } + } + }, [discoveryTarget, enabled, mountedRef]) + + useEffect(() => { + if (!enabled) { + return + } + void refresh() + }, [enabled, refresh]) + + useEffect(() => { + if (!enabled) { + return + } + const onChange = (): void => { + void refresh() + } + window.addEventListener('focus', onChange) + window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onChange) + window.addEventListener(FRESHNESS_CHANGED_EVENT, onChange) + return () => { + window.removeEventListener('focus', onChange) + window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onChange) + window.removeEventListener(FRESHNESS_CHANGED_EVENT, onChange) + } + }, [enabled, refresh]) + + const skills = useMemo(() => (enabled && result ? result.skills : []), [enabled, result]) + + const outdatedSkills = useMemo( + () => skills.filter((skill) => skill.status === 'outdated'), + [skills] + ) + + const isSkillOutdated = useCallback( + (skillName: string): boolean => { + const expected = normalizeSkillName(skillName) + return outdatedSkills.some((skill) => normalizeSkillName(skill.skillName) === expected) + }, + [outdatedSkills] + ) + + const getSkillEntry = useCallback( + (skillName: string): SkillFreshnessEntry | undefined => { + const expected = normalizeSkillName(skillName) + return skills.find((skill) => normalizeSkillName(skill.skillName) === expected) + }, + [skills] + ) + + return { + loading, + error, + skills, + outdatedSkills, + refresh, + isSkillOutdated, + getSkillEntry + } +} diff --git a/src/renderer/src/lib/settings-navigation-types.ts b/src/renderer/src/lib/settings-navigation-types.ts index 765940ffe9e..d6b4cb166d2 100644 --- a/src/renderer/src/lib/settings-navigation-types.ts +++ b/src/renderer/src/lib/settings-navigation-types.ts @@ -3,7 +3,7 @@ import type { LucideProps } from 'lucide-react' import type { SettingsSearchEntry } from '@/components/settings/settings-search' export type SettingsNavIcon = ComponentType -export type SettingsNavInstallStatus = 'install' | 'installed' | 'checking' +export type SettingsNavInstallStatus = 'install' | 'installed' | 'checking' | 'outdated' export type SettingsNavTarget = | 'general' diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 1af722e3899..53565f751bb 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -35,6 +35,7 @@ import type { WorkspaceSessionState } from '../../../shared/types' import type { SkillDiscoveryResult } from '../../../shared/skills' +import type { SkillFreshnessResult } from '../../../shared/skill-freshness' import type { SshConnectionState, SshTarget } from '../../../shared/ssh-types' import { getDefaultOnboardingState, @@ -2593,7 +2594,15 @@ function createSkillsApi(): NonNullable['skills']> { skills: [], sources: [], scannedAt: Date.now() - })) + })), + checkFreshness: (target) => + callRuntimeResult('skills.checkFreshness', target, 15_000).catch( + () => ({ + skills: [], + scannedAt: Date.now(), + referenceRoot: null + }) + ) } } diff --git a/src/shared/orca-managed-skills.ts b/src/shared/orca-managed-skills.ts new file mode 100644 index 00000000000..4818b805d73 --- /dev/null +++ b/src/shared/orca-managed-skills.ts @@ -0,0 +1,112 @@ +import { + COMPUTER_USE_SKILL_NAME, + COMPUTER_USE_SKILL_UPDATE_COMMAND, + EPHEMERAL_VMS_SKILL_NAME, + EPHEMERAL_VMS_SKILL_UPDATE_COMMAND, + LINEAR_TICKETS_SKILL_NAME, + LINEAR_TICKETS_SKILL_UPDATE_COMMAND, + ORCA_CLI_SKILL_NAME, + ORCA_CLI_SKILL_UPDATE_COMMAND, + ORCA_LINEAR_SKILL_NAME, + ORCA_LINEAR_SKILL_UPDATE_COMMAND, + ORCHESTRATION_SKILL_NAME, + ORCHESTRATION_SKILL_UPDATE_COMMAND, + buildAgentFeatureSkillUpdateCommand +} from './agent-feature-install-commands' + +/** Settings nav section id that hosts the skill setup surface. */ +export type OrcaManagedSkillSettingsSectionId = + | 'general' + | 'orchestration' + | 'computer-use' + | 'integrations' + | 'experimental' + | 'mobile-emulator' + +export type OrcaManagedSkillDefinition = { + /** Directory / skill package name under skills/ and ~/.agents/skills/. */ + skillName: string + /** Short product label for UI copy. */ + displayName: string + /** Settings sidebar section that owns this skill. */ + settingsSectionId: OrcaManagedSkillSettingsSectionId + /** Global update command (npx skills update …). */ + updateCommand: string + /** Alternate directory/frontmatter names that count as the same skill. */ + aliases?: readonly string[] +} + +export const ORCA_EMULATOR_SKILL_NAME = 'orca-emulator' +export const ORCA_EMULATOR_ANDROID_SKILL_NAME = 'orca-emulator-android' + +/** + * Orca-owned agent skills whose installed copy should track the app release. + * Only skills listed here participate in outdated detection and update prompts. + */ +export const ORCA_MANAGED_SKILLS: readonly OrcaManagedSkillDefinition[] = [ + { + skillName: ORCA_CLI_SKILL_NAME, + displayName: 'Orca CLI', + settingsSectionId: 'general', + updateCommand: ORCA_CLI_SKILL_UPDATE_COMMAND + }, + { + skillName: ORCHESTRATION_SKILL_NAME, + displayName: 'Orchestration', + settingsSectionId: 'orchestration', + updateCommand: ORCHESTRATION_SKILL_UPDATE_COMMAND + }, + { + skillName: COMPUTER_USE_SKILL_NAME, + displayName: 'Computer Use', + settingsSectionId: 'computer-use', + updateCommand: COMPUTER_USE_SKILL_UPDATE_COMMAND + }, + { + skillName: EPHEMERAL_VMS_SKILL_NAME, + displayName: 'Per-workspace env', + settingsSectionId: 'experimental', + updateCommand: EPHEMERAL_VMS_SKILL_UPDATE_COMMAND + }, + { + skillName: ORCA_LINEAR_SKILL_NAME, + displayName: 'Orca Linear', + settingsSectionId: 'integrations', + updateCommand: ORCA_LINEAR_SKILL_UPDATE_COMMAND, + aliases: [LINEAR_TICKETS_SKILL_NAME] + }, + { + skillName: LINEAR_TICKETS_SKILL_NAME, + displayName: 'Linear tickets', + settingsSectionId: 'integrations', + updateCommand: LINEAR_TICKETS_SKILL_UPDATE_COMMAND, + aliases: [ORCA_LINEAR_SKILL_NAME] + }, + { + skillName: ORCA_EMULATOR_SKILL_NAME, + displayName: 'iOS emulator', + settingsSectionId: 'mobile-emulator', + updateCommand: buildAgentFeatureSkillUpdateCommand(ORCA_EMULATOR_SKILL_NAME) + }, + { + skillName: ORCA_EMULATOR_ANDROID_SKILL_NAME, + displayName: 'Android emulator', + settingsSectionId: 'mobile-emulator', + updateCommand: buildAgentFeatureSkillUpdateCommand(ORCA_EMULATOR_ANDROID_SKILL_NAME) + } +] + +export function getOrcaManagedSkillDefinition( + skillName: string +): OrcaManagedSkillDefinition | undefined { + const normalized = skillName.trim().toLowerCase() + return ORCA_MANAGED_SKILLS.find( + (skill) => + skill.skillName === normalized || + skill.aliases?.some((alias) => alias.toLowerCase() === normalized) + ) +} + +export function listOrcaManagedSkillNames(): readonly string[] { + return ORCA_MANAGED_SKILLS.map((skill) => skill.skillName) +} diff --git a/src/shared/skill-freshness.ts b/src/shared/skill-freshness.ts new file mode 100644 index 00000000000..20892ee069f --- /dev/null +++ b/src/shared/skill-freshness.ts @@ -0,0 +1,23 @@ +import type { OrcaManagedSkillSettingsSectionId } from './orca-managed-skills' + +export type SkillFreshnessStatus = 'missing' | 'current' | 'outdated' | 'unknown' + +export type SkillFreshnessEntry = { + skillName: string + displayName: string + settingsSectionId: OrcaManagedSkillSettingsSectionId + updateCommand: string + status: SkillFreshnessStatus + /** sha256 of the app-bundled SKILL.md (expected). */ + expectedHash: string | null + /** sha256 of the globally installed SKILL.md when present. */ + installedHash: string | null + installedPath: string | null +} + +export type SkillFreshnessResult = { + skills: SkillFreshnessEntry[] + scannedAt: number + /** Absolute path to the reference skills root used for expected hashes. */ + referenceRoot: string | null +} From a620dccdf26e37005bc18e3738560a71af5e5a27 Mon Sep 17 00:00:00 2001 From: Jorge de los Santos Date: Tue, 14 Jul 2026 12:25:55 -0300 Subject: [PATCH 02/13] Harden outdated-skill detection based on review feedback Only count global/home installs for freshness, key the renderer cache by discovery target, avoid Escape stealing focus from dialogs/inputs, and cover the repo-local false positive with a regression test. --- src/main/skills/freshness.test.ts | 26 +++++ src/main/skills/freshness.ts | 67 ++++++----- .../skills/OutdatedSkillUpdateDialog.tsx | 19 +++- .../src/hooks/useOrcaSkillFreshness.ts | 105 ++++++++++++++---- 4 files changed, 157 insertions(+), 60 deletions(-) diff --git a/src/main/skills/freshness.test.ts b/src/main/skills/freshness.test.ts index 60633aaecd6..e9ed78f620e 100644 --- a/src/main/skills/freshness.test.ts +++ b/src/main/skills/freshness.test.ts @@ -94,4 +94,30 @@ describe('checkOrcaSkillFreshness', () => { const computerUse = result.skills.find((skill) => skill.skillName === 'computer-use') expect(computerUse?.status).toBe('missing') }) + + it('ignores repo-local skill copies when deciding global freshness', async () => { + const referenceRoot = await makeTempDir('orca-skill-ref-') + const homeDir = await makeTempDir('orca-skill-home-') + const repoDir = await makeTempDir('orca-skill-repo-') + const content = '---\nname: orca-cli\n---\nreference\n' + await writeSkill(referenceRoot, 'orca-cli', content) + await mkdir(join(homeDir, '.agents', 'skills'), { recursive: true }) + // Stale copy only under the repo — must not count as a global install. + await mkdir(join(repoDir, '.agents', 'skills'), { recursive: true }) + await writeSkill( + join(repoDir, '.agents', 'skills'), + 'orca-cli', + '---\nname: orca-cli\n---\nstale\n' + ) + + const result = await checkOrcaSkillFreshness({ + repos: [], + homeDir, + cwd: repoDir, + referenceRoot + }) + + const orcaCli = result.skills.find((skill) => skill.skillName === 'orca-cli') + expect(orcaCli?.status).toBe('missing') + }) }) diff --git a/src/main/skills/freshness.ts b/src/main/skills/freshness.ts index b8375fe1e0f..5251f2a8cf6 100644 --- a/src/main/skills/freshness.ts +++ b/src/main/skills/freshness.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { open, readdir, stat } from 'node:fs/promises' -import { basename, dirname, join } from 'node:path' +import { join } from 'node:path' import { ORCA_MANAGED_SKILLS } from '../../shared/orca-managed-skills' import type { SkillFreshnessEntry, @@ -20,7 +20,14 @@ function normalizeSkillName(value: string): string { } function basenameFromPath(pathValue: string): string { - return pathValue.split(/[\\/]/).filter(Boolean).at(-1) ?? pathValue + const segments = pathValue.split(/[\\/]/) + for (let index = segments.length - 1; index >= 0; index -= 1) { + const segment = segments[index] + if (segment) { + return segment + } + } + return pathValue } /** Normalize line endings so macOS/Windows installs hash the same content. */ @@ -65,18 +72,16 @@ function pickInstalledGlobalSkill( skills: readonly DiscoveredSkill[], skillName: string ): DiscoveredSkill | null { - // Prefer home installs so repo/plugin copies do not mask the global package. + // Why: only global/home installs participate in the update prompt. Repo and + // plugin copies must not mark a skill outdated or drive `npx skills update + // --global` when the user never installed that package globally. // Match only this skill's package name — aliases are install-time synonyms // (e.g. Linear), not interchangeable content for hash comparison. - const homeMatch = skills.find( - (skill) => - skill.installed && skill.sourceKind === 'home' && skillMatchesManagedName(skill, skillName) - ) - if (homeMatch) { - return homeMatch - } return ( - skills.find((skill) => skill.installed && skillMatchesManagedName(skill, skillName)) ?? null + skills.find( + (skill) => + skill.installed && skill.sourceKind === 'home' && skillMatchesManagedName(skill, skillName) + ) ?? null ) } @@ -130,22 +135,23 @@ export async function checkOrcaSkillFreshness(args?: { cwd: args?.cwd }) - const skills: SkillFreshnessEntry[] = [] - for (const definition of ORCA_MANAGED_SKILLS) { - const expectedHash = expectedHashes.get(normalizeSkillName(definition.skillName)) ?? null - const installed = pickInstalledGlobalSkill(discovery.skills, definition.skillName) - const installedHash = installed ? await hashSkillFile(installed.skillFilePath) : null - skills.push({ - skillName: definition.skillName, - displayName: definition.displayName, - settingsSectionId: definition.settingsSectionId, - updateCommand: definition.updateCommand, - status: resolveStatus({ expectedHash, installedHash }), - expectedHash, - installedHash, - installedPath: installed?.skillFilePath ?? null + const skills = await Promise.all( + ORCA_MANAGED_SKILLS.map(async (definition) => { + const expectedHash = expectedHashes.get(normalizeSkillName(definition.skillName)) ?? null + const installed = pickInstalledGlobalSkill(discovery.skills, definition.skillName) + const installedHash = installed ? await hashSkillFile(installed.skillFilePath) : null + return { + skillName: definition.skillName, + displayName: definition.displayName, + settingsSectionId: definition.settingsSectionId, + updateCommand: definition.updateCommand, + status: resolveStatus({ expectedHash, installedHash }), + expectedHash, + installedHash, + installedPath: installed?.skillFilePath ?? null + } satisfies SkillFreshnessEntry }) - } + ) return { skills, @@ -153,12 +159,3 @@ export async function checkOrcaSkillFreshness(args?: { referenceRoot } } - -/** Test helper: hash a path under a reference skills root. */ -export function referenceSkillFilePath(referenceRoot: string, skillName: string): string { - return join(referenceRoot, basename(skillName), SKILL_FILE_NAME) -} - -export function referenceSkillDirectory(referenceRoot: string, skillName: string): string { - return dirname(referenceSkillFilePath(referenceRoot, skillName)) -} diff --git a/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx b/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx index 97bd3e67c56..f8d61ed9d62 100644 --- a/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx +++ b/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx @@ -24,9 +24,24 @@ export function OutdatedSkillUpdateDialog(props: { useEffect(() => { const onKeyDown = (event: KeyboardEvent): void => { - if (event.key === 'Escape') { - onDismiss() + if (event.key !== 'Escape' || event.defaultPrevented) { + return } + // Why: do not steal Escape from open dialogs, menus, or text fields. + const target = event.target + if ( + target instanceof HTMLElement && + (target.closest('[role="dialog"]') || + target.closest('[role="menu"]') || + target.isContentEditable || + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'SELECT') + ) { + return + } + event.preventDefault() + onDismiss() } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) diff --git a/src/renderer/src/hooks/useOrcaSkillFreshness.ts b/src/renderer/src/hooks/useOrcaSkillFreshness.ts index 6d89164faf1..2c872074b6b 100644 --- a/src/renderer/src/hooks/useOrcaSkillFreshness.ts +++ b/src/renderer/src/hooks/useOrcaSkillFreshness.ts @@ -6,8 +6,8 @@ import { useMountedRef } from './useMountedRef' const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed' const FRESHNESS_CHANGED_EVENT = 'orca:skill-freshness-changed' -let cachedFreshness: SkillFreshnessResult | null = null -let pendingFreshness: Promise | null = null +let cachedFreshnessByTarget = new Map() +let pendingFreshnessByTarget = new Map>() export type OrcaSkillFreshnessState = { loading: boolean @@ -23,8 +23,21 @@ function normalizeSkillName(value: string): string { return value.trim().toLowerCase() } +function getSkillDiscoveryTargetKey(target: SkillDiscoveryTarget | undefined): string { + if (target?.projectRuntime) { + return target.projectRuntime.status === 'resolved' + ? target.projectRuntime.runtime.cacheKey + : target.projectRuntime.repair.cacheKey + } + if (target?.runtime === 'wsl') { + return `wsl:${target.wslDistro?.trim() ?? ''}` + } + const cwd = target?.cwd?.trim() + return cwd ? `host:cwd:${cwd}` : 'host' +} + export function notifyOrcaSkillFreshnessChanged(): void { - cachedFreshness = null + cachedFreshnessByTarget.clear() if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent(FRESHNESS_CHANGED_EVENT)) } @@ -34,34 +47,45 @@ async function loadSkillFreshness( force: boolean, target?: SkillDiscoveryTarget ): Promise { - if (!force && cachedFreshness) { - return cachedFreshness + const key = getSkillDiscoveryTargetKey(target) + if (!force) { + const cached = cachedFreshnessByTarget.get(key) + if (cached) { + return cached + } } - if (pendingFreshness) { - return pendingFreshness + + const inFlight = pendingFreshnessByTarget.get(key) + if (inFlight && !force) { + return inFlight } + const request = window.api.skills .checkFreshness(target) .then((result) => { - cachedFreshness = result + cachedFreshnessByTarget.set(key, result) return result }) .finally(() => { - if (pendingFreshness === request) { - pendingFreshness = null + if (pendingFreshnessByTarget.get(key) === request) { + pendingFreshnessByTarget.delete(key) } }) - pendingFreshness = request + pendingFreshnessByTarget.set(key, request) return request } export const _orcaSkillFreshnessInternalsForTests = { reset(): void { - cachedFreshness = null - pendingFreshness = null + cachedFreshnessByTarget = new Map() + pendingFreshnessByTarget = new Map() }, - setCached(result: SkillFreshnessResult | null): void { - cachedFreshness = result + setCached(result: SkillFreshnessResult | null, targetKey = 'host'): void { + if (result) { + cachedFreshnessByTarget.set(targetKey, result) + } else { + cachedFreshnessByTarget.clear() + } } } @@ -71,14 +95,19 @@ export function useOrcaSkillFreshness(options?: { }): OrcaSkillFreshnessState { const enabled = options?.enabled ?? true const discoveryTarget = options?.discoveryTarget + const discoveryTargetKey = getSkillDiscoveryTargetKey(discoveryTarget) const mountedRef = useMountedRef() - const [result, setResult] = useState(cachedFreshness) - const [loading, setLoading] = useState(enabled && !cachedFreshness) + const cachedDiscovery = cachedFreshnessByTarget.get(discoveryTargetKey) ?? null + const [result, setResult] = useState(cachedDiscovery) + const [loading, setLoading] = useState(enabled && !cachedDiscovery) const [error, setError] = useState(null) const generationRef = useRef(0) + const currentTargetKeyRef = useRef(discoveryTargetKey) + currentTargetKeyRef.current = discoveryTargetKey const refresh = useCallback(async (): Promise => { const generation = ++generationRef.current + const requestTargetKey = discoveryTargetKey if (!enabled) { if (mountedRef.current) { setLoading(false) @@ -90,31 +119,61 @@ export function useOrcaSkillFreshness(options?: { } try { const next = await loadSkillFreshness(true, discoveryTarget) - if (mountedRef.current && generation === generationRef.current) { + if ( + mountedRef.current && + generation === generationRef.current && + currentTargetKeyRef.current === requestTargetKey + ) { setResult(next) setError(null) } return next } catch (refreshError) { - if (mountedRef.current && generation === generationRef.current) { + if ( + mountedRef.current && + generation === generationRef.current && + currentTargetKeyRef.current === requestTargetKey + ) { setError( refreshError instanceof Error ? refreshError.message : 'Could not check skill freshness.' ) } return null } finally { - if (mountedRef.current && generation === generationRef.current) { + if ( + mountedRef.current && + generation === generationRef.current && + currentTargetKeyRef.current === requestTargetKey + ) { setLoading(false) } } - }, [discoveryTarget, enabled, mountedRef]) + }, [discoveryTarget, discoveryTargetKey, enabled, mountedRef]) useEffect(() => { if (!enabled) { return } - void refresh() - }, [enabled, refresh]) + // Prefer cache for the initial paint when another surface already scanned. + void loadSkillFreshness(false, discoveryTarget) + .then((next) => { + if (mountedRef.current && currentTargetKeyRef.current === discoveryTargetKey) { + setResult(next) + setError(null) + setLoading(false) + } + }) + .catch((refreshError: unknown) => { + if (mountedRef.current && currentTargetKeyRef.current === discoveryTargetKey) { + setError( + refreshError instanceof Error + ? refreshError.message + : 'Could not check skill freshness.' + ) + setLoading(false) + } + }) + }, [discoveryTarget, discoveryTargetKey, enabled, mountedRef]) useEffect(() => { if (!enabled) { From 16332f84b84fde73e60cd228deb90aa38dd936b0 Mon Sep 17 00:00:00 2001 From: Jorge de los Santos Date: Tue, 14 Jul 2026 12:27:18 -0300 Subject: [PATCH 03/13] Address CodeRabbit findings for outdated skill freshness Reject oversized SKILL.md prefixes, treat unreadable installs as unknown, scope freshness only to home installs, key the renderer cache by discovery target, drop global Escape dismiss, and use hash-based i18n keys. --- src/main/skills/freshness.test.ts | 23 ++++++++++ src/main/skills/freshness.ts | 21 +++++++-- .../settings/AgentSkillSetupPanel.tsx | 7 +-- .../components/settings/SettingsSidebar.tsx | 2 +- .../skills/OutdatedSkillUpdateDialog.tsx | 45 +++++-------------- 5 files changed, 55 insertions(+), 43 deletions(-) diff --git a/src/main/skills/freshness.test.ts b/src/main/skills/freshness.test.ts index e9ed78f620e..8708879de37 100644 --- a/src/main/skills/freshness.test.ts +++ b/src/main/skills/freshness.test.ts @@ -95,6 +95,29 @@ describe('checkOrcaSkillFreshness', () => { expect(computerUse?.status).toBe('missing') }) + it('returns unknown when an installed skill file cannot be hashed', async () => { + const referenceRoot = await makeTempDir('orca-skill-ref-') + const homeDir = await makeTempDir('orca-skill-home-') + await writeSkill(referenceRoot, 'orca-cli', '---\nname: orca-cli\n---\nexpected\n') + await mkdir(join(homeDir, '.agents', 'skills', 'orca-cli'), { recursive: true }) + // Directory where SKILL.md should be — leave it empty so discovery still + // finds the skill folder via other roots? Discovery needs SKILL.md present. + // Create a skill file then make it unreadable by replacing with a directory. + const skillFilePath = join(homeDir, '.agents', 'skills', 'orca-cli', 'SKILL.md') + await writeFile(skillFilePath, '---\nname: orca-cli\n---\nbody\n', 'utf8') + // Oversized content past the 256 KiB guard. + await writeFile(skillFilePath, 'x'.repeat(300 * 1024), 'utf8') + + const result = await checkOrcaSkillFreshness({ + repos: [], + homeDir, + referenceRoot + }) + + const orcaCli = result.skills.find((skill) => skill.skillName === 'orca-cli') + expect(orcaCli?.status).toBe('unknown') + }) + it('ignores repo-local skill copies when deciding global freshness', async () => { const referenceRoot = await makeTempDir('orca-skill-ref-') const homeDir = await makeTempDir('orca-skill-home-') diff --git a/src/main/skills/freshness.ts b/src/main/skills/freshness.ts index 5251f2a8cf6..7fd3d6b3c1f 100644 --- a/src/main/skills/freshness.ts +++ b/src/main/skills/freshness.ts @@ -42,9 +42,14 @@ export function hashSkillMarkdown(content: string): string { async function readSkillMarkdown(skillFilePath: string): Promise { try { const fileStat = await stat(skillFilePath) + // Why: prefix hashing two oversized files can false-match as current. + // Treat oversized skills as unreadable so status becomes unknown, not current. + if (fileStat.size > MAX_SKILL_MARKDOWN_BYTES) { + return null + } const file = await open(skillFilePath, 'r') try { - const buffer = Buffer.alloc(Math.min(fileStat.size, MAX_SKILL_MARKDOWN_BYTES)) + const buffer = Buffer.alloc(fileStat.size) const { bytesRead } = await file.read(buffer, 0, buffer.length, 0) return buffer.toString('utf8', 0, bytesRead) } finally { @@ -109,13 +114,19 @@ async function loadExpectedHashes(referenceRoot: string): Promise @@ -274,7 +271,7 @@ export function AgentSkillSetupPanel({ ) : installed && outdated ? ( {translate( - 'auto.components.settings.AgentSkillSetupPanel.outdated', + 'auto.components.settings.AgentSkillSetupPanel.23b567b459', 'Outdated' )} diff --git a/src/renderer/src/components/settings/SettingsSidebar.tsx b/src/renderer/src/components/settings/SettingsSidebar.tsx index ab4081df16f..9ee5f36e7f1 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.tsx @@ -158,7 +158,7 @@ export function SettingsSidebar({ case 'checking': return translate('auto.components.settings.AgentSkillSetupPanel.68a468752e', 'Checking...') case 'outdated': - return translate('auto.components.settings.SettingsSidebar.skillOutdated', 'Outdated') + return translate('auto.components.settings.SettingsSidebar.23b567b459', 'Outdated') } } const installStatusClassName = (status: SettingsNavInstallStatus): string => diff --git a/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx b/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx index f8d61ed9d62..124f9480d12 100644 --- a/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx +++ b/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx @@ -1,4 +1,3 @@ -import { useEffect } from 'react' import { X } from 'lucide-react' import { toast } from 'sonner' import type { SkillFreshnessEntry } from '../../../../shared/skill-freshness' @@ -11,6 +10,9 @@ import { translate } from '@/i18n/i18n' * Compact bottom-right skill update prompt — same shell and option pattern as * UpdateCard's SimpleCardContent: title + dismiss, short body, text link, * full-width primary Update. + * + * Why no global Escape: dismissing persists the expected hash. Stealing Escape + * from terminals/search would permanently hide the prompt; users dismiss via X. */ export function OutdatedSkillUpdateDialog(props: { skill: SkillFreshnessEntry @@ -22,37 +24,12 @@ export function OutdatedSkillUpdateDialog(props: { // Why: UpdateCard owns bottom-10; raise this card so both remain readable. const updateCardVisible = updateStatus.state !== 'idle' && updateStatus.state !== 'not-available' - useEffect(() => { - const onKeyDown = (event: KeyboardEvent): void => { - if (event.key !== 'Escape' || event.defaultPrevented) { - return - } - // Why: do not steal Escape from open dialogs, menus, or text fields. - const target = event.target - if ( - target instanceof HTMLElement && - (target.closest('[role="dialog"]') || - target.closest('[role="menu"]') || - target.isContentEditable || - target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA' || - target.tagName === 'SELECT') - ) { - return - } - event.preventDefault() - onDismiss() - } - window.addEventListener('keydown', onKeyDown) - return () => window.removeEventListener('keydown', onKeyDown) - }, [onDismiss]) - const copyCommand = async (): Promise => { try { await window.api.ui.writeClipboardText(skill.updateCommand) toast.success( translate( - 'auto.components.skills.OutdatedSkillUpdateDialog.copied', + 'auto.components.skills.OutdatedSkillUpdateDialog.7a5f26c79f', 'Copied update command.' ) ) @@ -61,7 +38,7 @@ export function OutdatedSkillUpdateDialog(props: { error instanceof Error ? error.message : translate( - 'auto.components.skills.OutdatedSkillUpdateDialog.copyFailed', + 'auto.components.skills.OutdatedSkillUpdateDialog.7e6c0adbca', 'Failed to copy update command.' ) ) @@ -83,7 +60,7 @@ export function OutdatedSkillUpdateDialog(props: {

{translate( - 'auto.components.skills.OutdatedSkillUpdateDialog.title', + 'auto.components.skills.OutdatedSkillUpdateDialog.556eab4c6f', 'Your Orca skills are outdated' )}

@@ -93,7 +70,7 @@ export function OutdatedSkillUpdateDialog(props: { className="size-7 min-h-[44px] min-w-[44px] shrink-0 -m-2" onClick={onDismiss} aria-label={translate( - 'auto.components.skills.OutdatedSkillUpdateDialog.dismissAria', + 'auto.components.skills.OutdatedSkillUpdateDialog.c04888cdf5', 'Dismiss skill update' )} > @@ -103,7 +80,7 @@ export function OutdatedSkillUpdateDialog(props: {

{translate( - 'auto.components.skills.OutdatedSkillUpdateDialog.readyLine', + 'auto.components.skills.OutdatedSkillUpdateDialog.ffa78ad9cf', 'The {{skillName}} skill needs an update.', { skillName: skill.displayName } )} @@ -111,7 +88,7 @@ export function OutdatedSkillUpdateDialog(props: {

{translate( - 'auto.components.skills.OutdatedSkillUpdateDialog.hint', + 'auto.components.skills.OutdatedSkillUpdateDialog.f37228fd6f', 'Update them so agents keep the latest Orca workflows.' )}

@@ -122,7 +99,7 @@ export function OutdatedSkillUpdateDialog(props: { onClick={() => void copyCommand()} > {translate( - 'auto.components.skills.OutdatedSkillUpdateDialog.copyCommand', + 'auto.components.skills.OutdatedSkillUpdateDialog.d2ce485b34', 'Copy update command' )} @@ -134,7 +111,7 @@ export function OutdatedSkillUpdateDialog(props: { onClick={onUpdate} className="mt-0.5 w-full cursor-pointer" > - {translate('auto.components.skills.OutdatedSkillUpdateDialog.update', 'Update')} + {translate('auto.components.skills.OutdatedSkillUpdateDialog.fb91e24fa5', 'Update')}
From 0cdfc3e6926108939dbbb7b5ebca1e2f352924d5 Mon Sep 17 00:00:00 2001 From: Jorge de los Santos Date: Tue, 14 Jul 2026 12:32:04 -0300 Subject: [PATCH 04/13] Polish outdated-skill freshness for production readiness Serialize forced refreshes per discovery target, cover packaged/dev reference root resolution, and keep the prior CodeRabbit correctness fixes covered by regression tests. --- src/main/skills/bundled-skills-root.test.ts | 62 +++++++++++++++++++ .../src/hooks/useOrcaSkillFreshness.ts | 20 ++++-- 2 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 src/main/skills/bundled-skills-root.test.ts diff --git a/src/main/skills/bundled-skills-root.test.ts b/src/main/skills/bundled-skills-root.test.ts new file mode 100644 index 00000000000..13acc6d5827 --- /dev/null +++ b/src/main/skills/bundled-skills-root.test.ts @@ -0,0 +1,62 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { resolveBundledSkillsRoot } = await import('./bundled-skills-root') + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => tmpdir() + } +})) + +describe('resolveBundledSkillsRoot', () => { + const created: string[] = [] + + afterEach(async () => { + created.length = 0 + }) + + it('returns packaged resources/orca-skills when present', async () => { + const resourcesPath = await mkdtemp(join(tmpdir(), 'orca-resources-')) + created.push(resourcesPath) + const skillsRoot = join(resourcesPath, 'orca-skills') + await mkdir(skillsRoot, { recursive: true }) + await writeFile(join(skillsRoot, 'README'), 'x', 'utf8') + + expect( + resolveBundledSkillsRoot({ + isPackaged: true, + resourcesPath, + appPath: tmpdir() + }) + ).toBe(skillsRoot) + }) + + it('returns null when packaged resources are missing', () => { + expect( + resolveBundledSkillsRoot({ + isPackaged: true, + resourcesPath: join(tmpdir(), 'missing-resources-dir'), + appPath: tmpdir() + }) + ).toBeNull() + }) + + it('falls back to a skills directory next to appPath in dev', async () => { + const appPath = await mkdtemp(join(tmpdir(), 'orca-app-')) + created.push(appPath) + const skillsRoot = join(appPath, 'skills') + await mkdir(skillsRoot, { recursive: true }) + + expect( + resolveBundledSkillsRoot({ + isPackaged: false, + resourcesPath: join(tmpdir(), 'unused'), + appPath + }) + ).toBe(skillsRoot) + }) +}) diff --git a/src/renderer/src/hooks/useOrcaSkillFreshness.ts b/src/renderer/src/hooks/useOrcaSkillFreshness.ts index 2c872074b6b..96aa8a64857 100644 --- a/src/renderer/src/hooks/useOrcaSkillFreshness.ts +++ b/src/renderer/src/hooks/useOrcaSkillFreshness.ts @@ -53,11 +53,21 @@ async function loadSkillFreshness( if (cached) { return cached } - } - - const inFlight = pendingFreshnessByTarget.get(key) - if (inFlight && !force) { - return inFlight + const inFlight = pendingFreshnessByTarget.get(key) + if (inFlight) { + return inFlight + } + } else { + // Why: wait out any in-flight scan for this target so a forced refresh + // cannot race an older result writing back over a newer one. + const inFlight = pendingFreshnessByTarget.get(key) + if (inFlight) { + try { + await inFlight + } catch { + // Previous failure should not block an explicit re-check. + } + } } const request = window.api.skills From f62931d6c933b5e8b7ecc181e2dc4ae9d4b4ccfa Mon Sep 17 00:00:00 2001 From: Jorge de los Santos Date: Tue, 14 Jul 2026 12:41:01 -0300 Subject: [PATCH 05/13] Fix remaining CodeRabbit findings for skill freshness Loop-read skill files to EOF with an oversize guard, clean temp dirs in tests, give each outdated-skill card a unique heading id, and simplify the prompt queue effect. --- src/main/skills/bundled-skills-root.test.ts | 3 +- src/main/skills/freshness.test.ts | 4 +- src/main/skills/freshness.ts | 46 +++++++++---------- .../skills/OutdatedSkillUpdateDialog.tsx | 9 ++-- .../skills/OutdatedSkillUpdateHost.tsx | 9 ++-- 5 files changed, 35 insertions(+), 36 deletions(-) diff --git a/src/main/skills/bundled-skills-root.test.ts b/src/main/skills/bundled-skills-root.test.ts index 13acc6d5827..b7bcec15a99 100644 --- a/src/main/skills/bundled-skills-root.test.ts +++ b/src/main/skills/bundled-skills-root.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -16,6 +16,7 @@ describe('resolveBundledSkillsRoot', () => { const created: string[] = [] afterEach(async () => { + await Promise.all(created.map((dir) => rm(dir, { recursive: true, force: true }))) created.length = 0 }) diff --git a/src/main/skills/freshness.test.ts b/src/main/skills/freshness.test.ts index 8708879de37..efebbe655bf 100644 --- a/src/main/skills/freshness.test.ts +++ b/src/main/skills/freshness.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' @@ -11,7 +11,7 @@ import { const tempDirs: string[] = [] afterEach(async () => { - // Temp dirs are disposable process artifacts; leave them for OS cleanup. + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) tempDirs.length = 0 }) diff --git a/src/main/skills/freshness.ts b/src/main/skills/freshness.ts index 7fd3d6b3c1f..29970929de7 100644 --- a/src/main/skills/freshness.ts +++ b/src/main/skills/freshness.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' -import { open, readdir, stat } from 'node:fs/promises' -import { join } from 'node:path' +import { open, readdir } from 'node:fs/promises' +import { basename, join } from 'node:path' import { ORCA_MANAGED_SKILLS } from '../../shared/orca-managed-skills' import type { SkillFreshnessEntry, @@ -19,17 +19,6 @@ function normalizeSkillName(value: string): string { return value.trim().toLowerCase() } -function basenameFromPath(pathValue: string): string { - const segments = pathValue.split(/[\\/]/) - for (let index = segments.length - 1; index >= 0; index -= 1) { - const segment = segments[index] - if (segment) { - return segment - } - } - return pathValue -} - /** Normalize line endings so macOS/Windows installs hash the same content. */ export function normalizeSkillMarkdownForHash(content: string): string { return content.replace(/\r\n/g, '\n') @@ -41,17 +30,28 @@ export function hashSkillMarkdown(content: string): string { async function readSkillMarkdown(skillFilePath: string): Promise { try { - const fileStat = await stat(skillFilePath) - // Why: prefix hashing two oversized files can false-match as current. - // Treat oversized skills as unreadable so status becomes unknown, not current. - if (fileStat.size > MAX_SKILL_MARKDOWN_BYTES) { - return null - } const file = await open(skillFilePath, 'r') try { - const buffer = Buffer.alloc(fileStat.size) - const { bytesRead } = await file.read(buffer, 0, buffer.length, 0) - return buffer.toString('utf8', 0, bytesRead) + // Why: FileHandle.read may return short reads; loop to EOF. Cap at + // MAX+1 so oversized skills are rejected instead of prefix-hashed. + const buffer = Buffer.alloc(MAX_SKILL_MARKDOWN_BYTES + 1) + let totalBytesRead = 0 + while (totalBytesRead < buffer.length) { + const { bytesRead } = await file.read( + buffer, + totalBytesRead, + buffer.length - totalBytesRead, + totalBytesRead + ) + if (bytesRead === 0) { + break + } + totalBytesRead += bytesRead + } + if (totalBytesRead > MAX_SKILL_MARKDOWN_BYTES) { + return null + } + return buffer.toString('utf8', 0, totalBytesRead) } finally { await file.close() } @@ -69,7 +69,7 @@ function skillMatchesManagedName(skill: DiscoveredSkill, skillName: string): boo const expected = normalizeSkillName(skillName) return ( normalizeSkillName(skill.name) === expected || - normalizeSkillName(basenameFromPath(skill.directoryPath)) === expected + normalizeSkillName(basename(skill.directoryPath)) === expected ) } diff --git a/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx b/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx index 124f9480d12..b0bc056a3b4 100644 --- a/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx +++ b/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx @@ -20,6 +20,7 @@ export function OutdatedSkillUpdateDialog(props: { onUpdate: () => void }): React.JSX.Element { const { skill, onDismiss, onUpdate } = props + const headingId = `outdated-skill-update-heading-${skill.skillName}` const updateStatus = useAppStore((s) => s.updateStatus) // Why: UpdateCard owns bottom-10; raise this card so both remain readable. const updateCardVisible = updateStatus.state !== 'idle' && updateStatus.state !== 'not-available' @@ -51,14 +52,10 @@ export function OutdatedSkillUpdateDialog(props: { updateCardVisible ? 'bottom-[220px]' : 'bottom-10' }`} > - +
-

+

{translate( 'auto.components.skills.OutdatedSkillUpdateDialog.556eab4c6f', 'Your Orca skills are outdated' diff --git a/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx b/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx index 630f1166a53..9aab208bc92 100644 --- a/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx +++ b/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx @@ -39,10 +39,11 @@ export function OutdatedSkillUpdateHost(): React.JSX.Element | null { }, [activeSkillName, promptQueue]) useEffect(() => { - if (loading || promptQueue.length === 0) { - if (!loading && promptQueue.length === 0) { - setActiveSkillName(null) - } + if (loading) { + return + } + if (promptQueue.length === 0) { + setActiveSkillName(null) return } setActiveSkillName((current) => { From 933c6fe92071ab96d8aaaf989e091d07984e3f8d Mon Sep 17 00:00:00 2001 From: Jorge de los Santos Date: Tue, 14 Jul 2026 13:04:33 -0300 Subject: [PATCH 06/13] Address Claude Fable review: multi-home freshness and update-loop fix Flag outdated when any home provider copy diverges, skip repo walks on the freshness path, coalesce forced renderer rescans, suppress re-prompts after an update attempt for the current app reference hash, and avoid Settings "Checking..." flicker from freshness loading. --- src/main/ipc/skills.test.ts | 4 +- src/main/ipc/skills.ts | 6 +- src/main/runtime/rpc/methods/skills.ts | 8 +-- src/main/skills/freshness.test.ts | 38 ++++++++--- src/main/skills/freshness.ts | 66 +++++++++++-------- .../src/components/settings/Settings.tsx | 29 ++++---- .../skills/OutdatedSkillUpdateHost.tsx | 4 ++ .../skills/outdated-skill-reminder.test.ts | 11 +++- .../skills/outdated-skill-reminder.ts | 48 +++++++++++++- .../src/hooks/useOrcaSkillFreshness.ts | 59 ++++++++++++----- src/shared/orca-managed-skills.ts | 25 +------ src/shared/skill-freshness.ts | 8 ++- 12 files changed, 206 insertions(+), 100 deletions(-) diff --git a/src/main/ipc/skills.test.ts b/src/main/ipc/skills.test.ts index aa3d2f4ec88..c6b8a78b43a 100644 --- a/src/main/ipc/skills.test.ts +++ b/src/main/ipc/skills.test.ts @@ -87,10 +87,10 @@ describe('registerSkillsHandlers', () => { return call[1] as (_event: unknown, target?: unknown) => Promise } - it('registers a host freshness check against the shared scan context', async () => { + it('registers a host freshness check without walking project repos', async () => { const handler = getFreshnessHandler() await handler(null, undefined) - expect(checkFreshnessMock).toHaveBeenCalledWith({ repos }) + expect(checkFreshnessMock).toHaveBeenCalledWith({ homeDir: undefined, repos: [] }) }) it('uses host skill discovery when resolved project runtime overrides stale WSL target state', async () => { diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index 31471bac7db..10980002c72 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -69,7 +69,11 @@ export function registerSkillsHandlers(store: Store): void { 'skills:checkFreshness', async (_event, target?: SkillDiscoveryTarget): Promise => { const context = await resolveScanContext(target) - return checkOrcaSkillFreshness(context) + // Why: freshness only hashes home installs — skip repo trees entirely (M4). + return checkOrcaSkillFreshness({ + homeDir: context.homeDir, + repos: [] + }) } ) } diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 72bba1f1bfb..dc1265871d8 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -21,11 +21,7 @@ export const SKILL_METHODS: RpcMethod[] = [ defineMethod({ name: 'skills.checkFreshness', params: SkillDiscoveryParams, - handler: async (params, { runtime }) => { - const cwd = params.cwd?.trim() || undefined - return cwd - ? checkOrcaSkillFreshness({ repos: [], cwd }) - : checkOrcaSkillFreshness({ repos: runtime.listRepos() }) - } + // Why: freshness only needs home skill roots; never walk project repos. + handler: async () => checkOrcaSkillFreshness({ repos: [] }) }) ] diff --git a/src/main/skills/freshness.test.ts b/src/main/skills/freshness.test.ts index efebbe655bf..80298aaa71e 100644 --- a/src/main/skills/freshness.test.ts +++ b/src/main/skills/freshness.test.ts @@ -30,9 +30,10 @@ async function writeSkill(root: string, skillName: string, body: string): Promis } describe('skill freshness hashing', () => { - it('normalizes CRLF so Windows and Unix installs compare equal', () => { + it('normalizes CRLF and BOM so Windows and Unix installs compare equal', () => { expect(hashSkillMarkdown('a\r\nb\n')).toBe(hashSkillMarkdown('a\nb\n')) - expect(normalizeSkillMarkdownForHash('x\r\ny')).toBe('x\ny') + expect(hashSkillMarkdown('\uFEFFhello\n')).toBe(hashSkillMarkdown('hello\n')) + expect(normalizeSkillMarkdownForHash('\uFEFFx\r\ny')).toBe('x\ny') }) }) @@ -59,6 +60,7 @@ describe('checkOrcaSkillFreshness', () => { expect(orcaCli?.expectedHash).toBeTruthy() expect(orcaCli?.installedHash).toBeTruthy() expect(orcaCli?.expectedHash).not.toBe(orcaCli?.installedHash) + expect(orcaCli?.divergingPaths.length).toBe(1) }) it('marks an installed skill current when content matches the reference', async () => { @@ -77,6 +79,7 @@ describe('checkOrcaSkillFreshness', () => { const orchestration = result.skills.find((skill) => skill.skillName === 'orchestration') expect(orchestration?.status).toBe('current') + expect(orchestration?.divergingPaths).toEqual([]) }) it('marks a managed skill missing when it is not installed', async () => { @@ -100,12 +103,7 @@ describe('checkOrcaSkillFreshness', () => { const homeDir = await makeTempDir('orca-skill-home-') await writeSkill(referenceRoot, 'orca-cli', '---\nname: orca-cli\n---\nexpected\n') await mkdir(join(homeDir, '.agents', 'skills', 'orca-cli'), { recursive: true }) - // Directory where SKILL.md should be — leave it empty so discovery still - // finds the skill folder via other roots? Discovery needs SKILL.md present. - // Create a skill file then make it unreadable by replacing with a directory. const skillFilePath = join(homeDir, '.agents', 'skills', 'orca-cli', 'SKILL.md') - await writeFile(skillFilePath, '---\nname: orca-cli\n---\nbody\n', 'utf8') - // Oversized content past the 256 KiB guard. await writeFile(skillFilePath, 'x'.repeat(300 * 1024), 'utf8') const result = await checkOrcaSkillFreshness({ @@ -125,7 +123,6 @@ describe('checkOrcaSkillFreshness', () => { const content = '---\nname: orca-cli\n---\nreference\n' await writeSkill(referenceRoot, 'orca-cli', content) await mkdir(join(homeDir, '.agents', 'skills'), { recursive: true }) - // Stale copy only under the repo — must not count as a global install. await mkdir(join(repoDir, '.agents', 'skills'), { recursive: true }) await writeSkill( join(repoDir, '.agents', 'skills'), @@ -143,4 +140,29 @@ describe('checkOrcaSkillFreshness', () => { const orcaCli = result.skills.find((skill) => skill.skillName === 'orca-cli') expect(orcaCli?.status).toBe('missing') }) + + it('flags outdated when any home provider copy diverges (M2)', async () => { + const referenceRoot = await makeTempDir('orca-skill-ref-') + const homeDir = await makeTempDir('orca-skill-home-') + const content = '---\nname: orca-cli\n---\nreference\n' + await writeSkill(referenceRoot, 'orca-cli', content) + await mkdir(join(homeDir, '.agents', 'skills'), { recursive: true }) + await mkdir(join(homeDir, '.claude', 'skills'), { recursive: true }) + await writeSkill(join(homeDir, '.agents', 'skills'), 'orca-cli', content) + await writeSkill( + join(homeDir, '.claude', 'skills'), + 'orca-cli', + '---\nname: orca-cli\n---\nstale-claude\n' + ) + + const result = await checkOrcaSkillFreshness({ + repos: [], + homeDir, + referenceRoot + }) + + const orcaCli = result.skills.find((skill) => skill.skillName === 'orca-cli') + expect(orcaCli?.status).toBe('outdated') + expect(orcaCli?.divergingPaths.some((path) => path.includes('.claude'))).toBe(true) + }) }) diff --git a/src/main/skills/freshness.ts b/src/main/skills/freshness.ts index 29970929de7..690646b5ddc 100644 --- a/src/main/skills/freshness.ts +++ b/src/main/skills/freshness.ts @@ -21,7 +21,8 @@ function normalizeSkillName(value: string): string { /** Normalize line endings so macOS/Windows installs hash the same content. */ export function normalizeSkillMarkdownForHash(content: string): string { - return content.replace(/\r\n/g, '\n') + // Why: strip a leading UTF-8 BOM so Windows-written skills still match. + return content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n') } export function hashSkillMarkdown(content: string): string { @@ -73,20 +74,15 @@ function skillMatchesManagedName(skill: DiscoveredSkill, skillName: string): boo ) } -function pickInstalledGlobalSkill( +function listInstalledHomeSkills( skills: readonly DiscoveredSkill[], skillName: string -): DiscoveredSkill | null { - // Why: only global/home installs participate in the update prompt. Repo and - // plugin copies must not mark a skill outdated or drive `npx skills update - // --global` when the user never installed that package globally. - // Match only this skill's package name — aliases are install-time synonyms - // (e.g. Linear), not interchangeable content for hash comparison. - return ( - skills.find( - (skill) => - skill.installed && skill.sourceKind === 'home' && skillMatchesManagedName(skill, skillName) - ) ?? null +): DiscoveredSkill[] { + // Why: `npx skills add --global` writes into each agent's home skills dir + // (~/.agents, ~/.claude, ~/.codex, …). Any diverging home copy must count. + return skills.filter( + (skill) => + skill.installed && skill.sourceKind === 'home' && skillMatchesManagedName(skill, skillName) ) } @@ -113,21 +109,24 @@ async function loadExpectedHashes(referenceRoot: string): Promise 0) { + return 'outdated' + } + return 'current' } export async function checkOrcaSkillFreshness(args?: { @@ -140,6 +139,7 @@ export async function checkOrcaSkillFreshness(args?: { args?.referenceRoot === undefined ? resolveBundledSkillsRoot() : args.referenceRoot const expectedHashes = referenceRoot ? await loadExpectedHashes(referenceRoot) : new Map() + // Why: freshness only cares about home installs. Skip repo walks (M4). const discovery = await discoverSkills({ repos: args?.repos ?? [], homeDir: args?.homeDir, @@ -149,8 +149,20 @@ export async function checkOrcaSkillFreshness(args?: { const skills = await Promise.all( ORCA_MANAGED_SKILLS.map(async (definition) => { const expectedHash = expectedHashes.get(normalizeSkillName(definition.skillName)) ?? null - const installed = pickInstalledGlobalSkill(discovery.skills, definition.skillName) - const installedHash = installed ? await hashSkillFile(installed.skillFilePath) : null + const homeInstalls = listInstalledHomeSkills(discovery.skills, definition.skillName) + const hashed = await Promise.all( + homeInstalls.map(async (install) => ({ + path: install.skillFilePath, + hash: await hashSkillFile(install.skillFilePath) + })) + ) + const readable = hashed.filter((entry) => entry.hash !== null) + const diverging = readable.filter( + (entry) => expectedHash !== null && entry.hash !== expectedHash + ) + const primary = diverging[0] ?? + readable[0] ?? { path: homeInstalls[0]?.skillFilePath ?? null, hash: null } + return { skillName: definition.skillName, displayName: definition.displayName, @@ -158,12 +170,14 @@ export async function checkOrcaSkillFreshness(args?: { updateCommand: definition.updateCommand, status: resolveStatus({ expectedHash, - installedHash, - installed: installed !== null + installedCount: homeInstalls.length, + readableCount: readable.length, + divergingCount: diverging.length }), expectedHash, - installedHash, - installedPath: installed?.skillFilePath ?? null + installedHash: primary.hash, + installedPath: primary.path, + divergingPaths: diverging.map((entry) => entry.path) } satisfies SkillFreshnessEntry }) ) diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 8e46b4dd047..d1ff5c0991f 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -683,34 +683,36 @@ function Settings(): React.JSX.Element { return sectionIds }, [skillFreshness.outdatedSkills]) const capabilityInstallStatusBySectionId = useMemo(() => { + // Why: do not OR skillFreshness.loading into known install statuses — + // that regressed Settings rows to "Checking..." on every open (Claude M3/UX). const next = new Map([ [ 'orchestration', getSkillNavInstallStatus({ installed: orchestrationSkillInstalled, - loading: orchestrationSkillLoading || skillFreshness.loading, + loading: orchestrationSkillLoading, outdated: outdatedSectionIds.has('orchestration') }) ] ]) - if (outdatedSectionIds.has('general')) { - next.set('general', 'outdated') - } - if (outdatedSectionIds.has('integrations')) { - next.set('integrations', 'outdated') - } - if (outdatedSectionIds.has('experimental')) { - next.set('experimental', 'outdated') - } - if (outdatedSectionIds.has('mobile-emulator') && showDesktopOnlySettings) { - next.set('mobile-emulator', 'outdated') + // Apply outdated badges generically from freshness section ids. + for (const sectionId of outdatedSectionIds) { + if (sectionId === 'computer-use' || sectionId === 'mobile-emulator') { + if (!showDesktopOnlySettings) { + continue + } + } + if (sectionId === 'orchestration') { + continue + } + next.set(sectionId, 'outdated') } if (showDesktopOnlySettings) { next.set( 'computer-use', getSkillNavInstallStatus({ installed: computerUseSkillInstalled, - loading: computerUseSkillLoading || skillFreshness.loading, + loading: computerUseSkillLoading, outdated: outdatedSectionIds.has('computer-use') }) ) @@ -735,7 +737,6 @@ function Settings(): React.JSX.Element { outdatedSectionIds, settings, showDesktopOnlySettings, - skillFreshness.loading, voiceModelStatesLoading ]) const navSections = useMemo( diff --git a/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx b/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx index 9aab208bc92..8e0294e20a4 100644 --- a/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx +++ b/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx @@ -5,6 +5,7 @@ import { useOrcaSkillFreshness } from '@/hooks/useOrcaSkillFreshness' import { useAppStore } from '@/store' import { dismissOutdatedSkillForHash, + markOutdatedSkillUpdateAttempted, shouldPromptOutdatedSkill, snoozeOutdatedSkillForSession } from './outdated-skill-reminder' @@ -84,6 +85,9 @@ export function OutdatedSkillUpdateHost(): React.JSX.Element | null { repoId: null }) openSettingsPage() + // Why (M1): after the user takes Update, don't re-prompt for this app + // reference hash even if GitHub HEAD still differs from the bundle. + markOutdatedSkillUpdateAttempted(activeSkill.skillName, activeSkill.expectedHash) snoozeOutdatedSkillForSession(activeSkill.skillName) suppressCurrent(activeSkill.skillName) }, [activeSkill, openSettingsPage, openSettingsTarget, suppressCurrent]) diff --git a/src/renderer/src/components/skills/outdated-skill-reminder.test.ts b/src/renderer/src/components/skills/outdated-skill-reminder.test.ts index 6221b488e1d..107f7e12276 100644 --- a/src/renderer/src/components/skills/outdated-skill-reminder.test.ts +++ b/src/renderer/src/components/skills/outdated-skill-reminder.test.ts @@ -4,6 +4,8 @@ import { dismissOutdatedSkillForHash, isOutdatedSkillDismissedForHash, isOutdatedSkillSnoozedForSession, + isOutdatedSkillUpdateAttemptedForHash, + markOutdatedSkillUpdateAttempted, shouldPromptOutdatedSkill, snoozeOutdatedSkillForSession } from './outdated-skill-reminder' @@ -46,7 +48,14 @@ describe('outdated skill reminder', () => { dismissOutdatedSkillForHash('orca-cli', 'abc') expect(isOutdatedSkillDismissedForHash('orca-cli', 'abc')).toBe(true) expect(shouldPromptOutdatedSkill({ skillName: 'orca-cli', expectedHash: 'abc' })).toBe(false) - // Why: a new Orca release changes the reference hash and should re-prompt. + expect(shouldPromptOutdatedSkill({ skillName: 'orca-cli', expectedHash: 'def' })).toBe(true) + }) + + it('stops prompting after an update attempt for the same expected hash (M1)', () => { + markOutdatedSkillUpdateAttempted('orca-cli', 'abc') + expect(isOutdatedSkillUpdateAttemptedForHash('orca-cli', 'abc')).toBe(true) + expect(shouldPromptOutdatedSkill({ skillName: 'orca-cli', expectedHash: 'abc' })).toBe(false) + // New app release with a new expected hash should re-prompt. expect(shouldPromptOutdatedSkill({ skillName: 'orca-cli', expectedHash: 'def' })).toBe(true) }) }) diff --git a/src/renderer/src/components/skills/outdated-skill-reminder.ts b/src/renderer/src/components/skills/outdated-skill-reminder.ts index ea5ffc77a4c..a43bc93d388 100644 --- a/src/renderer/src/components/skills/outdated-skill-reminder.ts +++ b/src/renderer/src/components/skills/outdated-skill-reminder.ts @@ -1,10 +1,15 @@ const DISMISS_STORAGE_PREFIX = 'orca:outdated-skill-dismissed:' +const UPDATE_ATTEMPTED_STORAGE_PREFIX = 'orca:outdated-skill-update-attempted:' const sessionSnoozedSkillNames = new Set() function dismissStorageKey(skillName: string, expectedHash: string): string { return `${DISMISS_STORAGE_PREFIX}${skillName.trim().toLowerCase()}:${expectedHash}` } +function updateAttemptedStorageKey(skillName: string, expectedHash: string): string { + return `${UPDATE_ATTEMPTED_STORAGE_PREFIX}${skillName.trim().toLowerCase()}:${expectedHash}` +} + export function isOutdatedSkillDismissedForHash( skillName: string, expectedHash: string | null @@ -30,6 +35,39 @@ export function dismissOutdatedSkillForHash(skillName: string, expectedHash: str } } +/** + * Why (M1): `npx skills update` pulls GitHub HEAD, not the app-bundled + * reference. After the user attempts an update for this app reference hash, + * stop re-prompting until the next Orca release changes expectedHash. + */ +export function markOutdatedSkillUpdateAttempted( + skillName: string, + expectedHash: string | null +): void { + if (!expectedHash || typeof localStorage === 'undefined') { + return + } + try { + localStorage.setItem(updateAttemptedStorageKey(skillName, expectedHash), '1') + } catch { + // Private mode / quota — session snooze still applies. + } +} + +export function isOutdatedSkillUpdateAttemptedForHash( + skillName: string, + expectedHash: string | null +): boolean { + if (!expectedHash || typeof localStorage === 'undefined') { + return false + } + try { + return localStorage.getItem(updateAttemptedStorageKey(skillName, expectedHash)) === '1' + } catch { + return false + } +} + export function snoozeOutdatedSkillForSession(skillName: string): void { sessionSnoozedSkillNames.add(skillName.trim().toLowerCase()) } @@ -45,7 +83,15 @@ export function shouldPromptOutdatedSkill(entry: { if (isOutdatedSkillSnoozedForSession(entry.skillName)) { return false } - return !isOutdatedSkillDismissedForHash(entry.skillName, entry.expectedHash) + if (isOutdatedSkillDismissedForHash(entry.skillName, entry.expectedHash)) { + return false + } + // Why: break the update→still-mismatch→reprompt loop when GitHub HEAD and + // the app-bundled reference diverge. + if (isOutdatedSkillUpdateAttemptedForHash(entry.skillName, entry.expectedHash)) { + return false + } + return true } export const _outdatedSkillReminderInternalsForTests = { diff --git a/src/renderer/src/hooks/useOrcaSkillFreshness.ts b/src/renderer/src/hooks/useOrcaSkillFreshness.ts index 96aa8a64857..034c9d1f52d 100644 --- a/src/renderer/src/hooks/useOrcaSkillFreshness.ts +++ b/src/renderer/src/hooks/useOrcaSkillFreshness.ts @@ -8,6 +8,9 @@ const FRESHNESS_CHANGED_EVENT = 'orca:skill-freshness-changed' let cachedFreshnessByTarget = new Map() let pendingFreshnessByTarget = new Map>() +// Why (M3): every Settings panel mounts this hook and focus-fires refresh. +// Coalesce forced loads per target so one focus event is one scan. +let forcedFreshnessByTarget = new Map>() export type OrcaSkillFreshnessState = { loading: boolean @@ -43,11 +46,31 @@ export function notifyOrcaSkillFreshnessChanged(): void { } } +async function startFreshnessRequest( + key: string, + target?: SkillDiscoveryTarget +): Promise { + const request = window.api.skills + .checkFreshness(target) + .then((result) => { + cachedFreshnessByTarget.set(key, result) + return result + }) + .finally(() => { + if (pendingFreshnessByTarget.get(key) === request) { + pendingFreshnessByTarget.delete(key) + } + }) + pendingFreshnessByTarget.set(key, request) + return request +} + async function loadSkillFreshness( force: boolean, target?: SkillDiscoveryTarget ): Promise { const key = getSkillDiscoveryTargetKey(target) + if (!force) { const cached = cachedFreshnessByTarget.get(key) if (cached) { @@ -57,9 +80,15 @@ async function loadSkillFreshness( if (inFlight) { return inFlight } - } else { - // Why: wait out any in-flight scan for this target so a forced refresh - // cannot race an older result writing back over a newer one. + return startFreshnessRequest(key, target) + } + + const existingForced = forcedFreshnessByTarget.get(key) + if (existingForced) { + return existingForced + } + + const forced = (async () => { const inFlight = pendingFreshnessByTarget.get(key) if (inFlight) { try { @@ -68,27 +97,22 @@ async function loadSkillFreshness( // Previous failure should not block an explicit re-check. } } - } + return startFreshnessRequest(key, target) + })().finally(() => { + if (forcedFreshnessByTarget.get(key) === forced) { + forcedFreshnessByTarget.delete(key) + } + }) - const request = window.api.skills - .checkFreshness(target) - .then((result) => { - cachedFreshnessByTarget.set(key, result) - return result - }) - .finally(() => { - if (pendingFreshnessByTarget.get(key) === request) { - pendingFreshnessByTarget.delete(key) - } - }) - pendingFreshnessByTarget.set(key, request) - return request + forcedFreshnessByTarget.set(key, forced) + return forced } export const _orcaSkillFreshnessInternalsForTests = { reset(): void { cachedFreshnessByTarget = new Map() pendingFreshnessByTarget = new Map() + forcedFreshnessByTarget = new Map() }, setCached(result: SkillFreshnessResult | null, targetKey = 'host'): void { if (result) { @@ -164,7 +188,6 @@ export function useOrcaSkillFreshness(options?: { if (!enabled) { return } - // Prefer cache for the initial paint when another surface already scanned. void loadSkillFreshness(false, discoveryTarget) .then((next) => { if (mountedRef.current && currentTargetKeyRef.current === discoveryTargetKey) { diff --git a/src/shared/orca-managed-skills.ts b/src/shared/orca-managed-skills.ts index 4818b805d73..d443aa668c9 100644 --- a/src/shared/orca-managed-skills.ts +++ b/src/shared/orca-managed-skills.ts @@ -28,12 +28,10 @@ export type OrcaManagedSkillDefinition = { skillName: string /** Short product label for UI copy. */ displayName: string - /** Settings sidebar section that owns this skill. */ + /** Settings sidebar section that hosts this skill. */ settingsSectionId: OrcaManagedSkillSettingsSectionId /** Global update command (npx skills update …). */ updateCommand: string - /** Alternate directory/frontmatter names that count as the same skill. */ - aliases?: readonly string[] } export const ORCA_EMULATOR_SKILL_NAME = 'orca-emulator' @@ -72,15 +70,13 @@ export const ORCA_MANAGED_SKILLS: readonly OrcaManagedSkillDefinition[] = [ skillName: ORCA_LINEAR_SKILL_NAME, displayName: 'Orca Linear', settingsSectionId: 'integrations', - updateCommand: ORCA_LINEAR_SKILL_UPDATE_COMMAND, - aliases: [LINEAR_TICKETS_SKILL_NAME] + updateCommand: ORCA_LINEAR_SKILL_UPDATE_COMMAND }, { skillName: LINEAR_TICKETS_SKILL_NAME, displayName: 'Linear tickets', settingsSectionId: 'integrations', - updateCommand: LINEAR_TICKETS_SKILL_UPDATE_COMMAND, - aliases: [ORCA_LINEAR_SKILL_NAME] + updateCommand: LINEAR_TICKETS_SKILL_UPDATE_COMMAND }, { skillName: ORCA_EMULATOR_SKILL_NAME, @@ -95,18 +91,3 @@ export const ORCA_MANAGED_SKILLS: readonly OrcaManagedSkillDefinition[] = [ updateCommand: buildAgentFeatureSkillUpdateCommand(ORCA_EMULATOR_ANDROID_SKILL_NAME) } ] - -export function getOrcaManagedSkillDefinition( - skillName: string -): OrcaManagedSkillDefinition | undefined { - const normalized = skillName.trim().toLowerCase() - return ORCA_MANAGED_SKILLS.find( - (skill) => - skill.skillName === normalized || - skill.aliases?.some((alias) => alias.toLowerCase() === normalized) - ) -} - -export function listOrcaManagedSkillNames(): readonly string[] { - return ORCA_MANAGED_SKILLS.map((skill) => skill.skillName) -} diff --git a/src/shared/skill-freshness.ts b/src/shared/skill-freshness.ts index 20892ee069f..6f56d8e03d1 100644 --- a/src/shared/skill-freshness.ts +++ b/src/shared/skill-freshness.ts @@ -10,9 +10,15 @@ export type SkillFreshnessEntry = { status: SkillFreshnessStatus /** sha256 of the app-bundled SKILL.md (expected). */ expectedHash: string | null - /** sha256 of the globally installed SKILL.md when present. */ + /** + * Hash used for prompt identity. When outdated, this is the first diverging + * home install's hash; when current, any matching home install hash. + */ installedHash: string | null + /** First home install path (prefer diverging when outdated). */ installedPath: string | null + /** Every home install path that diverges from the app reference. */ + divergingPaths: string[] } export type SkillFreshnessResult = { From e5be55fbc03af2b87c371947a9e5404c811d2c55 Mon Sep 17 00:00:00 2001 From: Jorge de los Santos Date: Tue, 14 Jul 2026 13:17:33 -0300 Subject: [PATCH 07/13] Refine freshness after Claude iteration-1 review Mark update-attempted only when Settings opens the update terminal (not the card click), skip project/cwd roots on freshness discovery, key the cache by host/WSL runtime only, and drop the unused freshness-changed notifier. --- src/main/skills/discovery.ts | 1 + src/main/skills/freshness.ts | 6 +-- src/main/skills/skill-discovery-sources.ts | 9 ++++- .../src/components/settings/CliSection.tsx | 40 ++++++------------- .../settings/ComputerUseSkillSetupPanel.tsx | 8 +++- .../components/settings/EphemeralVmsPane.tsx | 8 +++- .../settings/OrchestrationSetupCard.tsx | 8 +++- .../settings/cli-section-platform-copy.ts | 26 ++++++++++++ .../skills/OutdatedSkillUpdateHost.tsx | 10 +++-- .../mark-outdated-skill-update-attempt.ts | 13 ++++++ .../src/hooks/useOrcaSkillFreshness.ts | 30 +++++++------- 11 files changed, 105 insertions(+), 54 deletions(-) create mode 100644 src/renderer/src/components/settings/cli-section-platform-copy.ts create mode 100644 src/renderer/src/components/skills/mark-outdated-skill-update-attempt.ts diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts index d84f82af426..4fc08b80cf4 100644 --- a/src/main/skills/discovery.ts +++ b/src/main/skills/discovery.ts @@ -234,6 +234,7 @@ export async function discoverSkills(args: { repos?: Repo[] homeDir?: string cwd?: string + includeProjectRoots?: boolean }): Promise { const roots = buildSkillDiscoverySources(args) const sources: SkillDiscoverySource[] = [] diff --git a/src/main/skills/freshness.ts b/src/main/skills/freshness.ts index 690646b5ddc..46619fc840f 100644 --- a/src/main/skills/freshness.ts +++ b/src/main/skills/freshness.ts @@ -139,11 +139,11 @@ export async function checkOrcaSkillFreshness(args?: { args?.referenceRoot === undefined ? resolveBundledSkillsRoot() : args.referenceRoot const expectedHashes = referenceRoot ? await loadExpectedHashes(referenceRoot) : new Map() - // Why: freshness only cares about home installs. Skip repo walks (M4). + // Why: freshness only cares about home installs. Skip repo/cwd walks. const discovery = await discoverSkills({ - repos: args?.repos ?? [], + repos: [], homeDir: args?.homeDir, - cwd: args?.cwd + includeProjectRoots: false }) const skills = await Promise.all( diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index bec34117c4c..a8103a07cad 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -25,10 +25,12 @@ export function buildSkillDiscoverySources( homeDir?: string cwd?: string repos?: Repo[] + /** When false, skip repo/cwd project roots (used by global skill freshness). */ + includeProjectRoots?: boolean } = {} ): SkillScanRoot[] { const home = args.homeDir ?? homedir() - const cwd = args.cwd ?? process.cwd() + const includeProjectRoots = args.includeProjectRoots !== false const roots: SkillScanRoot[] = [ source('home-codex', 'Codex home', join(home, '.codex', 'skills'), 'home', ['codex']), source('home-agents', 'Agent skills home', join(home, '.agents', 'skills'), 'home', [ @@ -60,6 +62,11 @@ export function buildSkillDiscoverySources( source('home-cursor', 'Cursor home', join(home, '.cursor', 'skills'), 'home', ['agent-skills']) ] + if (!includeProjectRoots) { + return roots + } + + const cwd = args.cwd ?? process.cwd() const projectPaths = new Set() for (const repo of args.repos ?? []) { if (repo.connectionId) { diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index 5f30c85d049..25df802c97d 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -18,6 +18,7 @@ import { useInstalledAgentSkill } from '@/hooks/useInstalledAgentSkills' import { useOrcaSkillFreshness } from '@/hooks/useOrcaSkillFreshness' +import { markOutdatedSkillUpdateAttemptIfNeeded } from '@/components/skills/mark-outdated-skill-update-attempt' import { useMountedRef } from '@/hooks/useMountedRef' import { Button } from '../ui/button' import { Label } from '../ui/label' @@ -33,6 +34,11 @@ import { getWslCliDistroRequest } from './CliSkillRuntimeSetup' import { WslCliRegistration } from './WslCliRegistration' +import { + getFallbackCommandName, + getInstallDescription, + getRevealLabel +} from './cli-section-platform-copy' import { translate } from '@/i18n/i18n' type CliSectionProps = { @@ -43,33 +49,6 @@ type CliSectionProps = { wslCapabilitiesLoading?: boolean } -function getRevealLabel(platform: string): string { - if (platform === 'darwin') { - return 'Show in Finder' - } - if (platform === 'win32') { - return 'Show in Explorer' - } - return 'Show in File Manager' -} - -function getInstallDescription(platform: string): string { - if (platform === 'darwin') { - return 'Register `orca` in /usr/local/bin.' - } - if (platform === 'linux') { - return 'Register `orca-ide` in ~/.local/bin.' - } - if (platform === 'win32') { - return 'Register `orca` in your user PATH.' - } - return 'CLI registration is not yet available on this platform.' -} - -function getFallbackCommandName(platform: string): string { - return platform === 'linux' ? 'orca-ide' : 'orca' -} - export function CliSection({ currentPlatform, settings, @@ -100,7 +79,7 @@ export function CliSection({ discoveryTarget: cliSkillDiscoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) - const { isSkillOutdated } = useOrcaSkillFreshness({ + const { isSkillOutdated, getSkillEntry } = useOrcaSkillFreshness({ discoveryTarget: cliSkillDiscoveryTarget }) const cliSkillInstallCommand = buildSkillCommandForRuntime( @@ -388,6 +367,11 @@ export function CliSection({ getPrerequisiteStatus={getCliSkillPrerequisiteStatus} isPrerequisiteAvailable={isOrcaCliAvailableOnPath} onBeforeOpenTerminal={async () => { + markOutdatedSkillUpdateAttemptIfNeeded( + ORCA_CLI_SKILL_NAME, + isSkillOutdated(ORCA_CLI_SKILL_NAME), + getSkillEntry(ORCA_CLI_SKILL_NAME)?.expectedHash + ) await (agentRuntime.runtime === 'wsl' ? ensureWslCliAvailableForAgentSkillTerminal(agentRuntime) : ensureOrcaCliAvailableForAgentSkillTerminal({ diff --git a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx index 3fdd32ca9cf..4d0efa483be 100644 --- a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx @@ -14,6 +14,7 @@ import { } from '@/hooks/useInstalledAgentSkills' import { useOrcaSkillFreshness } from '@/hooks/useOrcaSkillFreshness' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' +import { markOutdatedSkillUpdateAttemptIfNeeded } from '@/components/skills/mark-outdated-skill-update-attempt' import { useAppStore } from '@/store' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' import { @@ -46,7 +47,7 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element { discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) - const { isSkillOutdated } = useOrcaSkillFreshness({ + const { isSkillOutdated, getSkillEntry } = useOrcaSkillFreshness({ discoveryTarget: activeSkillRuntime.discoveryTarget }) @@ -78,6 +79,11 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element { : window.api.cli.getInstallStatus() } onBeforeOpenTerminal={async () => { + markOutdatedSkillUpdateAttemptIfNeeded( + COMPUTER_USE_SKILL_NAME, + isSkillOutdated(COMPUTER_USE_SKILL_NAME), + getSkillEntry(COMPUTER_USE_SKILL_NAME)?.expectedHash + ) useAppStore.getState().recordFeatureInteraction('computer-use-setup') await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) diff --git a/src/renderer/src/components/settings/EphemeralVmsPane.tsx b/src/renderer/src/components/settings/EphemeralVmsPane.tsx index dbbf15b96a5..6175e3eb2eb 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.tsx @@ -23,6 +23,7 @@ import { } from '@/hooks/useInstalledAgentSkills' import { useOrcaSkillFreshness } from '@/hooks/useOrcaSkillFreshness' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' +import { markOutdatedSkillUpdateAttemptIfNeeded } from '@/components/skills/mark-outdated-skill-update-attempt' import { buildSkillCommandForRuntime, ensureWslCliAvailableForAgentSkillTerminal, @@ -70,7 +71,7 @@ export function EphemeralVmsPane(): React.JSX.Element { discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) - const { isSkillOutdated } = useOrcaSkillFreshness({ + const { isSkillOutdated, getSkillEntry } = useOrcaSkillFreshness({ discoveryTarget: activeSkillRuntime.discoveryTarget }) @@ -163,6 +164,11 @@ export function EphemeralVmsPane(): React.JSX.Element { : window.api.cli.getInstallStatus() } onBeforeOpenTerminal={async () => { + markOutdatedSkillUpdateAttemptIfNeeded( + EPHEMERAL_VMS_SKILL_NAME, + isSkillOutdated(EPHEMERAL_VMS_SKILL_NAME), + getSkillEntry(EPHEMERAL_VMS_SKILL_NAME)?.expectedHash + ) await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) : ensureOrcaCliAvailableForAgentSkillTerminal()) diff --git a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx index cf08dd2e67f..818bd6a6f10 100644 --- a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx +++ b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx @@ -11,6 +11,7 @@ import type { InstalledAgentSkillState } from '@/hooks/useInstalledAgentSkills' import { useOrcaSkillFreshness } from '@/hooks/useOrcaSkillFreshness' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands' +import { markOutdatedSkillUpdateAttemptIfNeeded } from '@/components/skills/mark-outdated-skill-update-attempt' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' import { buildSkillCommandForRuntime, @@ -27,7 +28,7 @@ export function OrchestrationSetupCard(props: { }): JSX.Element { const { compact, terminalHeightPx, skill } = props const activeSkillRuntime = useActiveProjectSkillRuntime() - const { isSkillOutdated } = useOrcaSkillFreshness({ + const { isSkillOutdated, getSkillEntry } = useOrcaSkillFreshness({ discoveryTarget: activeSkillRuntime.discoveryTarget }) const installCommand = !activeSkillRuntime.installDisabledReason @@ -75,6 +76,11 @@ export function OrchestrationSetupCard(props: { : window.api.cli.getInstallStatus() } onBeforeOpenTerminal={async () => { + markOutdatedSkillUpdateAttemptIfNeeded( + ORCHESTRATION_SKILL_NAME, + isSkillOutdated(ORCHESTRATION_SKILL_NAME), + getSkillEntry(ORCHESTRATION_SKILL_NAME)?.expectedHash + ) useAppStore.getState().recordFeatureInteraction('agent-orchestration-setup') await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) diff --git a/src/renderer/src/components/settings/cli-section-platform-copy.ts b/src/renderer/src/components/settings/cli-section-platform-copy.ts new file mode 100644 index 00000000000..928170f01dd --- /dev/null +++ b/src/renderer/src/components/settings/cli-section-platform-copy.ts @@ -0,0 +1,26 @@ +export function getRevealLabel(platform: string): string { + if (platform === 'darwin') { + return 'Show in Finder' + } + if (platform === 'win32') { + return 'Show in Explorer' + } + return 'Show in File Manager' +} + +export function getInstallDescription(platform: string): string { + if (platform === 'darwin') { + return 'Register `orca` in /usr/local/bin.' + } + if (platform === 'linux') { + return 'Register `orca-ide` in ~/.local/bin.' + } + if (platform === 'win32') { + return 'Register `orca` in your user PATH.' + } + return 'CLI registration is not yet available on this platform.' +} + +export function getFallbackCommandName(platform: string): string { + return platform === 'linux' ? 'orca-ide' : 'orca' +} diff --git a/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx b/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx index 8e0294e20a4..6812e97addf 100644 --- a/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx +++ b/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx @@ -5,7 +5,6 @@ import { useOrcaSkillFreshness } from '@/hooks/useOrcaSkillFreshness' import { useAppStore } from '@/store' import { dismissOutdatedSkillForHash, - markOutdatedSkillUpdateAttempted, shouldPromptOutdatedSkill, snoozeOutdatedSkillForSession } from './outdated-skill-reminder' @@ -15,6 +14,10 @@ import { OutdatedSkillUpdateDialog } from './OutdatedSkillUpdateDialog' * Queues one outdated-skill card at a time after app open. Skills that the * user snoozed this session or dismissed for the current expected hash are * skipped so only remaining outdated packages surface. + * + * Why host-only discovery: Settings panels may use a WSL project runtime for + * install status; the floating card always reflects the local host so it never + * navigates desktop users into a WSL-only false positive. */ export function OutdatedSkillUpdateHost(): React.JSX.Element | null { const { outdatedSkills, loading } = useOrcaSkillFreshness() @@ -85,9 +88,8 @@ export function OutdatedSkillUpdateHost(): React.JSX.Element | null { repoId: null }) openSettingsPage() - // Why (M1): after the user takes Update, don't re-prompt for this app - // reference hash even if GitHub HEAD still differs from the bundle. - markOutdatedSkillUpdateAttempted(activeSkill.skillName, activeSkill.expectedHash) + // Why: only session-snooze here. Permanent "update attempted" is recorded + // when the Settings panel actually opens the update terminal command. snoozeOutdatedSkillForSession(activeSkill.skillName) suppressCurrent(activeSkill.skillName) }, [activeSkill, openSettingsPage, openSettingsTarget, suppressCurrent]) diff --git a/src/renderer/src/components/skills/mark-outdated-skill-update-attempt.ts b/src/renderer/src/components/skills/mark-outdated-skill-update-attempt.ts new file mode 100644 index 00000000000..32a7dcb251b --- /dev/null +++ b/src/renderer/src/components/skills/mark-outdated-skill-update-attempt.ts @@ -0,0 +1,13 @@ +import { markOutdatedSkillUpdateAttempted } from './outdated-skill-reminder' + +/** Record an update attempt when the setup panel opens an update terminal. */ +export function markOutdatedSkillUpdateAttemptIfNeeded( + skillName: string, + outdated: boolean, + expectedHash: string | null | undefined +): void { + if (!outdated) { + return + } + markOutdatedSkillUpdateAttempted(skillName, expectedHash ?? null) +} diff --git a/src/renderer/src/hooks/useOrcaSkillFreshness.ts b/src/renderer/src/hooks/useOrcaSkillFreshness.ts index 034c9d1f52d..c2370008db2 100644 --- a/src/renderer/src/hooks/useOrcaSkillFreshness.ts +++ b/src/renderer/src/hooks/useOrcaSkillFreshness.ts @@ -4,7 +4,6 @@ import type { SkillFreshnessEntry, SkillFreshnessResult } from '../../../shared/ import { useMountedRef } from './useMountedRef' const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed' -const FRESHNESS_CHANGED_EVENT = 'orca:skill-freshness-changed' let cachedFreshnessByTarget = new Map() let pendingFreshnessByTarget = new Map>() @@ -26,24 +25,27 @@ function normalizeSkillName(value: string): string { return value.trim().toLowerCase() } +/** + * Why: backend freshness ignores cwd and only scans home roots. Key by WSL vs + * host runtime only so project switches do not duplicate identical scans. + */ function getSkillDiscoveryTargetKey(target: SkillDiscoveryTarget | undefined): string { if (target?.projectRuntime) { - return target.projectRuntime.status === 'resolved' - ? target.projectRuntime.runtime.cacheKey - : target.projectRuntime.repair.cacheKey + if ( + target.projectRuntime.status === 'resolved' && + target.projectRuntime.runtime.kind === 'wsl' + ) { + return `wsl:${target.projectRuntime.runtime.distro}` + } + if (target.projectRuntime.status === 'repair-required') { + return `repair:${target.projectRuntime.repair.cacheKey}` + } + return 'host' } if (target?.runtime === 'wsl') { return `wsl:${target.wslDistro?.trim() ?? ''}` } - const cwd = target?.cwd?.trim() - return cwd ? `host:cwd:${cwd}` : 'host' -} - -export function notifyOrcaSkillFreshnessChanged(): void { - cachedFreshnessByTarget.clear() - if (typeof window !== 'undefined') { - window.dispatchEvent(new CustomEvent(FRESHNESS_CHANGED_EVENT)) - } + return 'host' } async function startFreshnessRequest( @@ -217,11 +219,9 @@ export function useOrcaSkillFreshness(options?: { } window.addEventListener('focus', onChange) window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onChange) - window.addEventListener(FRESHNESS_CHANGED_EVENT, onChange) return () => { window.removeEventListener('focus', onChange) window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onChange) - window.removeEventListener(FRESHNESS_CHANGED_EVENT, onChange) } }, [enabled, refresh]) From b5df8d531fc2b275037ed89db18ebb120fc97c1e Mon Sep 17 00:00:00 2001 From: Jorge de los Santos Date: Tue, 14 Jul 2026 13:24:04 -0300 Subject: [PATCH 08/13] Address Claude iteration-2 findings for update-attempt coverage Record update-attempted from the card Update action for all managed skills, mark Settings terminals only after prerequisites succeed, and skip freshness IPC when project runtime requires repair. --- .../src/components/settings/CliSection.tsx | 12 +++++++----- .../settings/ComputerUseSkillSetupPanel.tsx | 8 ++++---- .../components/settings/EphemeralVmsPane.tsx | 6 +++--- .../settings/OrchestrationSetupCard.tsx | 8 ++++---- .../skills/OutdatedSkillUpdateHost.tsx | 6 ++++-- .../src/hooks/useOrcaSkillFreshness.ts | 19 +++++++++++++++++-- 6 files changed, 39 insertions(+), 20 deletions(-) diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index 25df802c97d..ba064f7a321 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -367,16 +367,18 @@ export function CliSection({ getPrerequisiteStatus={getCliSkillPrerequisiteStatus} isPrerequisiteAvailable={isOrcaCliAvailableOnPath} onBeforeOpenTerminal={async () => { - markOutdatedSkillUpdateAttemptIfNeeded( - ORCA_CLI_SKILL_NAME, - isSkillOutdated(ORCA_CLI_SKILL_NAME), - getSkillEntry(ORCA_CLI_SKILL_NAME)?.expectedHash - ) await (agentRuntime.runtime === 'wsl' ? ensureWslCliAvailableForAgentSkillTerminal(agentRuntime) : ensureOrcaCliAvailableForAgentSkillTerminal({ onStatusChange: handleStatusChange })) + // Why: mark only after prereqs succeed so a failed ensure does + // not suppress prompts for the whole app release. + markOutdatedSkillUpdateAttemptIfNeeded( + ORCA_CLI_SKILL_NAME, + isSkillOutdated(ORCA_CLI_SKILL_NAME), + getSkillEntry(ORCA_CLI_SKILL_NAME)?.expectedHash + ) }} onRecheck={refreshCliSkill} /> diff --git a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx index 4d0efa483be..a5bcbe8d3c0 100644 --- a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx @@ -79,15 +79,15 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element { : window.api.cli.getInstallStatus() } onBeforeOpenTerminal={async () => { + useAppStore.getState().recordFeatureInteraction('computer-use-setup') + await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' + ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) + : ensureOrcaCliAvailableForAgentSkillTerminal()) markOutdatedSkillUpdateAttemptIfNeeded( COMPUTER_USE_SKILL_NAME, isSkillOutdated(COMPUTER_USE_SKILL_NAME), getSkillEntry(COMPUTER_USE_SKILL_NAME)?.expectedHash ) - useAppStore.getState().recordFeatureInteraction('computer-use-setup') - await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' - ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) - : ensureOrcaCliAvailableForAgentSkillTerminal()) }} onRecheck={refreshComputerUseSkill} /> diff --git a/src/renderer/src/components/settings/EphemeralVmsPane.tsx b/src/renderer/src/components/settings/EphemeralVmsPane.tsx index 6175e3eb2eb..dcfd086fe92 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.tsx @@ -164,14 +164,14 @@ export function EphemeralVmsPane(): React.JSX.Element { : window.api.cli.getInstallStatus() } onBeforeOpenTerminal={async () => { + await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' + ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) + : ensureOrcaCliAvailableForAgentSkillTerminal()) markOutdatedSkillUpdateAttemptIfNeeded( EPHEMERAL_VMS_SKILL_NAME, isSkillOutdated(EPHEMERAL_VMS_SKILL_NAME), getSkillEntry(EPHEMERAL_VMS_SKILL_NAME)?.expectedHash ) - await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' - ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) - : ensureOrcaCliAvailableForAgentSkillTerminal()) }} onRecheck={refreshSkill} /> diff --git a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx index 818bd6a6f10..bc0d4d95901 100644 --- a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx +++ b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx @@ -76,15 +76,15 @@ export function OrchestrationSetupCard(props: { : window.api.cli.getInstallStatus() } onBeforeOpenTerminal={async () => { + useAppStore.getState().recordFeatureInteraction('agent-orchestration-setup') + await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' + ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) + : ensureOrcaCliAvailableForAgentSkillTerminal()) markOutdatedSkillUpdateAttemptIfNeeded( ORCHESTRATION_SKILL_NAME, isSkillOutdated(ORCHESTRATION_SKILL_NAME), getSkillEntry(ORCHESTRATION_SKILL_NAME)?.expectedHash ) - useAppStore.getState().recordFeatureInteraction('agent-orchestration-setup') - await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' - ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) - : ensureOrcaCliAvailableForAgentSkillTerminal()) }} onRecheck={skill.refresh} /> diff --git a/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx b/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx index 6812e97addf..5478ff67668 100644 --- a/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx +++ b/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx @@ -5,6 +5,7 @@ import { useOrcaSkillFreshness } from '@/hooks/useOrcaSkillFreshness' import { useAppStore } from '@/store' import { dismissOutdatedSkillForHash, + markOutdatedSkillUpdateAttempted, shouldPromptOutdatedSkill, snoozeOutdatedSkillForSession } from './outdated-skill-reminder' @@ -88,8 +89,9 @@ export function OutdatedSkillUpdateHost(): React.JSX.Element | null { repoId: null }) openSettingsPage() - // Why: only session-snooze here. Permanent "update attempted" is recorded - // when the Settings panel actually opens the update terminal command. + // Why: record for every managed skill (including Linear/emulator surfaces + // that only copy a command). Terminal panels also mark after prereqs pass. + markOutdatedSkillUpdateAttempted(activeSkill.skillName, activeSkill.expectedHash) snoozeOutdatedSkillForSession(activeSkill.skillName) suppressCurrent(activeSkill.skillName) }, [activeSkill, openSettingsPage, openSettingsTarget, suppressCurrent]) diff --git a/src/renderer/src/hooks/useOrcaSkillFreshness.ts b/src/renderer/src/hooks/useOrcaSkillFreshness.ts index c2370008db2..f427a5a316c 100644 --- a/src/renderer/src/hooks/useOrcaSkillFreshness.ts +++ b/src/renderer/src/hooks/useOrcaSkillFreshness.ts @@ -48,6 +48,10 @@ function getSkillDiscoveryTargetKey(target: SkillDiscoveryTarget | undefined): s return 'host' } +function isRepairRequiredTarget(target: SkillDiscoveryTarget | undefined): boolean { + return target?.projectRuntime?.status === 'repair-required' +} + async function startFreshnessRequest( key: string, target?: SkillDiscoveryTarget @@ -144,9 +148,13 @@ export function useOrcaSkillFreshness(options?: { const refresh = useCallback(async (): Promise => { const generation = ++generationRef.current const requestTargetKey = discoveryTargetKey - if (!enabled) { + if (!enabled || isRepairRequiredTarget(discoveryTarget)) { if (mountedRef.current) { setLoading(false) + if (isRepairRequiredTarget(discoveryTarget)) { + setResult(null) + setError(null) + } } return null } @@ -187,7 +195,14 @@ export function useOrcaSkillFreshness(options?: { }, [discoveryTarget, discoveryTargetKey, enabled, mountedRef]) useEffect(() => { - if (!enabled) { + if (!enabled || isRepairRequiredTarget(discoveryTarget)) { + if (mountedRef.current) { + setLoading(false) + if (isRepairRequiredTarget(discoveryTarget)) { + setResult(null) + setError(null) + } + } return } void loadSkillFreshness(false, discoveryTarget) From 954c4b000450b7ed96da89e7b5c2e04045652294 Mon Sep 17 00:00:00 2001 From: Jorge de los Santos Date: Tue, 14 Jul 2026 13:32:43 -0300 Subject: [PATCH 09/13] Clear stale skill-freshness state when discovery target changes Reset hook result/loading from the new target's cache on host/WSL switches so panels never flash the previous runtime's outdated/installed badges. Also use useId and role=region for the floating prompt a11y nits. --- .../skills/OutdatedSkillUpdateDialog.tsx | 5 ++-- .../src/hooks/useOrcaSkillFreshness.ts | 24 ++++++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx b/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx index b0bc056a3b4..fb63d9ca78e 100644 --- a/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx +++ b/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx @@ -1,3 +1,4 @@ +import { useId } from 'react' import { X } from 'lucide-react' import { toast } from 'sonner' import type { SkillFreshnessEntry } from '../../../../shared/skill-freshness' @@ -20,7 +21,7 @@ export function OutdatedSkillUpdateDialog(props: { onUpdate: () => void }): React.JSX.Element { const { skill, onDismiss, onUpdate } = props - const headingId = `outdated-skill-update-heading-${skill.skillName}` + const headingId = useId() const updateStatus = useAppStore((s) => s.updateStatus) // Why: UpdateCard owns bottom-10; raise this card so both remain readable. const updateCardVisible = updateStatus.state !== 'idle' && updateStatus.state !== 'not-available' @@ -52,7 +53,7 @@ export function OutdatedSkillUpdateDialog(props: { updateCardVisible ? 'bottom-[220px]' : 'bottom-10' }`} > - +

diff --git a/src/renderer/src/hooks/useOrcaSkillFreshness.ts b/src/renderer/src/hooks/useOrcaSkillFreshness.ts index f427a5a316c..9d7cc3d80c5 100644 --- a/src/renderer/src/hooks/useOrcaSkillFreshness.ts +++ b/src/renderer/src/hooks/useOrcaSkillFreshness.ts @@ -143,7 +143,29 @@ export function useOrcaSkillFreshness(options?: { const [error, setError] = useState(null) const generationRef = useRef(0) const currentTargetKeyRef = useRef(discoveryTargetKey) - currentTargetKeyRef.current = discoveryTargetKey + const stateResetInputRef = useRef({ discoveryTargetKey, enabled }) + // Why: host→WSL (or project runtime) switches must not keep the previous + // target's result while the new scan is in flight (CodeRabbit). + if ( + stateResetInputRef.current.discoveryTargetKey !== discoveryTargetKey || + stateResetInputRef.current.enabled !== enabled + ) { + const nextCached = cachedFreshnessByTarget.get(discoveryTargetKey) ?? null + const nextLoading = enabled && !isRepairRequiredTarget(discoveryTarget) && !nextCached + stateResetInputRef.current = { discoveryTargetKey, enabled } + currentTargetKeyRef.current = discoveryTargetKey + if (result !== nextCached) { + setResult(nextCached) + } + if (loading !== nextLoading) { + setLoading(nextLoading) + } + if (error !== null) { + setError(null) + } + } else { + currentTargetKeyRef.current = discoveryTargetKey + } const refresh = useCallback(async (): Promise => { const generation = ++generationRef.current From bf50f4284314c27652ca56d25daf89553deecf9c Mon Sep 17 00:00:00 2001 From: Jorge de los Santos Date: Tue, 14 Jul 2026 13:54:28 -0300 Subject: [PATCH 10/13] Align badges with update-attempt suppression and harden web freshness Apply dual-review consensus: Settings/pills honor dismiss+attempt flags, web checkFreshness propagates errors instead of caching empty success, match installs by directory basename only, and keep card Update as session-snooze only while terminals record the persistent attempt. --- src/main/ipc/skills.test.ts | 2 +- src/main/ipc/skills.ts | 5 ++--- src/main/runtime/rpc/methods/skills.ts | 5 +++-- src/main/skills/freshness.test.ts | 6 ------ src/main/skills/freshness.ts | 11 +++-------- src/renderer/src/components/settings/Settings.tsx | 2 ++ .../components/skills/OutdatedSkillUpdateHost.tsx | 13 +++++-------- src/renderer/src/hooks/useOrcaSkillFreshness.ts | 3 ++- src/renderer/src/web/web-preload-api.ts | 12 ++++-------- 9 files changed, 22 insertions(+), 37 deletions(-) diff --git a/src/main/ipc/skills.test.ts b/src/main/ipc/skills.test.ts index c6b8a78b43a..8b348dbcf7e 100644 --- a/src/main/ipc/skills.test.ts +++ b/src/main/ipc/skills.test.ts @@ -90,7 +90,7 @@ describe('registerSkillsHandlers', () => { it('registers a host freshness check without walking project repos', async () => { const handler = getFreshnessHandler() await handler(null, undefined) - expect(checkFreshnessMock).toHaveBeenCalledWith({ homeDir: undefined, repos: [] }) + expect(checkFreshnessMock).toHaveBeenCalledWith({ homeDir: undefined }) }) it('uses host skill discovery when resolved project runtime overrides stale WSL target state', async () => { diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index 10980002c72..f22fbe5fddd 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -69,10 +69,9 @@ export function registerSkillsHandlers(store: Store): void { 'skills:checkFreshness', async (_event, target?: SkillDiscoveryTarget): Promise => { const context = await resolveScanContext(target) - // Why: freshness only hashes home installs — skip repo trees entirely (M4). + // Why: freshness only hashes home installs — skip repo trees entirely. return checkOrcaSkillFreshness({ - homeDir: context.homeDir, - repos: [] + homeDir: context.homeDir }) } ) diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index dc1265871d8..bef23a0e996 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -21,7 +21,8 @@ export const SKILL_METHODS: RpcMethod[] = [ defineMethod({ name: 'skills.checkFreshness', params: SkillDiscoveryParams, - // Why: freshness only needs home skill roots; never walk project repos. - handler: async () => checkOrcaSkillFreshness({ repos: [] }) + // Why: web/SSH paired clients hash the daemon host home only. WSL home + // resolution stays on the desktop IPC path (Windows + wsl.exe). + handler: async () => checkOrcaSkillFreshness({}) }) ] diff --git a/src/main/skills/freshness.test.ts b/src/main/skills/freshness.test.ts index 80298aaa71e..178dda481ef 100644 --- a/src/main/skills/freshness.test.ts +++ b/src/main/skills/freshness.test.ts @@ -50,7 +50,6 @@ describe('checkOrcaSkillFreshness', () => { ) const result = await checkOrcaSkillFreshness({ - repos: [], homeDir, referenceRoot }) @@ -72,7 +71,6 @@ describe('checkOrcaSkillFreshness', () => { await writeSkill(join(homeDir, '.agents', 'skills'), 'orchestration', content) const result = await checkOrcaSkillFreshness({ - repos: [], homeDir, referenceRoot }) @@ -89,7 +87,6 @@ describe('checkOrcaSkillFreshness', () => { await mkdir(join(homeDir, '.agents', 'skills'), { recursive: true }) const result = await checkOrcaSkillFreshness({ - repos: [], homeDir, referenceRoot }) @@ -107,7 +104,6 @@ describe('checkOrcaSkillFreshness', () => { await writeFile(skillFilePath, 'x'.repeat(300 * 1024), 'utf8') const result = await checkOrcaSkillFreshness({ - repos: [], homeDir, referenceRoot }) @@ -131,7 +127,6 @@ describe('checkOrcaSkillFreshness', () => { ) const result = await checkOrcaSkillFreshness({ - repos: [], homeDir, cwd: repoDir, referenceRoot @@ -156,7 +151,6 @@ describe('checkOrcaSkillFreshness', () => { ) const result = await checkOrcaSkillFreshness({ - repos: [], homeDir, referenceRoot }) diff --git a/src/main/skills/freshness.ts b/src/main/skills/freshness.ts index 46619fc840f..a77260d527c 100644 --- a/src/main/skills/freshness.ts +++ b/src/main/skills/freshness.ts @@ -10,7 +10,6 @@ import type { import { discoverSkills } from './discovery' import { resolveBundledSkillsRoot } from './bundled-skills-root' import type { DiscoveredSkill } from '../../shared/skills' -import type { Repo } from '../../shared/types' const SKILL_FILE_NAME = 'SKILL.md' const MAX_SKILL_MARKDOWN_BYTES = 256 * 1024 @@ -67,11 +66,9 @@ async function hashSkillFile(skillFilePath: string): Promise { } function skillMatchesManagedName(skill: DiscoveredSkill, skillName: string): boolean { - const expected = normalizeSkillName(skillName) - return ( - normalizeSkillName(skill.name) === expected || - normalizeSkillName(basename(skill.directoryPath)) === expected - ) + // Why: match install identity (directory basename) only — frontmatter name + // collisions with user forks would otherwise drive overwrite prompts. + return normalizeSkillName(basename(skill.directoryPath)) === normalizeSkillName(skillName) } function listInstalledHomeSkills( @@ -130,9 +127,7 @@ function resolveStatus(args: { } export async function checkOrcaSkillFreshness(args?: { - repos?: Repo[] homeDir?: string - cwd?: string referenceRoot?: string | null }): Promise { const referenceRoot = diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index d1ff5c0991f..19bd5c02a06 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -675,6 +675,8 @@ function Settings(): React.JSX.Element { orchestrationSkill const { installed: computerUseSkillInstalled, loading: computerUseSkillLoading } = computerUseSkill + // Why: outdatedSkills already applies dismiss/attempt suppression so badges + // match the floating card (no permanent amber after a legitimate update). const outdatedSectionIds = useMemo(() => { const sectionIds = new Set() for (const skill of skillFreshness.outdatedSkills) { diff --git a/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx b/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx index 5478ff67668..0596f19f812 100644 --- a/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx +++ b/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx @@ -5,7 +5,6 @@ import { useOrcaSkillFreshness } from '@/hooks/useOrcaSkillFreshness' import { useAppStore } from '@/store' import { dismissOutdatedSkillForHash, - markOutdatedSkillUpdateAttempted, shouldPromptOutdatedSkill, snoozeOutdatedSkillForSession } from './outdated-skill-reminder' @@ -70,12 +69,11 @@ export function OutdatedSkillUpdateHost(): React.JSX.Element | null { if (!activeSkill) { return } - // Why: X matches UpdateCard dismiss — hide this expected hash until the - // next Orca release changes the reference skill content. + // Why: always session-snooze; also persist dismiss when we have a hash. + // If localStorage fails, snooze still covers the session (CodeRabbit). + snoozeOutdatedSkillForSession(activeSkill.skillName) if (activeSkill.expectedHash) { dismissOutdatedSkillForHash(activeSkill.skillName, activeSkill.expectedHash) - } else { - snoozeOutdatedSkillForSession(activeSkill.skillName) } suppressCurrent(activeSkill.skillName) }, [activeSkill, suppressCurrent]) @@ -89,9 +87,8 @@ export function OutdatedSkillUpdateHost(): React.JSX.Element | null { repoId: null }) openSettingsPage() - // Why: record for every managed skill (including Linear/emulator surfaces - // that only copy a command). Terminal panels also mark after prereqs pass. - markOutdatedSkillUpdateAttempted(activeSkill.skillName, activeSkill.expectedHash) + // Why: session-snooze on navigate only. Persistent update-attempt is recorded + // when a setup panel actually opens the update terminal after prereqs. snoozeOutdatedSkillForSession(activeSkill.skillName) suppressCurrent(activeSkill.skillName) }, [activeSkill, openSettingsPage, openSettingsTarget, suppressCurrent]) diff --git a/src/renderer/src/hooks/useOrcaSkillFreshness.ts b/src/renderer/src/hooks/useOrcaSkillFreshness.ts index 9d7cc3d80c5..412ed14291f 100644 --- a/src/renderer/src/hooks/useOrcaSkillFreshness.ts +++ b/src/renderer/src/hooks/useOrcaSkillFreshness.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { SkillDiscoveryTarget } from '../../../shared/skills' import type { SkillFreshnessEntry, SkillFreshnessResult } from '../../../shared/skill-freshness' +import { shouldPromptOutdatedSkill } from '@/components/skills/outdated-skill-reminder' import { useMountedRef } from './useMountedRef' const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed' @@ -265,7 +266,7 @@ export function useOrcaSkillFreshness(options?: { const skills = useMemo(() => (enabled && result ? result.skills : []), [enabled, result]) const outdatedSkills = useMemo( - () => skills.filter((skill) => skill.status === 'outdated'), + () => skills.filter((skill) => skill.status === 'outdated' && shouldPromptOutdatedSkill(skill)), [skills] ) diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 53565f751bb..b6f988e327a 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2595,14 +2595,10 @@ function createSkillsApi(): NonNullable['skills']> { sources: [], scannedAt: Date.now() })), - checkFreshness: (target) => - callRuntimeResult('skills.checkFreshness', target, 15_000).catch( - () => ({ - skills: [], - scannedAt: Date.now(), - referenceRoot: null - }) - ) + // Why: daemon freshness is host-home only; do not key WSL targets against + // host results. Propagate errors so the hook does not cache empty success. + checkFreshness: () => + callRuntimeResult('skills.checkFreshness', {}, 15_000) } } From 1f6b375b1da08bec9529af3d89590af53c94c8a7 Mon Sep 17 00:00:00 2001 From: Jorge de los Santos Date: Tue, 14 Jul 2026 14:26:56 -0300 Subject: [PATCH 11/13] Stop packaging skills; scope freshness to update-wired skills only Use a generated hash catalog instead of shipping skills/ into the app bundle. Limit outdated detection to the four skills with Settings update rails, probe only managed home basenames, and leave discovery sources unchanged. Tests use temp dirs only. --- config/electron-builder.config.cjs | 14 +- .../generate-orca-skill-reference-hashes.mjs | 104 ++++++++++ package.json | 4 +- src/main/skills/bundled-skills-root.test.ts | 63 ------ src/main/skills/bundled-skills-root.ts | 36 ---- src/main/skills/discovery.test.ts | 47 ----- src/main/skills/discovery.ts | 1 - src/main/skills/freshness.test.ts | 33 +++- src/main/skills/freshness.ts | 179 ++++++++++-------- src/main/skills/skill-discovery-sources.ts | 27 +-- .../src/components/settings/Settings.tsx | 7 +- src/shared/orca-managed-skills.ts | 41 +--- .../orca-skill-reference-hashes.generated.ts | 11 ++ src/shared/skill-freshness.ts | 7 +- 14 files changed, 268 insertions(+), 306 deletions(-) create mode 100644 config/scripts/generate-orca-skill-reference-hashes.mjs delete mode 100644 src/main/skills/bundled-skills-root.test.ts delete mode 100644 src/main/skills/bundled-skills-root.ts create mode 100644 src/shared/orca-skill-reference-hashes.generated.ts diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index ecfc7456a06..0dd6a3d85c7 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -18,14 +18,6 @@ const featureWallResources = { from: 'resources/onboarding/feature-wall', to: 'onboarding/feature-wall' } -// Why: packaged apps compare installed agent skills against these references -// after an Orca update. Ship SKILL.md only (not full skill packages) under -// process.resourcesPath/orca-skills so freshness checks work offline. -const orcaSkillsReferenceResources = { - from: 'skills', - to: 'orca-skills', - filter: ['**/SKILL.md'] -} // Why: SSH relay deploy resolves bundles from process.resourcesPath in packaged // apps. Keeping relay assets as extraResources makes them real directories // instead of paths hidden inside app.asar. @@ -39,11 +31,7 @@ const relayExtraResource = { // do not fall through to a developer checkout's node_modules. const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources() -const commonExtraResources = [ - relayExtraResource, - orcaSkillsReferenceResources, - ...packagedRuntimeNodeModuleResources -] +const commonExtraResources = [relayExtraResource, ...packagedRuntimeNodeModuleResources] const macSpeechNativeResource = { from: 'node_modules/sherpa-onnx-darwin-${arch}', to: 'node_modules/sherpa-onnx-darwin-${arch}' diff --git a/config/scripts/generate-orca-skill-reference-hashes.mjs b/config/scripts/generate-orca-skill-reference-hashes.mjs new file mode 100644 index 00000000000..3e69fb03457 --- /dev/null +++ b/config/scripts/generate-orca-skill-reference-hashes.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/** + * Writes sha256 reference hashes for Orca-managed agent skills into shared TS. + * + * Why: freshness checks must not ship or package the skills/ tree into the app. + * Hashes are computed from the repo skills/ source of truth at generate time. + * Only managed skill names (those with in-app update rails) are hashed. + * + * Usage: + * node config/scripts/generate-orca-skill-reference-hashes.mjs --write + * node config/scripts/generate-orca-skill-reference-hashes.mjs --check + */ +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +const MAX_SKILL_MARKDOWN_BYTES = 256 * 1024 +const ROOT = join(import.meta.dirname, '../..') +const SKILLS_ROOT = join(ROOT, 'skills') +const OUT_PATH = join(ROOT, 'src/shared/orca-skill-reference-hashes.generated.ts') + +// Keep in sync with ORCA_MANAGED_SKILLS in src/shared/orca-managed-skills.ts +const MANAGED_SKILL_NAMES = ['orca-cli', 'orchestration', 'computer-use', 'orca-per-workspace-env'] + +function normalizeSkillMarkdownForHash(content) { + return content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n') +} + +function hashSkillMarkdown(content) { + return createHash('sha256').update(normalizeSkillMarkdownForHash(content), 'utf8').digest('hex') +} + +function buildSource() { + if (!existsSync(SKILLS_ROOT)) { + throw new Error(`skills/ not found at ${SKILLS_ROOT}`) + } + + /** @type {Record} */ + const hashes = {} + for (const skillName of MANAGED_SKILL_NAMES) { + const skillFilePath = join(SKILLS_ROOT, skillName, 'SKILL.md') + if (!existsSync(skillFilePath)) { + throw new Error(`Missing managed skill file: ${skillFilePath}`) + } + const content = readFileSync(skillFilePath) + if (content.byteLength > MAX_SKILL_MARKDOWN_BYTES) { + throw new Error(`Skill file exceeds ${MAX_SKILL_MARKDOWN_BYTES} bytes: ${skillFilePath}`) + } + hashes[skillName] = hashSkillMarkdown(content.toString('utf8')) + } + + // Surface unexpected skills/ dirs without failing — never treat them as fixtures to ship. + for (const entry of readdirSync(SKILLS_ROOT, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue + } + if (!MANAGED_SKILL_NAMES.includes(entry.name)) { + console.warn( + `[generate-orca-skill-reference-hashes] skipping non-managed skill dir: ${entry.name}` + ) + } + } + + return `/* eslint-disable */ +// AUTO-GENERATED by config/scripts/generate-orca-skill-reference-hashes.mjs +// Do not edit by hand. Re-run the script after changing managed skills/*/SKILL.md. +// Why: package freshness without shipping the skills/ tree into the app bundle. + +export const ORCA_SKILL_REFERENCE_HASHES: Readonly> = Object.freeze(${JSON.stringify( + hashes, + null, + 2 + )}) +` +} + +function main() { + const args = new Set(process.argv.slice(2)) + const write = args.has('--write') + const check = args.has('--check') + if (write === check) { + throw new Error('Pass exactly one of --write or --check') + } + + const next = buildSource() + if (write) { + writeFileSync(OUT_PATH, next, 'utf8') + console.log(`Wrote ${OUT_PATH} (${MANAGED_SKILL_NAMES.length} managed skills)`) + return + } + + if (!existsSync(OUT_PATH)) { + throw new Error(`Missing ${OUT_PATH}. Run: pnpm run generate:orca-skill-reference-hashes`) + } + const current = readFileSync(OUT_PATH, 'utf8') + if (current !== next) { + throw new Error( + `Stale orca skill reference hashes at ${OUT_PATH}. Run: pnpm run generate:orca-skill-reference-hashes` + ) + } + console.log('orca skill reference hashes are up to date') +} + +main() diff --git a/package.json b/package.json index 65a56f0c31c..17b76842683 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "main": "./out/main/index.js", "scripts": { "format": "oxfmt --write .", - "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:orca-skill-reference-hashes && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", "lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json", "lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs", "lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet", @@ -23,6 +23,8 @@ "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", "generate:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --write", "verify:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --check", + "generate:orca-skill-reference-hashes": "node config/scripts/generate-orca-skill-reference-hashes.mjs --write", + "verify:orca-skill-reference-hashes": "node config/scripts/generate-orca-skill-reference-hashes.mjs --check", "verify:macos-entitlements": "node config/scripts/verify-macos-entitlements.mjs", "vendor:feature-wall-assets": "node config/scripts/vendor-feature-wall-assets.mjs", "tc:node": "pnpm run typecheck:node", diff --git a/src/main/skills/bundled-skills-root.test.ts b/src/main/skills/bundled-skills-root.test.ts deleted file mode 100644 index b7bcec15a99..00000000000 --- a/src/main/skills/bundled-skills-root.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' - -const { resolveBundledSkillsRoot } = await import('./bundled-skills-root') - -vi.mock('electron', () => ({ - app: { - isPackaged: false, - getAppPath: () => tmpdir() - } -})) - -describe('resolveBundledSkillsRoot', () => { - const created: string[] = [] - - afterEach(async () => { - await Promise.all(created.map((dir) => rm(dir, { recursive: true, force: true }))) - created.length = 0 - }) - - it('returns packaged resources/orca-skills when present', async () => { - const resourcesPath = await mkdtemp(join(tmpdir(), 'orca-resources-')) - created.push(resourcesPath) - const skillsRoot = join(resourcesPath, 'orca-skills') - await mkdir(skillsRoot, { recursive: true }) - await writeFile(join(skillsRoot, 'README'), 'x', 'utf8') - - expect( - resolveBundledSkillsRoot({ - isPackaged: true, - resourcesPath, - appPath: tmpdir() - }) - ).toBe(skillsRoot) - }) - - it('returns null when packaged resources are missing', () => { - expect( - resolveBundledSkillsRoot({ - isPackaged: true, - resourcesPath: join(tmpdir(), 'missing-resources-dir'), - appPath: tmpdir() - }) - ).toBeNull() - }) - - it('falls back to a skills directory next to appPath in dev', async () => { - const appPath = await mkdtemp(join(tmpdir(), 'orca-app-')) - created.push(appPath) - const skillsRoot = join(appPath, 'skills') - await mkdir(skillsRoot, { recursive: true }) - - expect( - resolveBundledSkillsRoot({ - isPackaged: false, - resourcesPath: join(tmpdir(), 'unused'), - appPath - }) - ).toBe(skillsRoot) - }) -}) diff --git a/src/main/skills/bundled-skills-root.ts b/src/main/skills/bundled-skills-root.ts deleted file mode 100644 index 5899841fe60..00000000000 --- a/src/main/skills/bundled-skills-root.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { existsSync } from 'node:fs' -import { join } from 'node:path' -import { app } from 'electron' - -/** - * Resolve the on-disk root of Orca-shipped skill references used for - * freshness checks. Packaged apps read `process.resourcesPath/orca-skills`; - * dev/unpackaged builds fall back to the repo `skills/` directory. - */ -export function resolveBundledSkillsRoot(options?: { - isPackaged?: boolean - resourcesPath?: string - appPath?: string -}): string | null { - const isPackaged = options?.isPackaged ?? app.isPackaged - const resourcesPath = options?.resourcesPath ?? process.resourcesPath - const appPath = options?.appPath ?? app.getAppPath() - - if (isPackaged) { - const packagedRoot = join(resourcesPath, 'orca-skills') - return existsSync(packagedRoot) ? packagedRoot : null - } - - // electron-vite may set getAppPath() to the project root or out/. - const candidates = [ - join(appPath, 'skills'), - join(appPath, '..', 'skills'), - join(process.cwd(), 'skills') - ] - for (const candidate of candidates) { - if (existsSync(candidate)) { - return candidate - } - } - return null -} diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index 68ff5e9d017..dcee058f8e2 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -57,32 +57,6 @@ describe('skill discovery', () => { expect(rootPaths).toContain('/workspace/current/.claude/skills') }) - it('scans each provider home skill root that npx skills --global writes to', () => { - const roots = buildSkillDiscoverySources({ - homeDir: '/home/test', - cwd: '/workspace/current' - }) - - const rootPaths = roots.map((root) => root.path.replace(/\\/g, '/')) - expect(rootPaths).toEqual( - expect.arrayContaining([ - '/home/test/.grok/skills', - '/home/test/.config/opencode/skills', - '/home/test/.pi/agent/skills', - '/home/test/.gemini/skills', - '/home/test/.gemini/antigravity/skills', - '/home/test/.cursor/skills' - ]) - ) - // Why: these live outside ~/.agents/skills, so they must carry the shared - // agent-skills provider to feed per-agent orchestration coverage. - for (const root of roots) { - if (root.path.replace(/\\/g, '/') === '/home/test/.grok/skills') { - expect(root.providers).toEqual(['agent-skills']) - } - } - }) - it('discovers skill packages through symlinked skill directories', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) const home = join(root, 'home') @@ -103,27 +77,6 @@ describe('skill discovery', () => { expect(skill?.directoryPath).toBe(linkedSkill) }) - it('discovers a symlinked skill inside a provider home root (#8256/#8503)', async () => { - const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) - const home = join(root, 'home') - const realSkill = join(root, 'central-skills', 'orchestration') - const linkedSkill = join(home, '.pi', 'agent', 'skills', 'orchestration') - await mkdir(realSkill, { recursive: true }) - await mkdir(join(home, '.pi', 'agent', 'skills'), { recursive: true }) - await writeFile(join(realSkill, 'SKILL.md'), '# orchestration\n\nCoordinate agents.') - await symlink(realSkill, linkedSkill, process.platform === 'win32' ? 'junction' : 'dir') - - const result = await discoverSkills({ - homeDir: home, - cwd: join(root, 'missing-cwd') - }) - - const skill = result.skills.find((entry) => entry.name === 'orchestration') - expect(skill?.sourceKind).toBe('home') - expect(skill?.directoryPath).toBe(linkedSkill) - expect(skill?.providers).toEqual(['agent-skills']) - }) - it('discovers worktree .agents skill symlinks from the requested cwd', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) const home = join(root, 'home') diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts index 4fc08b80cf4..d84f82af426 100644 --- a/src/main/skills/discovery.ts +++ b/src/main/skills/discovery.ts @@ -234,7 +234,6 @@ export async function discoverSkills(args: { repos?: Repo[] homeDir?: string cwd?: string - includeProjectRoots?: boolean }): Promise { const roots = buildSkillDiscoverySources(args) const sources: SkillDiscoverySource[] = [] diff --git a/src/main/skills/freshness.test.ts b/src/main/skills/freshness.test.ts index 178dda481ef..7c53b642577 100644 --- a/src/main/skills/freshness.test.ts +++ b/src/main/skills/freshness.test.ts @@ -2,6 +2,8 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' +import { ORCA_MANAGED_SKILLS } from '../../shared/orca-managed-skills' +import { ORCA_SKILL_REFERENCE_HASHES } from '../../shared/orca-skill-reference-hashes.generated' import { checkOrcaSkillFreshness, hashSkillMarkdown, @@ -16,6 +18,7 @@ afterEach(async () => { }) async function makeTempDir(prefix: string): Promise { + // Why: all fixtures live under os.tmpdir — never write into repo skills/ or real ~/.agents. const dir = await mkdtemp(join(tmpdir(), prefix)) tempDirs.push(dir) return dir @@ -37,6 +40,18 @@ describe('skill freshness hashing', () => { }) }) +describe('managed skill catalog contract', () => { + it('pins every managed skill to a generated reference hash (no packaging of skills/)', () => { + const catalogNames = new Set(Object.keys(ORCA_SKILL_REFERENCE_HASHES)) + for (const skill of ORCA_MANAGED_SKILLS) { + expect(catalogNames.has(skill.skillName)).toBe(true) + expect(ORCA_SKILL_REFERENCE_HASHES[skill.skillName]).toMatch(/^[a-f0-9]{64}$/) + } + // Why: catalog must not grow beyond the update-wired managed set. + expect(catalogNames.size).toBe(ORCA_MANAGED_SKILLS.length) + }) +}) + describe('checkOrcaSkillFreshness', () => { it('marks an installed skill outdated when content diverges from the reference', async () => { const referenceRoot = await makeTempDir('orca-skill-ref-') @@ -126,9 +141,9 @@ describe('checkOrcaSkillFreshness', () => { '---\nname: orca-cli\n---\nstale\n' ) + // Why: homeDir is empty of installs; repoDir is never scanned. const result = await checkOrcaSkillFreshness({ homeDir, - cwd: repoDir, referenceRoot }) @@ -136,7 +151,21 @@ describe('checkOrcaSkillFreshness', () => { expect(orcaCli?.status).toBe('missing') }) - it('flags outdated when any home provider copy diverges (M2)', async () => { + it('uses the generated catalog when no referenceRoot is provided', async () => { + const homeDir = await makeTempDir('orca-skill-home-') + await mkdir(join(homeDir, '.agents', 'skills'), { recursive: true }) + + const result = await checkOrcaSkillFreshness({ homeDir }) + + expect(result.referenceRoot).toBeNull() + expect(result.skills.map((skill) => skill.skillName).sort()).toEqual( + ORCA_MANAGED_SKILLS.map((skill) => skill.skillName).sort() + ) + expect(result.skills.every((skill) => skill.expectedHash !== null)).toBe(true) + expect(result.skills.every((skill) => skill.status === 'missing')).toBe(true) + }) + + it('flags outdated when any home provider copy diverges', async () => { const referenceRoot = await makeTempDir('orca-skill-ref-') const homeDir = await makeTempDir('orca-skill-home-') const content = '---\nname: orca-cli\n---\nreference\n' diff --git a/src/main/skills/freshness.ts b/src/main/skills/freshness.ts index a77260d527c..be279c01e1d 100644 --- a/src/main/skills/freshness.ts +++ b/src/main/skills/freshness.ts @@ -1,19 +1,35 @@ import { createHash } from 'node:crypto' import { open, readdir } from 'node:fs/promises' -import { basename, join } from 'node:path' +import { homedir } from 'node:os' +import { join } from 'node:path' import { ORCA_MANAGED_SKILLS } from '../../shared/orca-managed-skills' +import { ORCA_SKILL_REFERENCE_HASHES } from '../../shared/orca-skill-reference-hashes.generated' import type { SkillFreshnessEntry, SkillFreshnessResult, SkillFreshnessStatus } from '../../shared/skill-freshness' -import { discoverSkills } from './discovery' -import { resolveBundledSkillsRoot } from './bundled-skills-root' -import type { DiscoveredSkill } from '../../shared/skills' const SKILL_FILE_NAME = 'SKILL.md' const MAX_SKILL_MARKDOWN_BYTES = 256 * 1024 +/** + * Home provider skill directories that `npx skills add --global` writes into. + * Why private to freshness: do not expand Skills-page discovery roots; only + * probe managed skill basenames under these paths. + */ +const HOME_SKILL_DIR_SEGMENTS: readonly (readonly string[])[] = [ + ['.agents', 'skills'], + ['.claude', 'skills'], + ['.codex', 'skills'], + ['.grok', 'skills'], + ['.config', 'opencode', 'skills'], + ['.pi', 'agent', 'skills'], + ['.gemini', 'skills'], + ['.gemini', 'antigravity', 'skills'], + ['.cursor', 'skills'] +] + function normalizeSkillName(value: string): string { return value.trim().toLowerCase() } @@ -28,62 +44,61 @@ export function hashSkillMarkdown(content: string): string { return createHash('sha256').update(normalizeSkillMarkdownForHash(content), 'utf8').digest('hex') } -async function readSkillMarkdown(skillFilePath: string): Promise { +type SkillFileHashOutcome = + | { kind: 'missing' } + | { kind: 'unreadable' } + | { kind: 'ok'; hash: string } + +async function inspectSkillFile(skillFilePath: string): Promise { + let file try { - const file = await open(skillFilePath, 'r') - try { - // Why: FileHandle.read may return short reads; loop to EOF. Cap at - // MAX+1 so oversized skills are rejected instead of prefix-hashed. - const buffer = Buffer.alloc(MAX_SKILL_MARKDOWN_BYTES + 1) - let totalBytesRead = 0 - while (totalBytesRead < buffer.length) { - const { bytesRead } = await file.read( - buffer, - totalBytesRead, - buffer.length - totalBytesRead, - totalBytesRead - ) - if (bytesRead === 0) { - break - } - totalBytesRead += bytesRead - } - if (totalBytesRead > MAX_SKILL_MARKDOWN_BYTES) { - return null + file = await open(skillFilePath, 'r') + } catch { + return { kind: 'missing' } + } + + try { + // Why: FileHandle.read may return short reads; loop to EOF. Cap at + // MAX+1 so oversized skills are rejected instead of prefix-hashed. + const buffer = Buffer.alloc(MAX_SKILL_MARKDOWN_BYTES + 1) + let totalBytesRead = 0 + while (totalBytesRead < buffer.length) { + const { bytesRead } = await file.read( + buffer, + totalBytesRead, + buffer.length - totalBytesRead, + totalBytesRead + ) + if (bytesRead === 0) { + break } - return buffer.toString('utf8', 0, totalBytesRead) - } finally { - await file.close() + totalBytesRead += bytesRead + } + if (totalBytesRead > MAX_SKILL_MARKDOWN_BYTES) { + return { kind: 'unreadable' } } + const content = buffer.toString('utf8', 0, totalBytesRead) + return { kind: 'ok', hash: hashSkillMarkdown(content) } } catch { - return null + return { kind: 'unreadable' } + } finally { + await file.close() } } -async function hashSkillFile(skillFilePath: string): Promise { - const content = await readSkillMarkdown(skillFilePath) - return content === null ? null : hashSkillMarkdown(content) -} - -function skillMatchesManagedName(skill: DiscoveredSkill, skillName: string): boolean { - // Why: match install identity (directory basename) only — frontmatter name - // collisions with user forks would otherwise drive overwrite prompts. - return normalizeSkillName(basename(skill.directoryPath)) === normalizeSkillName(skillName) -} - -function listInstalledHomeSkills( - skills: readonly DiscoveredSkill[], - skillName: string -): DiscoveredSkill[] { - // Why: `npx skills add --global` writes into each agent's home skills dir - // (~/.agents, ~/.claude, ~/.codex, …). Any diverging home copy must count. - return skills.filter( - (skill) => - skill.installed && skill.sourceKind === 'home' && skillMatchesManagedName(skill, skillName) - ) +function loadCatalogExpectedHashes(): Map { + const hashes = new Map() + for (const [skillName, hash] of Object.entries(ORCA_SKILL_REFERENCE_HASHES)) { + hashes.set(normalizeSkillName(skillName), hash) + } + return hashes } -async function loadExpectedHashes(referenceRoot: string): Promise> { +/** + * Test-only override: load expected hashes from a temp skills tree. + * Production always uses the generated catalog — never the repo skills/ tree. + */ +async function loadExpectedHashesFromRoot(referenceRoot: string): Promise> { const hashes = new Map() let entries: string[] = [] try { @@ -95,15 +110,22 @@ async function loadExpectedHashes(referenceRoot: string): Promise { const skillFilePath = join(referenceRoot, entryName, SKILL_FILE_NAME) - const hash = await hashSkillFile(skillFilePath) - if (hash) { - hashes.set(normalizeSkillName(entryName), hash) + const outcome = await inspectSkillFile(skillFilePath) + if (outcome.kind === 'ok') { + hashes.set(normalizeSkillName(entryName), outcome.hash) } }) ) return hashes } +/** Resolve installed managed-skill paths under home provider skill roots only. */ +function listManagedHomeSkillPaths(homeDir: string, skillName: string): string[] { + return HOME_SKILL_DIR_SEGMENTS.map((segments) => + join(homeDir, ...segments, skillName, SKILL_FILE_NAME) + ) +} + function resolveStatus(args: { expectedHash: string | null installedCount: number @@ -128,35 +150,42 @@ function resolveStatus(args: { export async function checkOrcaSkillFreshness(args?: { homeDir?: string + /** + * Optional temp skills root for unit tests. When omitted, expected hashes + * come from ORCA_SKILL_REFERENCE_HASHES (no packaged skills/ tree). + */ referenceRoot?: string | null }): Promise { - const referenceRoot = - args?.referenceRoot === undefined ? resolveBundledSkillsRoot() : args.referenceRoot - const expectedHashes = referenceRoot ? await loadExpectedHashes(referenceRoot) : new Map() + // Why: never read process.resourcesPath/orca-skills or repo skills/ at runtime. + const referenceRoot = args?.referenceRoot ?? null + const expectedHashes = + referenceRoot === null + ? loadCatalogExpectedHashes() + : await loadExpectedHashesFromRoot(referenceRoot) - // Why: freshness only cares about home installs. Skip repo/cwd walks. - const discovery = await discoverSkills({ - repos: [], - homeDir: args?.homeDir, - includeProjectRoots: false - }) + // Why: only probe known managed basenames under home skill roots — no repo/cwd + // walks and no full skill-discovery scan (avoids mutating discovery sources). + const homeDir = args?.homeDir ?? homedir() const skills = await Promise.all( ORCA_MANAGED_SKILLS.map(async (definition) => { const expectedHash = expectedHashes.get(normalizeSkillName(definition.skillName)) ?? null - const homeInstalls = listInstalledHomeSkills(discovery.skills, definition.skillName) - const hashed = await Promise.all( - homeInstalls.map(async (install) => ({ - path: install.skillFilePath, - hash: await hashSkillFile(install.skillFilePath) + const candidatePaths = listManagedHomeSkillPaths(homeDir, definition.skillName) + const inspected = await Promise.all( + candidatePaths.map(async (skillFilePath) => ({ + path: skillFilePath, + outcome: await inspectSkillFile(skillFilePath) })) ) - const readable = hashed.filter((entry) => entry.hash !== null) + const installed = inspected.filter((entry) => entry.outcome.kind !== 'missing') + const readable = inspected.filter( + (entry): entry is { path: string; outcome: { kind: 'ok'; hash: string } } => + entry.outcome.kind === 'ok' + ) const diverging = readable.filter( - (entry) => expectedHash !== null && entry.hash !== expectedHash + (entry) => expectedHash !== null && entry.outcome.hash !== expectedHash ) - const primary = diverging[0] ?? - readable[0] ?? { path: homeInstalls[0]?.skillFilePath ?? null, hash: null } + const primary = diverging[0] ?? readable[0] return { skillName: definition.skillName, @@ -165,13 +194,13 @@ export async function checkOrcaSkillFreshness(args?: { updateCommand: definition.updateCommand, status: resolveStatus({ expectedHash, - installedCount: homeInstalls.length, + installedCount: installed.length, readableCount: readable.length, divergingCount: diverging.length }), expectedHash, - installedHash: primary.hash, - installedPath: primary.path, + installedHash: primary?.outcome.hash ?? null, + installedPath: primary?.path ?? installed[0]?.path ?? null, divergingPaths: diverging.map((entry) => entry.path) } satisfies SkillFreshnessEntry }) diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index a8103a07cad..c1bb1072b2e 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -25,12 +25,10 @@ export function buildSkillDiscoverySources( homeDir?: string cwd?: string repos?: Repo[] - /** When false, skip repo/cwd project roots (used by global skill freshness). */ - includeProjectRoots?: boolean } = {} ): SkillScanRoot[] { const home = args.homeDir ?? homedir() - const includeProjectRoots = args.includeProjectRoots !== false + const cwd = args.cwd ?? process.cwd() const roots: SkillScanRoot[] = [ source('home-codex', 'Codex home', join(home, '.codex', 'skills'), 'home', ['codex']), source('home-agents', 'Agent skills home', join(home, '.agents', 'skills'), 'home', [ @@ -43,30 +41,9 @@ export function buildSkillDiscoverySources( join(home, '.codex', 'plugins', 'cache'), 'plugin', ['codex', 'agent-skills'] - ), - // Why: `npx skills add --global` writes into each agent's own home skills - // directory, so coverage misses them unless we scan every provider root. - source('home-grok', 'Grok home', join(home, '.grok', 'skills'), 'home', ['agent-skills']), - source('home-opencode', 'OpenCode home', join(home, '.config', 'opencode', 'skills'), 'home', [ - 'agent-skills' - ]), - source('home-pi', 'Pi home', join(home, '.pi', 'agent', 'skills'), 'home', ['agent-skills']), - source('home-gemini', 'Gemini home', join(home, '.gemini', 'skills'), 'home', ['agent-skills']), - source( - 'home-antigravity', - 'Antigravity home', - join(home, '.gemini', 'antigravity', 'skills'), - 'home', - ['agent-skills'] - ), - source('home-cursor', 'Cursor home', join(home, '.cursor', 'skills'), 'home', ['agent-skills']) + ) ] - if (!includeProjectRoots) { - return roots - } - - const cwd = args.cwd ?? process.cwd() const projectPaths = new Set() for (const repo of args.repos ?? []) { if (repo.connectionId) { diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 19bd5c02a06..39afcdaed2c 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -698,11 +698,10 @@ function Settings(): React.JSX.Element { ] ]) // Apply outdated badges generically from freshness section ids. + // Managed set is CLI / orchestration / computer-use / ephemeral only. for (const sectionId of outdatedSectionIds) { - if (sectionId === 'computer-use' || sectionId === 'mobile-emulator') { - if (!showDesktopOnlySettings) { - continue - } + if (sectionId === 'computer-use' && !showDesktopOnlySettings) { + continue } if (sectionId === 'orchestration') { continue diff --git a/src/shared/orca-managed-skills.ts b/src/shared/orca-managed-skills.ts index d443aa668c9..c1876875505 100644 --- a/src/shared/orca-managed-skills.ts +++ b/src/shared/orca-managed-skills.ts @@ -3,15 +3,10 @@ import { COMPUTER_USE_SKILL_UPDATE_COMMAND, EPHEMERAL_VMS_SKILL_NAME, EPHEMERAL_VMS_SKILL_UPDATE_COMMAND, - LINEAR_TICKETS_SKILL_NAME, - LINEAR_TICKETS_SKILL_UPDATE_COMMAND, ORCA_CLI_SKILL_NAME, ORCA_CLI_SKILL_UPDATE_COMMAND, - ORCA_LINEAR_SKILL_NAME, - ORCA_LINEAR_SKILL_UPDATE_COMMAND, ORCHESTRATION_SKILL_NAME, - ORCHESTRATION_SKILL_UPDATE_COMMAND, - buildAgentFeatureSkillUpdateCommand + ORCHESTRATION_SKILL_UPDATE_COMMAND } from './agent-feature-install-commands' /** Settings nav section id that hosts the skill setup surface. */ @@ -19,9 +14,7 @@ export type OrcaManagedSkillSettingsSectionId = | 'general' | 'orchestration' | 'computer-use' - | 'integrations' | 'experimental' - | 'mobile-emulator' export type OrcaManagedSkillDefinition = { /** Directory / skill package name under skills/ and ~/.agents/skills/. */ @@ -34,12 +27,10 @@ export type OrcaManagedSkillDefinition = { updateCommand: string } -export const ORCA_EMULATOR_SKILL_NAME = 'orca-emulator' -export const ORCA_EMULATOR_ANDROID_SKILL_NAME = 'orca-emulator-android' - /** - * Orca-owned agent skills whose installed copy should track the app release. - * Only skills listed here participate in outdated detection and update prompts. + * Orca-owned agent skills with an in-app update surface. + * Only these participate in outdated detection and prompts — Linear / emulator + * skills keep their existing install flows until they gain the same rail. */ export const ORCA_MANAGED_SKILLS: readonly OrcaManagedSkillDefinition[] = [ { @@ -65,29 +56,5 @@ export const ORCA_MANAGED_SKILLS: readonly OrcaManagedSkillDefinition[] = [ displayName: 'Per-workspace env', settingsSectionId: 'experimental', updateCommand: EPHEMERAL_VMS_SKILL_UPDATE_COMMAND - }, - { - skillName: ORCA_LINEAR_SKILL_NAME, - displayName: 'Orca Linear', - settingsSectionId: 'integrations', - updateCommand: ORCA_LINEAR_SKILL_UPDATE_COMMAND - }, - { - skillName: LINEAR_TICKETS_SKILL_NAME, - displayName: 'Linear tickets', - settingsSectionId: 'integrations', - updateCommand: LINEAR_TICKETS_SKILL_UPDATE_COMMAND - }, - { - skillName: ORCA_EMULATOR_SKILL_NAME, - displayName: 'iOS emulator', - settingsSectionId: 'mobile-emulator', - updateCommand: buildAgentFeatureSkillUpdateCommand(ORCA_EMULATOR_SKILL_NAME) - }, - { - skillName: ORCA_EMULATOR_ANDROID_SKILL_NAME, - displayName: 'Android emulator', - settingsSectionId: 'mobile-emulator', - updateCommand: buildAgentFeatureSkillUpdateCommand(ORCA_EMULATOR_ANDROID_SKILL_NAME) } ] diff --git a/src/shared/orca-skill-reference-hashes.generated.ts b/src/shared/orca-skill-reference-hashes.generated.ts new file mode 100644 index 00000000000..231301c50b5 --- /dev/null +++ b/src/shared/orca-skill-reference-hashes.generated.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +// AUTO-GENERATED by config/scripts/generate-orca-skill-reference-hashes.mjs +// Do not edit by hand. Re-run the script after changing managed skills/*/SKILL.md. +// Why: package freshness without shipping the skills/ tree into the app bundle. + +export const ORCA_SKILL_REFERENCE_HASHES: Readonly> = Object.freeze({ + 'orca-cli': 'b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be', + orchestration: '0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5', + 'computer-use': 'f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39', + 'orca-per-workspace-env': '58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7' +}) diff --git a/src/shared/skill-freshness.ts b/src/shared/skill-freshness.ts index 6f56d8e03d1..8df09ab12ae 100644 --- a/src/shared/skill-freshness.ts +++ b/src/shared/skill-freshness.ts @@ -8,7 +8,7 @@ export type SkillFreshnessEntry = { settingsSectionId: OrcaManagedSkillSettingsSectionId updateCommand: string status: SkillFreshnessStatus - /** sha256 of the app-bundled SKILL.md (expected). */ + /** sha256 of the release catalog SKILL.md (expected). */ expectedHash: string | null /** * Hash used for prompt identity. When outdated, this is the first diverging @@ -24,6 +24,9 @@ export type SkillFreshnessEntry = { export type SkillFreshnessResult = { skills: SkillFreshnessEntry[] scannedAt: number - /** Absolute path to the reference skills root used for expected hashes. */ + /** + * Absolute path to a test-only reference skills root, if one was provided. + * Production scans always use the generated hash catalog (`null` here). + */ referenceRoot: string | null } From e95186c76388bf1ced21d8951af5b5624574a7bd Mon Sep 17 00:00:00 2001 From: Jorge de los Santos Date: Tue, 14 Jul 2026 14:29:04 -0300 Subject: [PATCH 12/13] Restore provider-home skill discovery coverage from #8510 The previous cleanup incorrectly reset discovery sources and tests to an outdated origin/main tip, dropping multi-agent home roots and the #8256/#8503 symlink coverage cases. Those belong to orchestration agent coverage, not the outdated-skills packaging change. --- src/main/skills/discovery.test.ts | 47 ++++++++++++++++++++++ src/main/skills/skill-discovery-sources.ts | 18 ++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index dcee058f8e2..68ff5e9d017 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -57,6 +57,32 @@ describe('skill discovery', () => { expect(rootPaths).toContain('/workspace/current/.claude/skills') }) + it('scans each provider home skill root that npx skills --global writes to', () => { + const roots = buildSkillDiscoverySources({ + homeDir: '/home/test', + cwd: '/workspace/current' + }) + + const rootPaths = roots.map((root) => root.path.replace(/\\/g, '/')) + expect(rootPaths).toEqual( + expect.arrayContaining([ + '/home/test/.grok/skills', + '/home/test/.config/opencode/skills', + '/home/test/.pi/agent/skills', + '/home/test/.gemini/skills', + '/home/test/.gemini/antigravity/skills', + '/home/test/.cursor/skills' + ]) + ) + // Why: these live outside ~/.agents/skills, so they must carry the shared + // agent-skills provider to feed per-agent orchestration coverage. + for (const root of roots) { + if (root.path.replace(/\\/g, '/') === '/home/test/.grok/skills') { + expect(root.providers).toEqual(['agent-skills']) + } + } + }) + it('discovers skill packages through symlinked skill directories', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) const home = join(root, 'home') @@ -77,6 +103,27 @@ describe('skill discovery', () => { expect(skill?.directoryPath).toBe(linkedSkill) }) + it('discovers a symlinked skill inside a provider home root (#8256/#8503)', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const home = join(root, 'home') + const realSkill = join(root, 'central-skills', 'orchestration') + const linkedSkill = join(home, '.pi', 'agent', 'skills', 'orchestration') + await mkdir(realSkill, { recursive: true }) + await mkdir(join(home, '.pi', 'agent', 'skills'), { recursive: true }) + await writeFile(join(realSkill, 'SKILL.md'), '# orchestration\n\nCoordinate agents.') + await symlink(realSkill, linkedSkill, process.platform === 'win32' ? 'junction' : 'dir') + + const result = await discoverSkills({ + homeDir: home, + cwd: join(root, 'missing-cwd') + }) + + const skill = result.skills.find((entry) => entry.name === 'orchestration') + expect(skill?.sourceKind).toBe('home') + expect(skill?.directoryPath).toBe(linkedSkill) + expect(skill?.providers).toEqual(['agent-skills']) + }) + it('discovers worktree .agents skill symlinks from the requested cwd', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) const home = join(root, 'home') diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index c1bb1072b2e..bec34117c4c 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -41,7 +41,23 @@ export function buildSkillDiscoverySources( join(home, '.codex', 'plugins', 'cache'), 'plugin', ['codex', 'agent-skills'] - ) + ), + // Why: `npx skills add --global` writes into each agent's own home skills + // directory, so coverage misses them unless we scan every provider root. + source('home-grok', 'Grok home', join(home, '.grok', 'skills'), 'home', ['agent-skills']), + source('home-opencode', 'OpenCode home', join(home, '.config', 'opencode', 'skills'), 'home', [ + 'agent-skills' + ]), + source('home-pi', 'Pi home', join(home, '.pi', 'agent', 'skills'), 'home', ['agent-skills']), + source('home-gemini', 'Gemini home', join(home, '.gemini', 'skills'), 'home', ['agent-skills']), + source( + 'home-antigravity', + 'Antigravity home', + join(home, '.gemini', 'antigravity', 'skills'), + 'home', + ['agent-skills'] + ), + source('home-cursor', 'Cursor home', join(home, '.cursor', 'skills'), 'home', ['agent-skills']) ] const projectPaths = new Set() From 5d16419ce3355f4c42da823f4713436d695c6e53 Mon Sep 17 00:00:00 2001 From: Jorge de los Santos Date: Tue, 14 Jul 2026 14:45:16 -0300 Subject: [PATCH 13/13] Polish outdated-skill UX: exit-time attempt mark, stack, hook tests Record update-attempt suppression when the setup terminal closes instead of on open, stack the skill card above UpdateCard and StarNag, make hash catalog verify resilient to oxfmt quote rewrites, and cover the freshness hook for target switches, coalescing, and repair-required. --- .../generate-orca-skill-reference-hashes.mjs | 28 ++- .../settings/AgentSkillSetupPanel.tsx | 14 +- .../src/components/settings/CliSection.tsx | 6 +- .../settings/ComputerUseSkillSetupPanel.tsx | 2 + .../components/settings/EphemeralVmsPane.tsx | 2 + .../settings/OrchestrationSetupCard.tsx | 2 + .../skills/OutdatedSkillUpdateDialog.tsx | 9 +- .../useOrcaSkillFreshness.react.test.tsx | 205 ++++++++++++++++++ 8 files changed, 257 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/hooks/useOrcaSkillFreshness.react.test.tsx diff --git a/config/scripts/generate-orca-skill-reference-hashes.mjs b/config/scripts/generate-orca-skill-reference-hashes.mjs index 3e69fb03457..8e859bc4f03 100644 --- a/config/scripts/generate-orca-skill-reference-hashes.mjs +++ b/config/scripts/generate-orca-skill-reference-hashes.mjs @@ -82,9 +82,18 @@ function main() { throw new Error('Pass exactly one of --write or --check') } - const next = buildSource() + const nextSource = buildSource() + /** @type {Record} */ + const nextHashes = Object.fromEntries( + MANAGED_SKILL_NAMES.map((name) => { + // Recompute for semantic compare (file format may differ after oxfmt). + const skillFilePath = join(SKILLS_ROOT, name, 'SKILL.md') + return [name, hashSkillMarkdown(readFileSync(skillFilePath).toString('utf8'))] + }) + ) + if (write) { - writeFileSync(OUT_PATH, next, 'utf8') + writeFileSync(OUT_PATH, nextSource, 'utf8') console.log(`Wrote ${OUT_PATH} (${MANAGED_SKILL_NAMES.length} managed skills)`) return } @@ -93,7 +102,20 @@ function main() { throw new Error(`Missing ${OUT_PATH}. Run: pnpm run generate:orca-skill-reference-hashes`) } const current = readFileSync(OUT_PATH, 'utf8') - if (current !== next) { + // Why: oxfmt rewrites quote style on commit; compare hash values, not bytes. + const match = /Object\.freeze\((\{[\s\S]*\})\s*\)/.exec(current) + if (!match) { + throw new Error(`Could not parse hashes from ${OUT_PATH}`) + } + /** @type {Record} */ + const currentHashes = Function(`"use strict"; return (${match[1]})`)() + const currentKeys = Object.keys(currentHashes).sort() + const nextKeys = Object.keys(nextHashes).sort() + if ( + currentKeys.length !== nextKeys.length || + currentKeys.some((key, index) => key !== nextKeys[index]) || + nextKeys.some((key) => currentHashes[key] !== nextHashes[key]) + ) { throw new Error( `Stale orca skill reference hashes at ${OUT_PATH}. Run: pnpm run generate:orca-skill-reference-hashes` ) diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx index 5aa1deabc8d..b9c40245e3f 100644 --- a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx @@ -23,7 +23,7 @@ type AgentSkillSetupPanelProps = { terminalAriaLabel: string terminalWorktreeId: string installed: boolean - /** When installed but behind the app-bundled skill reference. */ + /** When installed but behind the release skill-hash catalog. */ outdated?: boolean loading: boolean error: string | null @@ -41,6 +41,12 @@ type AgentSkillSetupPanelProps = { getPrerequisiteStatus?: () => Promise isPrerequisiteAvailable?: (status: SkillPrerequisiteStatus) => boolean onBeforeOpenTerminal?: () => void | Promise + /** + * Fired when the setup terminal closes. Use for post-attempt bookkeeping + * (e.g. outdated-skill suppression) after the user had a chance to run the + * command — not for prereqs (those stay on onBeforeOpenTerminal). + */ + onTerminalExit?: () => void showInstallWhenInstalled?: boolean showRecheckWhenInstalled?: boolean installLabel?: string @@ -75,6 +81,7 @@ export function AgentSkillSetupPanel({ getPrerequisiteStatus, isPrerequisiteAvailable = isOrcaCliAvailableOnPath, onBeforeOpenTerminal, + onTerminalExit, showInstallWhenInstalled = true, showRecheckWhenInstalled = true, installLabel = 'Install', @@ -362,7 +369,10 @@ export function AgentSkillSetupPanel({ terminalTopMarginPx={8} descriptionPaddingClassName="px-4 py-2" autoScrollIntoView={false} - onTerminalExit={notifyInstalledAgentSkillsChanged} + onTerminalExit={() => { + notifyInstalledAgentSkillsChanged() + onTerminalExit?.() + }} />

) : null} diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index ba064f7a321..072d7864fcf 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -372,8 +372,10 @@ export function CliSection({ : ensureOrcaCliAvailableForAgentSkillTerminal({ onStatusChange: handleStatusChange })) - // Why: mark only after prereqs succeed so a failed ensure does - // not suppress prompts for the whole app release. + }} + onTerminalExit={() => { + // Why: mark after the terminal closes (user had a chance to run + // update), not on open — accidental open/close must not suppress. markOutdatedSkillUpdateAttemptIfNeeded( ORCA_CLI_SKILL_NAME, isSkillOutdated(ORCA_CLI_SKILL_NAME), diff --git a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx index a5bcbe8d3c0..43265d1fe93 100644 --- a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx @@ -83,6 +83,8 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element { await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) : ensureOrcaCliAvailableForAgentSkillTerminal()) + }} + onTerminalExit={() => { markOutdatedSkillUpdateAttemptIfNeeded( COMPUTER_USE_SKILL_NAME, isSkillOutdated(COMPUTER_USE_SKILL_NAME), diff --git a/src/renderer/src/components/settings/EphemeralVmsPane.tsx b/src/renderer/src/components/settings/EphemeralVmsPane.tsx index dcfd086fe92..270fe818a72 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.tsx @@ -167,6 +167,8 @@ export function EphemeralVmsPane(): React.JSX.Element { await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) : ensureOrcaCliAvailableForAgentSkillTerminal()) + }} + onTerminalExit={() => { markOutdatedSkillUpdateAttemptIfNeeded( EPHEMERAL_VMS_SKILL_NAME, isSkillOutdated(EPHEMERAL_VMS_SKILL_NAME), diff --git a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx index bc0d4d95901..1775707a9d4 100644 --- a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx +++ b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx @@ -80,6 +80,8 @@ export function OrchestrationSetupCard(props: { await (activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) : ensureOrcaCliAvailableForAgentSkillTerminal()) + }} + onTerminalExit={() => { markOutdatedSkillUpdateAttemptIfNeeded( ORCHESTRATION_SKILL_NAME, isSkillOutdated(ORCHESTRATION_SKILL_NAME), diff --git a/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx b/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx index fb63d9ca78e..c04b881799d 100644 --- a/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx +++ b/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx @@ -23,8 +23,11 @@ export function OutdatedSkillUpdateDialog(props: { const { skill, onDismiss, onUpdate } = props const headingId = useId() const updateStatus = useAppStore((s) => s.updateStatus) - // Why: UpdateCard owns bottom-10; raise this card so both remain readable. + // Why: UpdateCard and StarNagCard share the bottom-right slot (bottom-10, or + // bottom-[220px] when UpdateCard is up). Skill prompts stack above both so + // they never pixel-collide with equal z-index siblings. const updateCardVisible = updateStatus.state !== 'idle' && updateStatus.state !== 'not-available' + const bottomClass = updateCardVisible ? 'bottom-[430px]' : 'bottom-[220px]' const copyCommand = async (): Promise => { try { @@ -49,9 +52,7 @@ export function OutdatedSkillUpdateDialog(props: { return (
diff --git a/src/renderer/src/hooks/useOrcaSkillFreshness.react.test.tsx b/src/renderer/src/hooks/useOrcaSkillFreshness.react.test.tsx new file mode 100644 index 00000000000..7fe5e8d094e --- /dev/null +++ b/src/renderer/src/hooks/useOrcaSkillFreshness.react.test.tsx @@ -0,0 +1,205 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SkillDiscoveryTarget } from '../../../shared/skills' +import type { SkillFreshnessEntry, SkillFreshnessResult } from '../../../shared/skill-freshness' +import { + _orcaSkillFreshnessInternalsForTests, + type OrcaSkillFreshnessState, + useOrcaSkillFreshness +} from './useOrcaSkillFreshness' + +let root: Root | null = null +let container: HTMLDivElement | null = null +let latestState: OrcaSkillFreshnessState | null = null + +function entry(overrides: Partial): SkillFreshnessEntry { + return { + skillName: 'orca-cli', + displayName: 'Orca CLI', + settingsSectionId: 'general', + updateCommand: 'npx skills update orca-cli --global', + status: 'outdated', + expectedHash: 'abc', + installedHash: 'def', + installedPath: '/home/test/.agents/skills/orca-cli/SKILL.md', + divergingPaths: ['/home/test/.agents/skills/orca-cli/SKILL.md'], + ...overrides + } +} + +function freshnessResult(skills: SkillFreshnessEntry[] = []): SkillFreshnessResult { + return { + skills, + scannedAt: Date.now(), + referenceRoot: null + } +} + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (reason?: unknown) => void +} { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +function Probe({ discoveryTarget }: { discoveryTarget?: SkillDiscoveryTarget }): null { + latestState = useOrcaSkillFreshness({ discoveryTarget }) + return null +} + +async function renderProbe(discoveryTarget?: SkillDiscoveryTarget): Promise { + if (!container) { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + } + await act(async () => { + root?.render() + }) +} + +function stubSkillsApi( + checkFreshness: (target?: SkillDiscoveryTarget) => Promise +): void { + Object.defineProperty(window, 'api', { + configurable: true, + value: { skills: { checkFreshness } } + }) +} + +afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + root = null + } + container?.remove() + container = null + latestState = null + _orcaSkillFreshnessInternalsForTests.reset() + vi.restoreAllMocks() + Reflect.deleteProperty(window, 'api') +}) + +describe('useOrcaSkillFreshness', () => { + it('loads host freshness and exposes outdated skills', async () => { + const result = freshnessResult([entry({})]) + const checkFreshness = vi.fn().mockResolvedValue(result) + stubSkillsApi(checkFreshness) + + await renderProbe() + await act(async () => { + await Promise.resolve() + }) + + expect(checkFreshness).toHaveBeenCalledTimes(1) + expect(latestState?.loading).toBe(false) + expect(latestState?.outdatedSkills.map((skill) => skill.skillName)).toEqual(['orca-cli']) + expect(latestState?.isSkillOutdated('orca-cli')).toBe(true) + }) + + it('reseeds state when discovery target switches host → WSL', async () => { + const hostResult = freshnessResult([ + entry({ skillName: 'orca-cli', status: 'outdated', expectedHash: 'host' }) + ]) + const wslResult = freshnessResult([ + entry({ + skillName: 'orca-cli', + status: 'current', + expectedHash: 'wsl', + installedHash: 'wsl', + divergingPaths: [] + }) + ]) + const checkFreshness = vi + .fn() + .mockResolvedValueOnce(hostResult) + .mockResolvedValueOnce(wslResult) + stubSkillsApi(checkFreshness) + + await renderProbe() + await act(async () => { + await Promise.resolve() + }) + expect(latestState?.isSkillOutdated('orca-cli')).toBe(true) + + await renderProbe({ runtime: 'wsl', wslDistro: 'Ubuntu' }) + await act(async () => { + await Promise.resolve() + }) + + expect(checkFreshness).toHaveBeenCalledTimes(2) + expect(latestState?.isSkillOutdated('orca-cli')).toBe(false) + expect(latestState?.getSkillEntry('orca-cli')?.status).toBe('current') + }) + + it('coalesces concurrent forced refreshes for the same target', async () => { + const initial = deferred() + const forced = deferred() + const checkFreshness = vi + .fn() + .mockReturnValueOnce(initial.promise) + .mockReturnValueOnce(forced.promise) + stubSkillsApi(checkFreshness) + + await renderProbe() + expect(checkFreshness).toHaveBeenCalledTimes(1) + + const first = latestState?.refresh() + const second = latestState?.refresh() + // Forced path waits on the initial in-flight scan; no extra call yet. + expect(checkFreshness).toHaveBeenCalledTimes(1) + + initial.resolve(freshnessResult([entry({})])) + await act(async () => { + await Promise.resolve() + }) + // One shared forced scan after the initial settles. + expect(checkFreshness).toHaveBeenCalledTimes(2) + + forced.resolve( + freshnessResult([entry({ status: 'current', installedHash: 'abc', divergingPaths: [] })]) + ) + await act(async () => { + await first + await second + }) + expect(latestState?.isSkillOutdated('orca-cli')).toBe(false) + }) + + it('skips scans for repair-required project runtimes', async () => { + const checkFreshness = vi.fn() + stubSkillsApi(checkFreshness) + + await renderProbe({ + projectRuntime: { + status: 'repair-required', + repair: { + projectId: 'repo-1', + preferredRuntime: { kind: 'wsl', distro: null }, + reason: 'wsl-distro-required', + source: 'project-override', + cacheKey: 'repo:repair' + } + } + }) + await act(async () => { + await Promise.resolve() + }) + + expect(checkFreshness).not.toHaveBeenCalled() + expect(latestState?.loading).toBe(false) + expect(latestState?.skills).toEqual([]) + }) +})