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..8e859bc4f03 --- /dev/null +++ b/config/scripts/generate-orca-skill-reference-hashes.mjs @@ -0,0 +1,126 @@ +#!/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 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, nextSource, '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') + // 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` + ) + } + 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/ipc/skills.test.ts b/src/main/ipc/skills.test.ts index 680163f8b5d..8b348dbcf7e 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 without walking project repos', async () => { + const handler = getFreshnessHandler() + await handler(null, undefined) + expect(checkFreshnessMock).toHaveBeenCalledWith({ homeDir: undefined }) + }) + 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..f22fbe5fddd 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,45 @@ 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) + // Why: freshness only hashes home installs — skip repo trees entirely. + return checkOrcaSkillFreshness({ + homeDir: context.homeDir + }) } ) } diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 8719cc528ab..bef23a0e996 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,12 @@ export const SKILL_METHODS: RpcMethod[] = [ ? discoverSkills({ repos: [], cwd }) : discoverSkills({ repos: runtime.listRepos() }) } + }), + defineMethod({ + name: 'skills.checkFreshness', + params: SkillDiscoveryParams, + // 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 new file mode 100644 index 00000000000..7c53b642577 --- /dev/null +++ b/src/main/skills/freshness.test.ts @@ -0,0 +1,191 @@ +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, + normalizeSkillMarkdownForHash +} from './freshness' + +const tempDirs: string[] = [] + +afterEach(async () => { + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) + tempDirs.length = 0 +}) + +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 +} + +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 and BOM so Windows and Unix installs compare equal', () => { + expect(hashSkillMarkdown('a\r\nb\n')).toBe(hashSkillMarkdown('a\nb\n')) + expect(hashSkillMarkdown('\uFEFFhello\n')).toBe(hashSkillMarkdown('hello\n')) + expect(normalizeSkillMarkdownForHash('\uFEFFx\r\ny')).toBe('x\ny') + }) +}) + +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-') + 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({ + 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) + expect(orcaCli?.divergingPaths.length).toBe(1) + }) + + 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({ + homeDir, + referenceRoot + }) + + 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 () => { + 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({ + homeDir, + referenceRoot + }) + + const computerUse = result.skills.find((skill) => skill.skillName === 'computer-use') + 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 }) + const skillFilePath = join(homeDir, '.agents', 'skills', 'orca-cli', 'SKILL.md') + await writeFile(skillFilePath, 'x'.repeat(300 * 1024), 'utf8') + + const result = await checkOrcaSkillFreshness({ + 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-') + 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 }) + await mkdir(join(repoDir, '.agents', 'skills'), { recursive: true }) + await writeSkill( + join(repoDir, '.agents', 'skills'), + 'orca-cli', + '---\nname: orca-cli\n---\nstale\n' + ) + + // Why: homeDir is empty of installs; repoDir is never scanned. + const result = await checkOrcaSkillFreshness({ + homeDir, + referenceRoot + }) + + const orcaCli = result.skills.find((skill) => skill.skillName === 'orca-cli') + expect(orcaCli?.status).toBe('missing') + }) + + 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' + 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({ + 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 new file mode 100644 index 00000000000..be279c01e1d --- /dev/null +++ b/src/main/skills/freshness.ts @@ -0,0 +1,214 @@ +import { createHash } from 'node:crypto' +import { open, readdir } from 'node:fs/promises' +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' + +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() +} + +/** Normalize line endings so macOS/Windows installs hash the same content. */ +export function normalizeSkillMarkdownForHash(content: string): string { + // 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 { + return createHash('sha256').update(normalizeSkillMarkdownForHash(content), 'utf8').digest('hex') +} + +type SkillFileHashOutcome = + | { kind: 'missing' } + | { kind: 'unreadable' } + | { kind: 'ok'; hash: string } + +async function inspectSkillFile(skillFilePath: string): Promise { + let file + try { + 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 + } + 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 { kind: 'unreadable' } + } finally { + await file.close() + } +} + +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 +} + +/** + * 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 { + entries = await readdir(referenceRoot) + } catch { + return hashes + } + + await Promise.all( + entries.map(async (entryName) => { + const skillFilePath = join(referenceRoot, entryName, SKILL_FILE_NAME) + 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 + readableCount: number + divergingCount: number +}): SkillFreshnessStatus { + if (!args.expectedHash) { + return 'unknown' + } + if (args.installedCount === 0) { + return 'missing' + } + // Why: present-but-unreadable must not drive install UX. + if (args.readableCount === 0) { + return 'unknown' + } + if (args.divergingCount > 0) { + return 'outdated' + } + return 'current' +} + +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 { + // 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: 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 candidatePaths = listManagedHomeSkillPaths(homeDir, definition.skillName) + const inspected = await Promise.all( + candidatePaths.map(async (skillFilePath) => ({ + path: skillFilePath, + outcome: await inspectSkillFile(skillFilePath) + })) + ) + 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.outcome.hash !== expectedHash + ) + const primary = diverging[0] ?? readable[0] + + return { + skillName: definition.skillName, + displayName: definition.displayName, + settingsSectionId: definition.settingsSectionId, + updateCommand: definition.updateCommand, + status: resolveStatus({ + expectedHash, + installedCount: installed.length, + readableCount: readable.length, + divergingCount: diverging.length + }), + expectedHash, + installedHash: primary?.outcome.hash ?? null, + installedPath: primary?.path ?? installed[0]?.path ?? null, + divergingPaths: diverging.map((entry) => entry.path) + } satisfies SkillFreshnessEntry + }) + ) + + return { + skills, + scannedAt: Date.now(), + referenceRoot + } +} 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..b9c40245e3f 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 release skill-hash catalog. */ + outdated?: boolean loading: boolean error: string | null installDisabled?: boolean @@ -39,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 @@ -58,6 +66,7 @@ export function AgentSkillSetupPanel({ terminalAriaLabel, terminalWorktreeId, installed, + outdated = false, loading, error, installDisabled = false, @@ -72,6 +81,7 @@ export function AgentSkillSetupPanel({ getPrerequisiteStatus, isPrerequisiteAvailable = isOrcaCliAvailableOnPath, onBeforeOpenTerminal, + onTerminalExit, showInstallWhenInstalled = true, showRecheckWhenInstalled = true, installLabel = 'Install', @@ -201,7 +211,9 @@ export function AgentSkillSetupPanel({ {terminalOpening ? translate('auto.components.settings.AgentSkillSetupPanel.5f818f12ab', 'Preparing...') : installed - ? installedInstallLabel + ? outdated + ? translate('auto.components.settings.AgentSkillSetupPanel.fb91e24fa5', 'Update') + : installedInstallLabel : installLabel} ) : null} @@ -263,6 +275,13 @@ export function AgentSkillSetupPanel({ 'Checking...' )} + ) : installed && outdated ? ( + + {translate( + 'auto.components.settings.AgentSkillSetupPanel.23b567b459', + 'Outdated' + )} + ) : installed ? ( {translate( @@ -350,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 20380332009..072d7864fcf 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -17,6 +17,8 @@ import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, 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' @@ -32,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 = { @@ -42,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, @@ -99,6 +79,9 @@ export function CliSection({ discoveryTarget: cliSkillDiscoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) + const { isSkillOutdated, getSkillEntry } = useOrcaSkillFreshness({ + discoveryTarget: cliSkillDiscoveryTarget + }) const cliSkillInstallCommand = buildSkillCommandForRuntime( ORCA_CLI_SKILL_INSTALL_COMMAND, agentRuntime @@ -377,6 +360,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} @@ -389,6 +373,15 @@ export function CliSection({ onStatusChange: handleStatusChange })) }} + 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), + 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 270e1785aef..43265d1fe93 100644 --- a/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx +++ b/src/renderer/src/components/settings/ComputerUseSkillSetupPanel.tsx @@ -12,7 +12,9 @@ import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, useInstalledAgentSkill } 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 { @@ -45,6 +47,9 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element { discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) + const { isSkillOutdated, getSkillEntry } = useOrcaSkillFreshness({ + discoveryTarget: activeSkillRuntime.discoveryTarget + }) return ( { + markOutdatedSkillUpdateAttemptIfNeeded( + COMPUTER_USE_SKILL_NAME, + isSkillOutdated(COMPUTER_USE_SKILL_NAME), + getSkillEntry(COMPUTER_USE_SKILL_NAME)?.expectedHash + ) + }} onRecheck={refreshComputerUseSkill} /> ) diff --git a/src/renderer/src/components/settings/EphemeralVmsPane.tsx b/src/renderer/src/components/settings/EphemeralVmsPane.tsx index 85b27f12ed7..270fe818a72 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.tsx @@ -21,7 +21,9 @@ import { GLOBAL_AGENT_SKILL_SOURCE_KINDS, useInstalledAgentSkill } 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, @@ -69,6 +71,9 @@ export function EphemeralVmsPane(): React.JSX.Element { discoveryTarget: activeSkillRuntime.discoveryTarget, sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS }) + const { isSkillOutdated, getSkillEntry } = useOrcaSkillFreshness({ + discoveryTarget: activeSkillRuntime.discoveryTarget + }) const refresh = useCallback(async (): Promise => { if (mountedRef.current) { @@ -145,6 +150,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)} @@ -162,6 +168,13 @@ export function EphemeralVmsPane(): React.JSX.Element { ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) : ensureOrcaCliAvailableForAgentSkillTerminal()) }} + onTerminalExit={() => { + markOutdatedSkillUpdateAttemptIfNeeded( + EPHEMERAL_VMS_SKILL_NAME, + isSkillOutdated(EPHEMERAL_VMS_SKILL_NAME), + getSkillEntry(EPHEMERAL_VMS_SKILL_NAME)?.expectedHash + ) + }} onRecheck={refreshSkill} /> diff --git a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx index b757261bba6..1775707a9d4 100644 --- a/src/renderer/src/components/settings/OrchestrationSetupCard.tsx +++ b/src/renderer/src/components/settings/OrchestrationSetupCard.tsx @@ -8,7 +8,10 @@ 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 { markOutdatedSkillUpdateAttemptIfNeeded } from '@/components/skills/mark-outdated-skill-update-attempt' import { AgentSkillSetupPanel } from './AgentSkillSetupPanel' import { buildSkillCommandForRuntime, @@ -25,6 +28,9 @@ export function OrchestrationSetupCard(props: { }): JSX.Element { const { compact, terminalHeightPx, skill } = props const activeSkillRuntime = useActiveProjectSkillRuntime() + const { isSkillOutdated, getSkillEntry } = useOrcaSkillFreshness({ + discoveryTarget: activeSkillRuntime.discoveryTarget + }) const installCommand = !activeSkillRuntime.installDisabledReason ? buildSkillCommandForRuntime( ORCHESTRATION_SKILL_INSTALL_COMMAND, @@ -56,6 +62,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)} @@ -74,6 +81,13 @@ export function OrchestrationSetupCard(props: { ? ensureWslCliAvailableForAgentSkillTerminal(activeSkillRuntime.agentRuntime) : ensureOrcaCliAvailableForAgentSkillTerminal()) }} + onTerminalExit={() => { + markOutdatedSkillUpdateAttemptIfNeeded( + ORCHESTRATION_SKILL_NAME, + isSkillOutdated(ORCHESTRATION_SKILL_NAME), + getSkillEntry(ORCHESTRATION_SKILL_NAME)?.expectedHash + ) + }} onRecheck={skill.refresh} /> ) diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 2d5a941226c..39afcdaed2c 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,46 @@ 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) { + sectionIds.add(skill.settingsSectionId) + } + 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 + loading: orchestrationSkillLoading, + outdated: outdatedSectionIds.has('orchestration') }) ] ]) + // 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' && !showDesktopOnlySettings) { + continue + } + if (sectionId === 'orchestration') { + continue + } + next.set(sectionId, 'outdated') + } if (showDesktopOnlySettings) { next.set( 'computer-use', getSkillNavInstallStatus({ installed: computerUseSkillInstalled, - loading: computerUseSkillLoading + loading: computerUseSkillLoading, + outdated: outdatedSectionIds.has('computer-use') }) ) if (settings) { @@ -703,6 +735,7 @@ function Settings(): React.JSX.Element { modelStates, orchestrationSkillInstalled, orchestrationSkillLoading, + outdatedSectionIds, settings, showDesktopOnlySettings, voiceModelStatesLoading 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..9ee5f36e7f1 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.23b567b459', '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/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/OutdatedSkillUpdateDialog.tsx b/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx new file mode 100644 index 00000000000..c04b881799d --- /dev/null +++ b/src/renderer/src/components/skills/OutdatedSkillUpdateDialog.tsx @@ -0,0 +1,119 @@ +import { useId } 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. + * + * 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 + onDismiss: () => void + onUpdate: () => void +}): React.JSX.Element { + const { skill, onDismiss, onUpdate } = props + const headingId = useId() + const updateStatus = useAppStore((s) => s.updateStatus) + // 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 { + await window.api.ui.writeClipboardText(skill.updateCommand) + toast.success( + translate( + 'auto.components.skills.OutdatedSkillUpdateDialog.7a5f26c79f', + 'Copied update command.' + ) + ) + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : translate( + 'auto.components.skills.OutdatedSkillUpdateDialog.7e6c0adbca', + 'Failed to copy update command.' + ) + ) + } + } + + return ( +
+ +
+
+

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

+ +
+ +

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

+ +

+ {translate( + 'auto.components.skills.OutdatedSkillUpdateDialog.f37228fd6f', + '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..0596f19f812 --- /dev/null +++ b/src/renderer/src/components/skills/OutdatedSkillUpdateHost.tsx @@ -0,0 +1,107 @@ +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. + * + * 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() + 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) { + return + } + if (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: 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) + } + suppressCurrent(activeSkill.skillName) + }, [activeSkill, suppressCurrent]) + + const handleUpdate = useCallback(() => { + if (!activeSkill) { + return + } + openSettingsTarget({ + pane: activeSkill.settingsSectionId as SettingsNavTarget, + repoId: null + }) + openSettingsPage() + // 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]) + + if (!activeSkill) { + return null + } + + return ( + + ) +} 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/components/skills/outdated-skill-reminder.test.ts b/src/renderer/src/components/skills/outdated-skill-reminder.test.ts new file mode 100644 index 00000000000..107f7e12276 --- /dev/null +++ b/src/renderer/src/components/skills/outdated-skill-reminder.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + _outdatedSkillReminderInternalsForTests, + dismissOutdatedSkillForHash, + isOutdatedSkillDismissedForHash, + isOutdatedSkillSnoozedForSession, + isOutdatedSkillUpdateAttemptedForHash, + markOutdatedSkillUpdateAttempted, + 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) + 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 new file mode 100644 index 00000000000..a43bc93d388 --- /dev/null +++ b/src/renderer/src/components/skills/outdated-skill-reminder.ts @@ -0,0 +1,101 @@ +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 +): 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. + } +} + +/** + * 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()) +} + +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 + } + 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 = { + reset(): void { + sessionSnoozedSkillNames.clear() + } +} 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([]) + }) +}) diff --git a/src/renderer/src/hooks/useOrcaSkillFreshness.ts b/src/renderer/src/hooks/useOrcaSkillFreshness.ts new file mode 100644 index 00000000000..412ed14291f --- /dev/null +++ b/src/renderer/src/hooks/useOrcaSkillFreshness.ts @@ -0,0 +1,298 @@ +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' + +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 + 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() +} + +/** + * 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) { + 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() ?? ''}` + } + return 'host' +} + +function isRepairRequiredTarget(target: SkillDiscoveryTarget | undefined): boolean { + return target?.projectRuntime?.status === 'repair-required' +} + +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) { + return cached + } + const inFlight = pendingFreshnessByTarget.get(key) + if (inFlight) { + return inFlight + } + return startFreshnessRequest(key, target) + } + + const existingForced = forcedFreshnessByTarget.get(key) + if (existingForced) { + return existingForced + } + + const forced = (async () => { + const inFlight = pendingFreshnessByTarget.get(key) + if (inFlight) { + try { + await inFlight + } catch { + // Previous failure should not block an explicit re-check. + } + } + return startFreshnessRequest(key, target) + })().finally(() => { + if (forcedFreshnessByTarget.get(key) === forced) { + forcedFreshnessByTarget.delete(key) + } + }) + + 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) { + cachedFreshnessByTarget.set(targetKey, result) + } else { + cachedFreshnessByTarget.clear() + } + } +} + +export function useOrcaSkillFreshness(options?: { + enabled?: boolean + discoveryTarget?: SkillDiscoveryTarget +}): OrcaSkillFreshnessState { + const enabled = options?.enabled ?? true + const discoveryTarget = options?.discoveryTarget + const discoveryTargetKey = getSkillDiscoveryTargetKey(discoveryTarget) + const mountedRef = useMountedRef() + 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) + 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 + const requestTargetKey = discoveryTargetKey + if (!enabled || isRepairRequiredTarget(discoveryTarget)) { + if (mountedRef.current) { + setLoading(false) + if (isRepairRequiredTarget(discoveryTarget)) { + setResult(null) + setError(null) + } + } + return null + } + if (mountedRef.current) { + setLoading(true) + } + try { + const next = await loadSkillFreshness(true, discoveryTarget) + if ( + mountedRef.current && + generation === generationRef.current && + currentTargetKeyRef.current === requestTargetKey + ) { + setResult(next) + setError(null) + } + return next + } catch (refreshError) { + 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 && + currentTargetKeyRef.current === requestTargetKey + ) { + setLoading(false) + } + } + }, [discoveryTarget, discoveryTargetKey, enabled, mountedRef]) + + useEffect(() => { + if (!enabled || isRepairRequiredTarget(discoveryTarget)) { + if (mountedRef.current) { + setLoading(false) + if (isRepairRequiredTarget(discoveryTarget)) { + setResult(null) + setError(null) + } + } + return + } + 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) { + return + } + const onChange = (): void => { + void refresh() + } + window.addEventListener('focus', onChange) + window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onChange) + return () => { + window.removeEventListener('focus', onChange) + window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onChange) + } + }, [enabled, refresh]) + + const skills = useMemo(() => (enabled && result ? result.skills : []), [enabled, result]) + + const outdatedSkills = useMemo( + () => skills.filter((skill) => skill.status === 'outdated' && shouldPromptOutdatedSkill(skill)), + [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..b6f988e327a 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,11 @@ function createSkillsApi(): NonNullable['skills']> { skills: [], sources: [], scannedAt: Date.now() - })) + })), + // 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) } } diff --git a/src/shared/orca-managed-skills.ts b/src/shared/orca-managed-skills.ts new file mode 100644 index 00000000000..c1876875505 --- /dev/null +++ b/src/shared/orca-managed-skills.ts @@ -0,0 +1,60 @@ +import { + COMPUTER_USE_SKILL_NAME, + COMPUTER_USE_SKILL_UPDATE_COMMAND, + EPHEMERAL_VMS_SKILL_NAME, + EPHEMERAL_VMS_SKILL_UPDATE_COMMAND, + ORCA_CLI_SKILL_NAME, + ORCA_CLI_SKILL_UPDATE_COMMAND, + ORCHESTRATION_SKILL_NAME, + ORCHESTRATION_SKILL_UPDATE_COMMAND +} from './agent-feature-install-commands' + +/** Settings nav section id that hosts the skill setup surface. */ +export type OrcaManagedSkillSettingsSectionId = + | 'general' + | 'orchestration' + | 'computer-use' + | 'experimental' + +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 hosts this skill. */ + settingsSectionId: OrcaManagedSkillSettingsSectionId + /** Global update command (npx skills update …). */ + updateCommand: string +} + +/** + * 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[] = [ + { + 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 + } +] 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 new file mode 100644 index 00000000000..8df09ab12ae --- /dev/null +++ b/src/shared/skill-freshness.ts @@ -0,0 +1,32 @@ +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 release catalog SKILL.md (expected). */ + expectedHash: string | null + /** + * 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 = { + skills: SkillFreshnessEntry[] + scannedAt: number + /** + * 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 +}