Add safe skill freshness detection and update rail (#8637)

* Add safe skill freshness detection

* Accept observed copy-mode rail outcomes

* chore(skills): regenerate snapshot artifacts for the merged guide content

The rebase onto main picked up the reviewed guide fixes (#8624), so the
current manifest hashes and a new appended snapshot generation must
match those bytes; the registry keeps all prior snapshots so existing
installs classify as outdated rather than unrecognized.

* fix(skills): canonicalize snapshot file order and guard released history

Historical snapshots kept git ls-tree byte-order while the working-tree
walk and runtime observation use the sorted depth-first order, so any
future multi-file skill would misclassify older installs as unrecognized
and churn spurious registry revisions; all producers now share one
canonical order (no digest changes for today's single-file packages).
Also rejects executable files from shipped skills (Windows observation
cannot see execute bits, which would misclassify pristine Windows
installs) and adds an explicit append-only invariant for released
snapshots so a generation-logic change cannot rewrite them silently.

* fix(skills): throttle focus rescans and correct self-blocked placement copy

Every window focus re-read and re-hashed all installed packages, and the
nudge and panel each forced their own trailing rescan for one event; a
15s cooldown plus a shared invalidation latch keep one bounded scan per
event while install-change events stay immediate. Bundle artifacts are
now loaded once per run instead of re-parsed on every scan. A read-only
or otherwise unsupported outdated placement now explains that it blocks
itself instead of blaming a phantom sibling placement; the supported
topology set moved to shared so eligibility and copy cannot drift.

* feat(skills): move freshness surfacing to a lingering toast and update modal

The Skills page has been unreachable since its toolbox menu entry was
removed (#4535), so surfacing freshness there buried the feature behind
its own nudge. The nudge now lingers until acted on (ignoring it records
nothing; only the explicit close persists dismissal keys) and opens an
update modal hosting the pre-filled editable terminal, an honest
current/blocked summary, and the per-placement rows in a collapsed
Details section. A compact 'Check for skill updates' row in CLI settings
is the manual re-entry point. Skills page restored to main; design-doc
surfacing section records the venue decision.

* fix(skills): mount update dialog inside the link-routing provider and fold freshness into the setup rails

The dialog hosts a live terminal pane that requires the link-routing
preference context; mounted outside the provider it crashed the renderer
the moment an eligible update existed (caught by live QA — unit tests
mock the terminal). It now mounts inside the provider behind its own
recoverable boundary.

The separate 'Check for skill updates' settings button is gone: the
setup rails' own pill now carries freshness (Update available / Up to
date, falling back to Installed for blocked or unrecognized copies and
for non-local runtimes the local-only scan cannot vouch for), and
Re-check refreshes both installation detection and the freshness
inventory. Wired for the CLI, Orchestration, Computer Use, and
Per-Workspace Environments rails.

* fix(skills): use the sleek scrollbar style in the update dialog

* chore(skills): regenerate manifest for merged main (v1.4.142-rc.1)

Main advanced to 1.4.142-rc.1 with a v1.4.141 release, so the embedded
appVersion and release mapping were stale on the PR's merged tree. Only
appVersion and the new release entry change; no snapshot digests move
(released history preserved).

* fix(skills): bound and batch freshness work

* fix(skills): harden freshness integrity checks

* fix(skills): accept observed copy topology outcomes

* chore(skills): regenerate manifest for current main

* fix(skills): preserve update terminal lifecycle

* chore(skills): regenerate manifest for current main

* fix(skills): fail closed on stale freshness scans

* chore(skills): regenerate manifest for current main

* fix(skills): preserve freshness safety under focus churn

* feat(skills): group the update modal by skill with plain-language status

The Update skills modal now lists only skills that will update or that can't
(with why), grouped by skill with their install locations nested underneath —
no more one row per placement.

- Statuses collapse to "Update available" / "Can't update" at the skill level.
- A location's problem is a chip (Duplicate, Unrecognized, Inaccessible, Read
  only, In a repo, External/Broken link, Plugin cache) with a hover tooltip
  that explains what it means for the user and what to do.
- Up-to-date, unrecognized-only, and unreadable-only skills are hidden; a
  current/unrecognized/etc. location only appears when it explains a shown skill.
- Copy is de-jargoned (drops "copy"/"placement"/"snapshot"/"official copy") and
  names the mechanism as the npx skills update command, not "Orca's update".
- Rename the section to "Update details"; drop the unreachable newer-known state.

Renderer-only: derivation is a pure module (groupSkillFreshness) with unit tests;
no IPC or main-process change. Locales updated for all five languages.

* chore(skills): regenerate manifest for current main (v1.4.143-rc.0)

* feat(skills): don't let a duplicate block the update; clearer skipped copy

- Eligibility: a clean standalone duplicate no longer poisons the whole
  name — the canonical copy still updates and the duplicate is flagged;
  a duplicate-only skill stays unoffered.
- Update modal: "Can't update" -> "Skipped" with a reason-specific
  sentence (edited/read-only/in-a-repo/plugin/link); chips describe only
  the location state; footer "Check now" -> "Re-check".
- Settings sidebar nav pills go amber "Update available" when a skill is
  updatable, matching the setup cards.
- Localized new strings across en/es/ja/ko/zh.

* chore(skills): regenerate manifest for merged main (v1.4.144-rc.1)
This commit is contained in:
Brennan Benson 2026-07-16 14:47:28 -07:00 committed by GitHub
parent f102972cc1
commit 68fca0b076
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
64 changed files with 7542 additions and 54 deletions

View File

@ -15,6 +15,11 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# Why: the freshness registry is derived from immutable release tags,
# so shallow PR checkouts cannot verify historical official identities.
fetch-depth: 0
persist-credentials: false
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh
@ -82,6 +87,9 @@ jobs:
- name: Verify bundled skill guides
run: pnpm run verify:bundled-skill-guides
- name: Verify skill freshness manifest
run: pnpm run verify:skill-bundle-manifest
# Why: project-owned type declarations must live in .ts so tsc
# actually checks them. TypeScript's skipLibCheck: true (inherited
# from @electron-toolkit/tsconfig) silently widens unresolved names

View File

@ -0,0 +1,50 @@
name: Skill update round trip
on:
pull_request:
paths:
- 'skills/**'
- 'resources/skills/**'
- 'config/scripts/verify-skill-update-roundtrip.mjs'
- 'src/main/skills/skill-freshness-eligibility.ts'
- 'src/shared/skill-freshness.ts'
- '.github/workflows/skill-update-roundtrip.yml'
merge_group:
push:
branches:
- main
jobs:
roundtrip:
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
shape: [symlink, copy]
autocrlf: ['false', 'true']
skills-cli: ['1.5.17']
include:
- os: ubuntu-latest
shape: symlink
autocrlf: 'false'
skills-cli: latest
continue-on-error: ${{ matrix.skills-cli == 'latest' }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Verify targeted update convergence and copy behavior
env:
GITHUB_TOKEN: ${{ github.token }}
SKILL_UPDATE_SOURCE: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
SKILL_UPDATE_REF: ${{ github.head_ref || github.ref_name }}
run: >-
node config/scripts/verify-skill-update-roundtrip.mjs
--cli=${{ matrix.skills-cli }}
--autocrlf=${{ matrix.autocrlf }}
--shape=${{ matrix.shape }}

View File

@ -18,6 +18,12 @@ const featureWallResources = {
from: 'resources/onboarding/feature-wall',
to: 'onboarding/feature-wall'
}
// Why: freshness detection needs immutable identity metadata from this exact
// app build, but never needs the skill package bytes or a runtime network read.
const skillFreshnessResources = {
from: 'resources/skills',
to: 'skills'
}
// 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 +37,11 @@ const relayExtraResource = {
// do not fall through to a developer checkout's node_modules.
const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources()
const commonExtraResources = [relayExtraResource, ...packagedRuntimeNodeModuleResources]
const commonExtraResources = [
relayExtraResource,
...packagedRuntimeNodeModuleResources,
skillFreshnessResources
]
const macSpeechNativeResource = {
from: 'node_modules/sherpa-onnx-darwin-${arch}',
to: 'node_modules/sherpa-onnx-darwin-${arch}'
@ -79,7 +89,8 @@ module.exports = {
'!tsconfig.json',
// Why: feature-wall media is copied via extraResources so runtime can read
// it from process.resourcesPath; exclude the source copy from app.asar.
'!resources/onboarding/feature-wall/**'
'!resources/onboarding/feature-wall/**',
'!resources/skills/**'
],
// Why: the CLI entry-point lives in out/cli/ but imports shared modules
// from out/shared/ and local hook mutators from out/main/. These paths must be

View File

@ -29,6 +29,7 @@ describe('electron-builder config', () => {
'!native{,/**/*}',
'!skills{,/**/*}',
'!skill-guides{,/**/*}',
'!resources/skills/**',
'!tests{,/**/*}',
'!pr-evidence{,/**/*}',
'!Casks{,/**/*}',
@ -39,6 +40,12 @@ describe('electron-builder config', () => {
})
it('keeps runtime resources available through extraResources', () => {
for (const platform of ['mac', 'linux', 'win']) {
expect(electronBuilderConfig[platform].extraResources).toContainEqual({
from: 'resources/skills',
to: 'skills'
})
}
expect(electronBuilderConfig.mac.extraResources).toEqual(
expect.arrayContaining([
expect.objectContaining({

View File

@ -0,0 +1,522 @@
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import { constants } from 'node:fs'
import { access, lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { isDeepStrictEqual } from 'node:util'
const SCHEMA_VERSION = 1
const SCRIPT_DIR = import.meta.dirname
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..')
const SKILLS_ROOT = path.join(REPO_ROOT, 'skills')
const OUTPUT_ROOT = path.join(REPO_ROOT, 'resources', 'skills')
const CURRENT_MANIFEST_PATH = path.join(OUTPUT_ROOT, 'current-manifest.json')
const SNAPSHOT_REGISTRY_PATH = path.join(OUTPUT_ROOT, 'snapshot-registry.json')
const RELEASE_MAPPING_PATH = path.join(OUTPUT_ROOT, 'release-mapping.json')
function sha256(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
function compareCodeUnits(left, right) {
return left === right ? 0 : left < right ? -1 : 1
}
function gitObjectSha(kind, bytes) {
return createHash('sha1').update(`${kind} ${bytes.length}\0`).update(bytes).digest()
}
function normalizeText(bytes) {
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8')
}
function classifyFile(bytes) {
if (bytes.includes(0)) {
return 'binary'
}
try {
normalizeText(bytes)
return 'text'
} catch {
return 'binary'
}
}
function assertSafeRelativePath(relativePath) {
if (
path.isAbsolute(relativePath) ||
relativePath === '..' ||
relativePath.startsWith(`..${path.sep}`)
) {
throw new Error(`Unsafe skill package path: ${relativePath}`)
}
}
function describeFile(manifestPath, bytes, executable) {
const classification = classifyFile(bytes)
const exactSha256 = sha256(bytes)
const textNormalizedSha256 = classification === 'text' ? sha256(normalizeText(bytes)) : null
return {
path: manifestPath,
size: bytes.length,
executable,
classification,
exactSha256,
textNormalizedSha256,
identitySha256: classification === 'text' && !executable ? textNormalizedSha256 : exactSha256,
gitBlobSha: gitObjectSha('blob', bytes).toString('hex')
}
}
function gitTreeSha(entries) {
const root = { directories: new Map(), files: [] }
for (const entry of entries) {
const parts = entry.path.split('/')
const filename = parts.pop()
let directory = root
for (const part of parts) {
let child = directory.directories.get(part)
if (!child) {
child = { directories: new Map(), files: [] }
directory.directories.set(part, child)
}
directory = child
}
directory.files.push({ filename, ...entry })
}
function hashDirectory(directory) {
const children = [
...[...directory.directories].map(([name, child]) => ({
mode: '40000',
name,
hash: hashDirectory(child)
})),
...directory.files.map((file) => ({
mode: file.executable ? '100755' : '100644',
name: file.filename,
hash: Buffer.from(file.gitBlobSha, 'hex')
}))
].sort((left, right) => {
const leftName = left.mode === '40000' ? `${left.name}/` : left.name
const rightName = right.mode === '40000' ? `${right.name}/` : right.name
return Buffer.from(leftName).compare(Buffer.from(rightName))
})
const body = Buffer.concat(
children.map(({ mode, name, hash }) =>
Buffer.concat([Buffer.from(`${mode} ${name}\0`, 'utf8'), hash])
)
)
return gitObjectSha('tree', body)
}
return hashDirectory(root).toString('hex')
}
async function collectPackageFiles(packageRoot) {
const files = []
const caseFoldedPaths = new Map()
async function visit(directory) {
const entries = await readdir(directory, { withFileTypes: true })
// Why: build-time Node and packaged Electron may ship different ICU data;
// package identity order must use the same locale-independent comparison.
entries.sort((left, right) => compareCodeUnits(left.name, right.name))
for (const entry of entries) {
const absolutePath = path.join(directory, entry.name)
const relativePath = path.relative(packageRoot, absolutePath)
assertSafeRelativePath(relativePath)
const manifestPath = relativePath.split(path.sep).join('/')
const foldedPath = manifestPath.toLocaleLowerCase('en-US')
const collision = caseFoldedPaths.get(foldedPath)
if (collision && collision !== manifestPath) {
throw new Error(`Case-colliding skill paths: ${collision} and ${manifestPath}`)
}
caseFoldedPaths.set(foldedPath, manifestPath)
const fileStat = await lstat(absolutePath)
if (fileStat.isSymbolicLink()) {
throw new Error(`Symlink is not allowed in a shipped skill: ${manifestPath}`)
}
if (fileStat.isDirectory()) {
await visit(absolutePath)
continue
}
if (!fileStat.isFile()) {
throw new Error(`Special file is not allowed in a shipped skill: ${manifestPath}`)
}
// Why: Windows observation cannot see execute bits, so an executable file in
// a shipped skill would misclassify every pristine Windows install as unrecognized.
if ((fileStat.mode & 0o111) !== 0) {
throw new Error(`Executable file is not allowed in a shipped skill: ${manifestPath}`)
}
files.push(describeFile(manifestPath, await readFile(absolutePath), false))
}
}
await visit(packageRoot)
return sortManifestFiles(files)
}
function collectGitSkillTreeEntries(treeSha) {
const output = execFileSync('git', ['ls-tree', '-r', '-z', treeSha])
.toString('utf8')
.split('\0')
.filter(Boolean)
const packages = new Map()
for (const line of output) {
const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(line)
if (!match) {
throw new Error(`Unexpected git tree entry in ${treeSha}: ${line}`)
}
const [, mode, type, objectSha, sourcePath] = match
const separator = sourcePath.indexOf('/')
if (separator <= 0 || separator === sourcePath.length - 1) {
throw new Error(`Unsupported shipped skill path in ${treeSha}: ${sourcePath}`)
}
const name = sourcePath.slice(0, separator)
const manifestPath = sourcePath.slice(separator + 1)
const entries = packages.get(name) ?? []
entries.push({ mode, type, objectSha, manifestPath })
packages.set(name, entries)
}
return packages
}
function readGitBlobs(objectShas) {
const uniqueShas = [...new Set(objectShas)]
if (uniqueShas.length === 0) {
return new Map()
}
// Why: released history spans hundreds of tags. Batch mode avoids a Git
// subprocess per historical file while remaining available on Git 2.25.
const output = execFileSync('git', ['cat-file', '--batch'], {
input: `${uniqueShas.join('\n')}\n`,
maxBuffer: 64 * 1024 * 1024
})
const blobs = new Map()
let offset = 0
for (const requestedSha of uniqueShas) {
const headerEnd = output.indexOf(10, offset)
if (headerEnd < 0) {
throw new Error(`Missing git cat-file header for ${requestedSha}`)
}
const header = output.subarray(offset, headerEnd).toString('utf8')
const match = /^([a-f0-9]+) blob (\d+)$/.exec(header)
if (!match || match[1] !== requestedSha) {
throw new Error(`Unexpected git cat-file header for ${requestedSha}: ${header}`)
}
const size = Number(match[2])
const contentStart = headerEnd + 1
const contentEnd = contentStart + size
if (contentEnd >= output.length || output[contentEnd] !== 10) {
throw new Error(`Truncated git blob for ${requestedSha}`)
}
blobs.set(requestedSha, Buffer.from(output.subarray(contentStart, contentEnd)))
offset = contentEnd + 1
}
return blobs
}
function collectGitPackageFiles(treeSha, name, entries, blobs) {
const caseFoldedPaths = new Map()
const files = entries.map(({ mode, type, objectSha, manifestPath }) => {
if (type !== 'blob' || (mode !== '100644' && mode !== '100755')) {
throw new Error(`Unsupported shipped skill entry in ${treeSha}: ${name}/${manifestPath}`)
}
assertSafeRelativePath(manifestPath)
const foldedPath = manifestPath.toLocaleLowerCase('en-US')
const collision = caseFoldedPaths.get(foldedPath)
if (collision && collision !== manifestPath) {
throw new Error(`Case-colliding skill paths in ${treeSha}: ${collision} and ${manifestPath}`)
}
caseFoldedPaths.set(foldedPath, manifestPath)
const bytes = blobs.get(objectSha)
if (!bytes) {
throw new Error(`Missing git blob ${objectSha} for ${name}/${manifestPath}`)
}
return describeFile(manifestPath, bytes, mode === '100755')
})
// Why: git ls-tree emits git byte-order, not the canonical walk order.
return sortManifestFiles(files)
}
// Why: snapshot matching compares files by array index, so every producer —
// working-tree walk, git history, and runtime observation — must emit one
// canonical order. This mirrors the sorted depth-first filesystem walk.
function compareManifestPaths(left, right) {
const leftParts = left.split('/')
const rightParts = right.split('/')
const shared = Math.min(leftParts.length, rightParts.length)
for (let index = 0; index < shared; index += 1) {
const order = compareCodeUnits(leftParts[index], rightParts[index])
if (order !== 0) {
return order
}
}
return leftParts.length - rightParts.length
}
function sortManifestFiles(files) {
return [...files].sort((left, right) => compareManifestPaths(left.path, right.path))
}
function packageDigest(files) {
return sha256(
Buffer.from(
JSON.stringify(
files.map((file) => ({
path: file.path,
executable: file.executable,
classification: file.classification,
identitySha256: file.identitySha256
}))
),
'utf8'
)
)
}
function releaseTags() {
return execFileSync(
'git',
['for-each-ref', '--sort=creatordate', '--format=%(refname:short)', 'refs/tags/v*'],
{ encoding: 'utf8' }
)
.split('\n')
.filter((tag) => /^v\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?$/.test(tag))
}
function skillsTreeShasAtRefs(refs) {
if (refs.length === 0) {
return []
}
const output = execFileSync('git', ['cat-file', '--batch-check=%(objectname) %(objecttype)'], {
input: `${refs.map((ref) => `${ref}:skills`).join('\n')}\n`,
encoding: 'utf8'
})
const lines = output.trimEnd().split('\n')
if (lines.length !== refs.length) {
throw new Error(`Expected ${refs.length} skills tree identities, received ${lines.length}`)
}
return lines.map((line, index) => {
if (line.endsWith(' missing')) {
return null
}
const match = /^([a-f0-9]+) tree$/.exec(line)
if (!match) {
throw new Error(`Unexpected skills tree identity at ${refs[index]}: ${line}`)
}
return match[1]
})
}
function buildReleasedHistory() {
const registry = { schemaVersion: SCHEMA_VERSION, skills: {} }
const mapping = { schemaVersion: SCHEMA_VERSION, releases: [] }
const tags = releaseTags()
const treeShas = skillsTreeShasAtRefs(tags)
const distinctTreeShas = [...new Set(treeShas.filter(Boolean))]
const packagesByTree = new Map(
distinctTreeShas.map((treeSha) => [treeSha, collectGitSkillTreeEntries(treeSha)])
)
const blobs = readGitBlobs(
[...packagesByTree.values()].flatMap((packages) =>
[...packages.values()].flatMap((entries) => entries.map((entry) => entry.objectSha))
)
)
let previousSkillsTreeSha = null
for (const [index, tag] of tags.entries()) {
const skillsTreeSha = treeShas[index]
if (!skillsTreeSha || skillsTreeSha === previousSkillsTreeSha) {
continue
}
previousSkillsTreeSha = skillsTreeSha
const revisions = {}
const packages = packagesByTree.get(skillsTreeSha)
if (!packages) {
throw new Error(`Missing released skill tree ${skillsTreeSha} at ${tag}`)
}
for (const name of [...packages.keys()].sort(compareCodeUnits)) {
const entries = packages.get(name)
const filesWithGitHashes = collectGitPackageFiles(skillsTreeSha, name, entries, blobs)
if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) {
continue
}
const digest = packageDigest(filesWithGitHashes)
const snapshots = registry.skills[name] ?? []
const latest = snapshots.at(-1)
if (!latest || latest.packageDigest !== digest) {
const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file)
snapshots.push({
releaseRevision: (latest?.releaseRevision ?? 0) + 1,
packageDigest: digest,
gitTreeSha: gitTreeSha(filesWithGitHashes),
files
})
registry.skills[name] = snapshots
}
revisions[name] = snapshots.at(-1).releaseRevision
}
if (Object.keys(revisions).length > 0) {
mapping.releases.push({ appVersion: tag.slice(1), skills: revisions })
}
}
return { registry, mapping }
}
async function buildArtifacts(appVersion) {
const { registry, mapping } = buildReleasedHistory()
const releasedSnapshotCounts = Object.fromEntries(
Object.entries(registry.skills).map(([name, snapshots]) => [name, snapshots.length])
)
const skillDirectories = (await readdir(SKILLS_ROOT, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort(compareCodeUnits)
const currentSkills = []
for (const name of skillDirectories) {
const filesWithGitHashes = await collectPackageFiles(path.join(SKILLS_ROOT, name))
if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) {
throw new Error(`Skill package ${name} has no top-level SKILL.md`)
}
const digest = packageDigest(filesWithGitHashes)
const snapshots = registry.skills[name] ?? []
const latest = snapshots.at(-1)
let snapshot = latest
if (!latest || latest.packageDigest !== digest) {
const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file)
snapshot = {
releaseRevision: (latest?.releaseRevision ?? 0) + 1,
packageDigest: digest,
gitTreeSha: gitTreeSha(filesWithGitHashes),
files
}
snapshots.push(snapshot)
registry.skills[name] = snapshots
}
currentSkills.push({
name,
sourcePath: `skills/${name}`,
appVersion,
...snapshot
})
}
return {
currentManifest: {
schemaVersion: SCHEMA_VERSION,
appVersion,
skills: currentSkills
},
snapshotRegistry: registry,
releaseMapping: mapping,
releasedSnapshotCounts
}
}
// Why: released snapshots are the detection ground truth for existing installs,
// so a generation-logic change must not rewrite them silently. Only the one
// unreleased working-tree append per skill may change between runs.
function assertReleasedHistoryPreserved(committedRegistry, artifacts) {
if (!committedRegistry || committedRegistry.schemaVersion !== SCHEMA_VERSION) {
return
}
for (const [name, committedSnapshots] of Object.entries(committedRegistry.skills ?? {})) {
const releasedCount = artifacts.releasedSnapshotCounts[name] ?? 0
const regenerated = artifacts.snapshotRegistry.skills[name] ?? []
if (releasedCount < Math.max(0, committedSnapshots.length - 1)) {
throw new Error(
`Released snapshot history is incomplete for ${name}. ` +
'Fetch all release tags before regenerating skill artifacts.'
)
}
const protectedCount = Math.min(committedSnapshots.length, releasedCount)
for (let index = 0; index < protectedCount; index += 1) {
const committed = committedSnapshots[index]
const rebuilt = regenerated[index]
if (!rebuilt || !isDeepStrictEqual(rebuilt, committed)) {
throw new Error(
`Released snapshot history changed for ${name} at revision ${committed.releaseRevision}. ` +
'Released snapshots are append-only; a deliberate identity migration must update this check.'
)
}
}
}
}
async function readCommittedRegistry() {
try {
return JSON.parse(await readFile(SNAPSHOT_REGISTRY_PATH, 'utf8'))
} catch {
return null
}
}
function serialized(value) {
return `${JSON.stringify(value, null, 2)}\n`
}
async function writeArtifacts(artifacts) {
await mkdir(OUTPUT_ROOT, { recursive: true })
await Promise.all([
writeFile(CURRENT_MANIFEST_PATH, serialized(artifacts.currentManifest)),
writeFile(SNAPSHOT_REGISTRY_PATH, serialized(artifacts.snapshotRegistry)),
writeFile(RELEASE_MAPPING_PATH, serialized(artifacts.releaseMapping))
])
}
async function verifyArtifacts(artifacts) {
const expected = [
[CURRENT_MANIFEST_PATH, artifacts.currentManifest],
[SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry],
[RELEASE_MAPPING_PATH, artifacts.releaseMapping]
]
const stale = []
for (const [filePath, value] of expected) {
try {
await access(filePath, constants.R_OK)
if ((await readFile(filePath, 'utf8')) !== serialized(value)) {
stale.push(filePath)
}
} catch {
stale.push(filePath)
}
}
if (stale.length > 0) {
throw new Error(
`Generated skill artifacts are stale:\n${stale
.map((filePath) => path.relative(REPO_ROOT, filePath))
.join('\n')}\nRun pnpm generate:skill-bundle-manifest.`
)
}
}
async function main() {
const packageJson = JSON.parse(await readFile(path.join(REPO_ROOT, 'package.json'), 'utf8'))
const artifacts = await buildArtifacts(packageJson.version)
assertReleasedHistoryPreserved(await readCommittedRegistry(), artifacts)
await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts)
}
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
}
export {
assertReleasedHistoryPreserved,
buildArtifacts,
buildReleasedHistory,
classifyFile,
collectPackageFiles,
describeFile,
gitTreeSha,
normalizeText,
packageDigest,
sortManifestFiles,
verifyArtifacts,
writeArtifacts
}

View File

@ -0,0 +1,199 @@
import { execFileSync } from 'node:child_process'
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
assertReleasedHistoryPreserved,
classifyFile,
collectPackageFiles,
describeFile,
gitTreeSha,
normalizeText,
packageDigest,
sortManifestFiles
} from './generate-skill-bundle-manifest.mjs'
const temporaryDirectories = []
async function createPackage() {
const directory = await mkdtemp(path.join(tmpdir(), 'orca-skill-manifest-'))
temporaryDirectories.push(directory)
return directory
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
)
})
describe('skill bundle manifest generator', () => {
it('folds platform line endings for text identity', () => {
const lf = Buffer.from('first\nsecond\n')
const crlf = Buffer.from('first\r\nsecond\r\n')
expect(classifyFile(lf)).toBe('text')
expect(normalizeText(crlf)).toEqual(lf)
})
it('classifies null-containing and invalid UTF-8 content as binary', () => {
expect(classifyFile(Buffer.from([0, 1, 2]))).toBe('binary')
expect(classifyFile(Buffer.from([0xc3, 0x28]))).toBe('binary')
})
it('uses normalized text identity but exact executable identity', () => {
const skillFile = describeFile('SKILL.md', Buffer.from('line one\r\nline two\r\n'), false)
const executable = describeFile('run.sh', Buffer.from('#!/bin/sh\r\necho ok\r\n'), true)
expect(skillFile.identitySha256).toBe(skillFile.textNormalizedSha256)
expect(skillFile.identitySha256).not.toBe(skillFile.exactSha256)
expect(executable.exactSha256).not.toBe(executable.textNormalizedSha256)
expect(executable.identitySha256).toBe(executable.exactSha256)
expect(packageDigest([skillFile, executable])).toMatch(/^[a-f0-9]{64}$/)
})
it('orders git-history files identically to the filesystem walk', async () => {
const packageRoot = await createPackage()
await mkdir(path.join(packageRoot, 'sub'))
for (const name of ['apple.md', 'sub.md', 'Zebra.md', path.join('sub', 'inner.txt')]) {
await writeFile(path.join(packageRoot, name), `${name}\n`)
}
const walked = await collectPackageFiles(packageRoot)
// Why: git ls-tree emits [Zebra.md, apple.md, sub.md, sub/inner.txt]; index-based
// snapshot matching requires history and observation to share one order.
const gitOrdered = ['Zebra.md', 'apple.md', 'sub.md', 'sub/inner.txt'].map((manifestPath) =>
walked.find((file) => file.path === manifestPath)
)
expect(sortManifestFiles(gitOrdered)).toEqual(walked)
expect(packageDigest(sortManifestFiles(gitOrdered))).toBe(packageDigest(walked))
expect(walked.map((file) => file.path)).toEqual([
'Zebra.md',
'apple.md',
'sub/inner.txt',
'sub.md'
])
})
it('rejects rewrites of released snapshots and allows floating-tail replacement', () => {
const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest })
const artifacts = {
releasedSnapshotCounts: { 'orca-cli': 2 },
snapshotRegistry: {
schemaVersion: 1,
skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'ccc')] }
}
}
expect(() =>
assertReleasedHistoryPreserved(
{ schemaVersion: 1, skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb')] } },
artifacts
)
).not.toThrow()
expect(() =>
assertReleasedHistoryPreserved(
{
schemaVersion: 1,
skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')] }
},
artifacts
)
).not.toThrow()
expect(() =>
assertReleasedHistoryPreserved(
{
schemaVersion: 1,
skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'rewritten')] }
},
artifacts
)
).toThrow('Released snapshot history changed for orca-cli at revision 2')
expect(() =>
assertReleasedHistoryPreserved(
{
schemaVersion: 1,
skills: {
'orca-cli': [snapshot(1, 'aaa'), { ...snapshot(2, 'bbb'), gitTreeSha: 'rewritten' }]
}
},
artifacts
)
).toThrow('Released snapshot history changed for orca-cli at revision 2')
expect(() =>
assertReleasedHistoryPreserved(
{
schemaVersion: 1,
skills: {
'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')]
}
},
{ ...artifacts, releasedSnapshotCounts: { 'orca-cli': 1 } }
)
).toThrow('Released snapshot history is incomplete for orca-cli')
expect(() => assertReleasedHistoryPreserved(null, artifacts)).not.toThrow()
})
it.runIf(process.platform !== 'win32')(
'rejects executable files in shipped skill packages',
async () => {
const packageRoot = await createPackage()
await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill\n')
await writeFile(path.join(packageRoot, 'run.sh'), '#!/bin/sh\necho ok\n')
await chmod(path.join(packageRoot, 'run.sh'), 0o755)
await expect(collectPackageFiles(packageRoot)).rejects.toThrow(
'Executable file is not allowed in a shipped skill: run.sh'
)
}
)
it.runIf(process.platform === 'linux')('rejects case-colliding paths', async () => {
const packageRoot = await createPackage()
await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill')
await writeFile(path.join(packageRoot, 'Readme.md'), 'one')
await writeFile(path.join(packageRoot, 'README.md'), 'two')
await expect(collectPackageFiles(packageRoot)).rejects.toThrow('Case-colliding skill paths')
})
it.runIf(process.platform !== 'win32')('rejects symlinks inside shipped packages', async () => {
const packageRoot = await createPackage()
await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill')
await symlink('SKILL.md', path.join(packageRoot, 'linked.md'))
await expect(collectPackageFiles(packageRoot)).rejects.toThrow(
'Symlink is not allowed in a shipped skill'
)
})
it('computes the same Git tree identity as Git', async () => {
const packageRoot = path.resolve('skills', 'orca-cli')
const files = await collectPackageFiles(packageRoot)
const expected = execFileSync('git', ['ls-tree', 'HEAD:skills', 'orca-cli'], {
encoding: 'utf8'
})
.trim()
.split(/\s+/)[2]
expect(gitTreeSha(files)).toBe(expected)
})
it('matches Git when a directory and file share a name prefix', async () => {
const packageRoot = await createPackage()
await mkdir(path.join(packageRoot, 'sub'))
await writeFile(path.join(packageRoot, 'sub', 'inner.txt'), 'nested\n')
await writeFile(path.join(packageRoot, 'sub.md'), 'sibling\n')
const files = await collectPackageFiles(packageRoot)
execFileSync('git', ['init', '--quiet'], { cwd: packageRoot })
execFileSync('git', ['add', '-A'], { cwd: packageRoot })
const expected = execFileSync('git', ['write-tree'], {
cwd: packageRoot,
encoding: 'utf8'
}).trim()
expect(gitTreeSha(files)).toBe(expected)
})
})

View File

@ -0,0 +1,249 @@
import { execFileSync } from 'node:child_process'
import {
chmod,
cp,
lstat,
mkdir,
mkdtemp,
readFile,
realpath,
rm,
symlink,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { collectPackageFiles, packageDigest } from './generate-skill-bundle-manifest.mjs'
function option(name) {
return process.argv.find((value) => value.startsWith(`--${name}=`))?.slice(name.length + 3)
}
const cliVersion = option('cli')
const autocrlf = option('autocrlf')
const shape = option('shape')
// Why: PR branch names are untrusted workflow input. Keep them out of the
// generated shell command and pass them to Node through the environment.
const source = option('source') ?? process.env.SKILL_UPDATE_SOURCE
const ref = option('ref') ?? process.env.SKILL_UPDATE_REF
if (
!cliVersion ||
(autocrlf !== 'true' && autocrlf !== 'false') ||
(shape !== 'symlink' && shape !== 'copy') ||
!source ||
!ref ||
!/^[^/\s]+\/[^/\s]+$/.test(source)
) {
throw new Error(
'Usage: verify-skill-update-roundtrip.mjs --cli=<version> --autocrlf=true|false --shape=symlink|copy --source=<owner/repo> --ref=<git-ref>'
)
}
const sandbox = await mkdtemp(path.join(tmpdir(), 'orca-skill-update-roundtrip-'))
const home = path.join(sandbox, 'home')
const stateHome = path.join(home, '.state')
const fakeBin = path.join(sandbox, 'bin')
const targetName = 'orca-cli'
const controlName = 'orchestration'
const manifest = JSON.parse(await readFile('resources/skills/current-manifest.json', 'utf8'))
const registry = JSON.parse(await readFile('resources/skills/snapshot-registry.json', 'utf8'))
const releaseMapping = JSON.parse(await readFile('resources/skills/release-mapping.json', 'utf8'))
function currentSkill(name) {
const skill = manifest.skills.find((entry) => entry.name === name)
if (!skill) {
throw new Error(`Current manifest is missing ${name}`)
}
return skill
}
function historicalRelease(name) {
const current = currentSkill(name)
for (const release of releaseMapping.releases.toReversed()) {
const revision = release.skills[name]
if (typeof revision !== 'number' || revision >= current.releaseRevision) {
continue
}
const snapshot = registry.skills[name]?.find((entry) => entry.releaseRevision === revision)
if (snapshot) {
return { tag: `v${release.appVersion}`, snapshot }
}
}
throw new Error(`No historical released snapshot is available for ${name}`)
}
async function materializePackage(name, tag, destination) {
const prefix = `skills/${name}/`
const entries = execFileSync('git', ['ls-tree', '-r', '-z', tag, '--', `skills/${name}`])
.toString('utf8')
.split('\0')
.filter(Boolean)
if (entries.length === 0) {
throw new Error(`${tag} does not contain ${name}`)
}
for (const entry of entries) {
const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(entry)
if (!match || match[2] !== 'blob') {
throw new Error(`Unsupported historical tree entry: ${entry}`)
}
const relativePath = match[4].slice(prefix.length)
const destinationPath = path.join(destination, ...relativePath.split('/'))
await mkdir(path.dirname(destinationPath), { recursive: true })
await writeFile(destinationPath, execFileSync('git', ['cat-file', 'blob', match[3]]))
if (process.platform !== 'win32' && match[1] === '100755') {
await chmod(destinationPath, 0o755)
}
}
}
async function seedPlacement(name, tag) {
const canonical = path.join(home, '.agents', 'skills', name)
await materializePackage(name, tag, canonical)
const providerRoot = path.join(home, '.claude', 'skills')
const provider = path.join(providerRoot, name)
await mkdir(providerRoot, { recursive: true })
await (shape === 'copy'
? cp(canonical, provider, { recursive: true })
: symlink(canonical, provider, process.platform === 'win32' ? 'junction' : 'dir'))
}
async function installFakeAgentCommands() {
await mkdir(fakeBin, { recursive: true })
for (const name of ['codex', 'claude']) {
const executable = path.join(fakeBin, process.platform === 'win32' ? `${name}.cmd` : name)
await writeFile(
executable,
process.platform === 'win32' ? '@exit /b 0\r\n' : '#!/bin/sh\nexit 0\n'
)
if (process.platform !== 'win32') {
await chmod(executable, 0o755)
}
}
}
async function packageDigestAt(pathValue) {
return packageDigest(await collectPackageFiles(pathValue))
}
async function assertCurrentCanonical(name) {
const expected = currentSkill(name).packageDigest
const canonical = path.join(home, '.agents', 'skills', name)
if ((await packageDigestAt(canonical)) !== expected) {
throw new Error(`${name} canonical placement did not update to the PR content`)
}
}
function execSkills(args) {
const executable = process.platform === 'win32' ? (process.env.ComSpec ?? 'cmd.exe') : 'npx'
const cliArgs = ['--yes', `skills@${cliVersion}`, ...args]
execFileSync(
executable,
process.platform === 'win32' ? ['/d', '/s', '/c', 'npx.cmd', ...cliArgs] : cliArgs,
{
cwd: process.cwd(),
env: {
...process.env,
HOME: home,
USERPROFILE: home,
CODEX_HOME: path.join(home, '.codex'),
CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
XDG_STATE_HOME: stateHome,
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'core.autocrlf',
GIT_CONFIG_VALUE_0: autocrlf,
PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ''}`,
CI: '1'
},
stdio: 'inherit'
}
)
}
try {
const targetHistorical = historicalRelease(targetName)
const controlHistorical = historicalRelease(controlName)
await installFakeAgentCommands()
await mkdir(path.join(home, '.codex'), { recursive: true })
await mkdir(path.join(home, '.claude'), { recursive: true })
await seedPlacement(targetName, targetHistorical.tag)
await seedPlacement(controlName, controlHistorical.tag)
const targetProvider = path.join(home, '.claude', 'skills', targetName)
const controlCanonical = path.join(home, '.agents', 'skills', controlName)
const controlProvider = path.join(home, '.claude', 'skills', controlName)
const targetProviderBefore = await packageDigestAt(await realpath(targetProvider))
const controlBefore = await packageDigestAt(controlCanonical)
const controlProviderBefore = await packageDigestAt(await realpath(controlProvider))
const timestamp = new Date().toISOString()
const lock = {
version: 3,
skills: {
[targetName]: {
source,
sourceType: 'github',
sourceUrl: `https://github.com/${source}.git`,
ref,
skillPath: `skills/${targetName}/SKILL.md`,
skillFolderHash: targetHistorical.snapshot.gitTreeSha,
installedAt: timestamp,
updatedAt: timestamp
},
[controlName]: {
source,
sourceType: 'github',
sourceUrl: `https://github.com/${source}.git`,
ref,
skillPath: `skills/${controlName}/SKILL.md`,
skillFolderHash: controlHistorical.snapshot.gitTreeSha,
installedAt: timestamp,
updatedAt: timestamp
}
}
}
const lockPath = path.join(stateHome, 'skills', '.skill-lock.json')
await mkdir(path.dirname(lockPath), { recursive: true })
await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`)
// Why: this is the exact user-visible rail. A bare update would include
// unrelated vendors, while this command must leave the control skill alone.
execSkills(['update', targetName, '--global'])
await assertCurrentCanonical(targetName)
const targetProviderAfter = await packageDigestAt(await realpath(targetProvider))
const targetProviderStat = await lstat(targetProvider)
if (shape === 'symlink' && !targetProviderStat.isSymbolicLink()) {
throw new Error(`${targetName} provider alias was replaced with an independent copy`)
}
if (shape === 'symlink' && targetProviderAfter !== currentSkill(targetName).packageDigest) {
throw new Error(`${targetName} provider alias did not converge with the canonical update`)
}
if (
shape === 'copy' &&
targetProviderAfter !== targetProviderBefore &&
targetProviderAfter !== currentSkill(targetName).packageDigest
) {
throw new Error('Independent provider copy changed to an unexpected package identity')
}
if (shape === 'copy') {
// Why: hosted 1.5.17 replaces copies with aliases while equivalent local runs
// retain the copy. Both prove this input topology must remain ineligible.
const outcome = targetProviderStat.isSymbolicLink()
? 'converged to an alias'
: targetProviderAfter === targetProviderBefore
? 'remained a historical copy'
: 'converged as a copy'
console.log(`[skill-update-roundtrip] independent copy ${outcome}`)
}
if ((await packageDigestAt(controlCanonical)) !== controlBefore) {
throw new Error('Targeted update changed the non-targeted control skill')
}
if ((await packageDigestAt(await realpath(controlProvider))) !== controlProviderBefore) {
throw new Error('Targeted update changed the non-targeted control provider placement')
}
const controlProviderStat = await lstat(controlProvider)
if (shape === 'symlink' && !controlProviderStat.isSymbolicLink()) {
throw new Error('Targeted update changed the non-targeted control topology')
}
} finally {
await rm(sandbox, { recursive: true, force: true })
}

View File

@ -126,6 +126,7 @@ Rules:
### C. Read-only detection (kept from Phase 1, slimmed)
Kept as-is:
- Bundled `skills/` packages + current manifest + released-snapshot registry + release
mapping, with the generation script and merge-queue monotonicity gate (static data + CI,
not runtime machinery).
@ -135,9 +136,12 @@ Kept as-is:
plugin caches and repo scopes excluded), and the launch / focus / post-install triggers.
- The skills-CLI round-trip CI on macOS/Linux/Windows — extended from current-install tests
to historical-fat-install → targeted global update → stub migration. The matrix covers
copy/symlink shapes, LF/CRLF, supported lock migrations, and post-update identity.
LF/CRLF and provider aliases as a positive convergence contract, plus independent-copy
observation that accepts only unchanged historical or exact-current bytes. Post-update
bytes, not exit status, decide.
Slimmed:
- Statuses collapse to: `current`, `outdated` (exact match of an older released snapshot),
`newer-known`, `unrecognized`, and `inaccessible`. Without a ledger, Orca cannot honestly
distinguish a locally modified official copy from unrelated same-named content;
@ -151,32 +155,51 @@ Slimmed:
### D. Surfacing
- **Settings rows** (read-only): name, status badge, one-line explanation. `newer-known`,
`unrecognized`, `inaccessible`, and unsupported-topology rows are informational.
- **Name-scoped update eligibility:** the skills CLI reinstalls every placement of a selected
skill name, so eligibility is computed across all discovered placements of that name, not
per row. Offer a name only when at least one placement is `outdated` and every placement is
an exact `current` or `outdated` official snapshot in a supported global topology. One
`newer-known`, unrecognized, external, read-only, inaccessible, or otherwise unsupported
provider copy poisons the update offer for that name entirely.
- **Surfaces (venue decision 2026-07-14):** a lingering toast, an update modal, and the
existing Settings setup rails for CLI, Orchestration, Computer Use, and Per-Workspace
Environments. Their installed pills carry safe freshness status, while their existing
Update and Re-check actions remain the per-skill path. The Skills page was
de-linked by #4535 (2026-06-02) — its only entry, the sidebar toolbox menu, was removed —
so it is no longer a venue; the freshness surface moved off it entirely. The behavior
contracts below (name-scoped eligibility, no auto-run, dismissal keys, re-inventory
triggers) are unchanged; only the venue moved.
- **Per-placement rows** (read-only): name, status badge, one-line explanation. `newer-known`,
`unrecognized`, `inaccessible`, and unsupported-topology rows are informational. They live in
the modal's collapsed **Details** section (auto-expanded when a placement is blocked).
- **Name-scoped update eligibility:** eligibility is computed across all discovered placements
of a name, not per row. Offer a name only when at least one placement is `outdated` and every
placement is an exact `current` or `outdated` official snapshot in a topology the validated
rail actually converges. With skills CLI 1.5.17 that means the canonical global copy and
provider aliases to it. Independent provider copies are informational and poison the offer:
empirical copy-mode testing produced both stale and converged provider copies in otherwise
equivalent 1.5.17 environments, so that topology is not deterministic enough to offer. One
`newer-known`, unrecognized, external, read-only,
inaccessible, repo/plugin, independent-copy, or otherwise unsupported placement poisons the
update offer for that name entirely.
- The action combines only eligible outdated Orca names into
`npx skills update <names...> --global`, opens the existing run-command terminal with that
command pre-filled, and leaves execution to the user. Never use an unscoped bulk update and
never auto-submit the command. Re-inventory after terminal exit or focus; only observed
bytes, not the skills CLI exit status, determine success.
- **One non-repeating nudge**: count only eligible outdated skill names and offer the same
targeted run-the-command action. An outdated name poisoned by another placement remains
visible in settings but never produces an unsafe nudge action. Dismissal is recorded per
(install, bundled revision), so a newly outdated official placement or genuinely newer
stub revision may prompt once more. No toggle — nothing automatic happens that would need
one.
`npx skills update <names...> --global` and opens the update modal's editable terminal with
that command pre-filled, leaving execution to the user. Never use an unscoped bulk update and
never auto-submit the command. Re-inventory after terminal exit, modal close, or focus; only
observed bytes, not the skills CLI exit status, determine success. When the eligible set
empties and every placement is `current`, the modal shows an up-to-date state; if placements
remain outdated-but-blocked or unrecognized, it says so honestly instead.
- **One lingering, non-repeating nudge**: count only eligible outdated skill names and offer the
same targeted action, which opens the update modal. The toast lingers (no auto-close) until the
user opens the modal or explicitly dismisses it; ignoring it (app quit) records nothing, so a
still-outdated skill may prompt once more next launch. A later inventory that resolves or blocks
the offered tuple retracts the stale toast without recording a dismissal. An outdated name
poisoned by another placement remains visible in the modal's Details but never produces an
unsafe nudge action. Dismissal is recorded per (physical identity, name, bundled revision) only
on explicit dismissal, so a newly outdated official placement or genuinely newer stub revision
may prompt once more. No
toggle — nothing automatic happens that would need one.
### E. Migration (fat → stub)
1. **Implemented, pending release:** from a fresh main-based PR, add authoritative guide
sources, generated embedded data, `orca skills list/get`, aliases, generated-output checks,
and local/SSH/WSL/dev tests. Keep distributed skills fat and ship this release first.
2. From a separate PR, land slim read-only detection and settings/nudge UI, including the
2. From a separate PR, land slim read-only detection and Skills-page/nudge UI, including the
name-scoped targeted update action and the real migration-rail CI. Keep distributed
skills fat.
3. Run the pointer-compliance spike against the released guide-serving binary, not a checkout
@ -211,15 +234,17 @@ not thin, until the relevant variant passes.
global Windows failures, missing global lock tracking, lossy lock migration, and copy-mode
topology changes. The historical-fat → targeted-global-update → stub CI is a release gate,
not an early-warning job. Detection always re-checks bytes after the user updates, so a
failed or no-op update re-surfaces `outdated` instead of lying. Choose and document a
validated CLI-version policy before rollout; monitor and contribute upstream fixes.
failed or no-op update re-surfaces `outdated` instead of lying. Minimum validated version is
1.5.17: 1.5.16 failed the provider-alias convergence contract, while 1.5.17 copy convergence
still varies by environment. CI pins 1.5.17 and probes latest; monitor and contribute upstream
fixes before broadening eligibility.
- **Trigger-copy iteration slows.** Improvements to stub descriptions reach existing
installs only when users run the npx command. Acceptable at stub-change cadence; the
compiled guides (the content that matters) are exempt by construction.
- **Multi-file skills.** Current shipped packages are single-file. If a future skill needs
scripts/assets, either the binary serves them (`--full` / `--script`) or that skill
accepts the fat-file decay model. Decide when it happens.
- **Remote hosts.** Detection ships local-host-only. Stubs make remote *content* a non-issue:
- **Remote hosts.** Detection ships local-host-only. Stubs make remote _content_ a non-issue:
SSH/WSL launchers forward to the host's bundled CLI, so the guide matches the command
surface that will handle subsequent requests. Remote stub installs can lag on trigger
copy, which is the accepted residual. The WSL/SSH reconciler phases of the old design are

View File

@ -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:skill-bundle-manifest && 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:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs --write",
"verify:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs",
"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",

View File

@ -0,0 +1,158 @@
{
"schemaVersion": 1,
"appVersion": "1.4.144-rc.1",
"skills": [
{
"name": "computer-use",
"sourcePath": "skills/computer-use",
"appVersion": "1.4.144-rc.1",
"releaseRevision": 5,
"packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68",
"gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344",
"files": [
{
"path": "SKILL.md",
"size": 11241,
"executable": false,
"classification": "text",
"exactSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39",
"textNormalizedSha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39",
"identitySha256": "f49b29fb6b209956907688692387adcdc509fad344555f09badaf383106f5f39"
}
]
},
{
"name": "linear-tickets",
"sourcePath": "skills/linear-tickets",
"appVersion": "1.4.144-rc.1",
"releaseRevision": 4,
"packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b",
"gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176",
"files": [
{
"path": "SKILL.md",
"size": 10596,
"executable": false,
"classification": "text",
"exactSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5",
"textNormalizedSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5",
"identitySha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5"
}
]
},
{
"name": "orca-cli",
"sourcePath": "skills/orca-cli",
"appVersion": "1.4.144-rc.1",
"releaseRevision": 32,
"packageDigest": "51740ff13f379ac5743d3fd28a14b17168dcef40f7048c20182dce166098c45f",
"gitTreeSha": "ded93000a5f654e2b4f324501282459bd56afe19",
"files": [
{
"path": "SKILL.md",
"size": 21557,
"executable": false,
"classification": "text",
"exactSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be",
"textNormalizedSha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be",
"identitySha256": "b4c36e19fc158fc8c286bdfdf05a537985cb2159a596c93862968c5417bf15be"
}
]
},
{
"name": "orca-emulator",
"sourcePath": "skills/orca-emulator",
"appVersion": "1.4.144-rc.1",
"releaseRevision": 4,
"packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b",
"gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656",
"files": [
{
"path": "SKILL.md",
"size": 11527,
"executable": false,
"classification": "text",
"exactSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429",
"textNormalizedSha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429",
"identitySha256": "84dbfacf6854874e369840c011e78603e533273fb848d21dac3cfb08e0346429"
}
]
},
{
"name": "orca-emulator-android",
"sourcePath": "skills/orca-emulator-android",
"appVersion": "1.4.144-rc.1",
"releaseRevision": 2,
"packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5",
"gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83",
"files": [
{
"path": "SKILL.md",
"size": 8886,
"executable": false,
"classification": "text",
"exactSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332",
"textNormalizedSha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332",
"identitySha256": "1035d4db357923e98d5075c0c21bc9995b00a36a3739543516fe45ae5ded0332"
}
]
},
{
"name": "orca-linear",
"sourcePath": "skills/orca-linear",
"appVersion": "1.4.144-rc.1",
"releaseRevision": 2,
"packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f",
"gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8",
"files": [
{
"path": "SKILL.md",
"size": 10320,
"executable": false,
"classification": "text",
"exactSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5",
"textNormalizedSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5",
"identitySha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5"
}
]
},
{
"name": "orca-per-workspace-env",
"sourcePath": "skills/orca-per-workspace-env",
"appVersion": "1.4.144-rc.1",
"releaseRevision": 2,
"packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d",
"gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650",
"files": [
{
"path": "SKILL.md",
"size": 43769,
"executable": false,
"classification": "text",
"exactSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7",
"textNormalizedSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7",
"identitySha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7"
}
]
},
{
"name": "orchestration",
"sourcePath": "skills/orchestration",
"appVersion": "1.4.144-rc.1",
"releaseRevision": 24,
"packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9",
"gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982",
"files": [
{
"path": "SKILL.md",
"size": 22861,
"executable": false,
"classification": "text",
"exactSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5",
"textNormalizedSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5",
"identitySha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5"
}
]
}
]
}

View File

@ -0,0 +1,501 @@
{
"schemaVersion": 1,
"releases": [
{
"appVersion": "1.0.81",
"skills": {
"orca-cli": 1
}
},
{
"appVersion": "1.0.86",
"skills": {
"orca-cli": 2
}
},
{
"appVersion": "1.0.94-rc.0",
"skills": {
"orca-cli": 3
}
},
{
"appVersion": "1.3.8-rc.1",
"skills": {
"orca-cli": 4
}
},
{
"appVersion": "1.3.12-rc.4",
"skills": {
"orca-cli": 5
}
},
{
"appVersion": "1.3.24-rc.2",
"skills": {
"orca-cli": 6,
"orchestration": 1
}
},
{
"appVersion": "1.3.33-rc.3",
"skills": {
"mobile-fit-debug": 1,
"orca-cli": 6,
"orchestration": 1
}
},
{
"appVersion": "0.0.1",
"skills": {
"orca-cli": 6,
"orchestration": 1
}
},
{
"appVersion": "1.3.35-rc.0",
"skills": {
"mobile-fit-debug": 1,
"orca-cli": 6,
"orchestration": 1
}
},
{
"appVersion": "1.3.38-rc.3",
"skills": {
"orca-cli": 6,
"orchestration": 1
}
},
{
"appVersion": "1.3.38",
"skills": {
"mobile-fit-debug": 1,
"orca-cli": 6,
"orchestration": 1
}
},
{
"appVersion": "1.3.39",
"skills": {
"orca-cli": 6,
"orchestration": 1
}
},
{
"appVersion": "1.3.46-rc.3",
"skills": {
"orca-cli": 7,
"orchestration": 2
}
},
{
"appVersion": "1.3.47-rc.0",
"skills": {
"orca-cli": 8,
"orchestration": 3
}
},
{
"appVersion": "1.3.49-rc.0",
"skills": {
"computer-use": 1,
"orca-cli": 8,
"orchestration": 3
}
},
{
"appVersion": "1.4.2-rc.10",
"skills": {
"computer-use": 1,
"orca-cli": 9,
"orchestration": 4
}
},
{
"appVersion": "1.4.2",
"skills": {
"computer-use": 1,
"orca-cli": 10,
"orchestration": 5
}
},
{
"appVersion": "1.4.3-rc.0",
"skills": {
"computer-use": 1,
"orca-cli": 11,
"orchestration": 6
}
},
{
"appVersion": "1.4.9-rc.1",
"skills": {
"computer-use": 1,
"orca-cli": 12,
"orchestration": 6
}
},
{
"appVersion": "1.4.10-rc.3",
"skills": {
"computer-use": 1,
"orca-cli": 13,
"orchestration": 6
}
},
{
"appVersion": "1.4.11-rc.0",
"skills": {
"computer-use": 1,
"orca-cli": 14,
"orchestration": 6
}
},
{
"appVersion": "1.4.18-rc.2",
"skills": {
"computer-use": 1,
"orca-cli": 15,
"orchestration": 6
}
},
{
"appVersion": "1.4.18-rc.3",
"skills": {
"computer-use": 1,
"orca-cli": 16,
"orchestration": 6
}
},
{
"appVersion": "1.4.22",
"skills": {
"computer-use": 1,
"orca-cli": 16,
"orchestration": 7
}
},
{
"appVersion": "1.4.36-rc.2",
"skills": {
"computer-use": 1,
"orca-cli": 17,
"orchestration": 8
}
},
{
"appVersion": "1.4.36-rc.6",
"skills": {
"computer-use": 1,
"orca-cli": 18,
"orchestration": 9
}
},
{
"appVersion": "1.4.42-rc.8",
"skills": {
"computer-use": 2,
"orca-cli": 19,
"orchestration": 10
}
},
{
"appVersion": "1.4.46-rc.0",
"skills": {
"computer-use": 2,
"orca-cli": 19,
"orchestration": 11
}
},
{
"appVersion": "1.4.51-rc.8",
"skills": {
"computer-use": 3,
"orca-cli": 19,
"orchestration": 11
}
},
{
"appVersion": "1.4.51-rc.9",
"skills": {
"computer-use": 3,
"orca-cli": 20,
"orca-emulator": 1,
"orchestration": 12
}
},
{
"appVersion": "1.4.65-rc.3",
"skills": {
"computer-use": 3,
"linear-tickets": 1,
"orca-cli": 20,
"orca-emulator": 1,
"orchestration": 12
}
},
{
"appVersion": "1.4.65",
"skills": {
"computer-use": 3,
"orca-cli": 20,
"orca-emulator": 1,
"orchestration": 12
}
},
{
"appVersion": "1.4.66-rc.0",
"skills": {
"computer-use": 3,
"linear-tickets": 1,
"orca-cli": 20,
"orca-emulator": 1,
"orchestration": 12
}
},
{
"appVersion": "1.4.67-rc.0",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 20,
"orca-emulator": 1,
"orchestration": 12
}
},
{
"appVersion": "1.4.68",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 20,
"orca-emulator": 1,
"orchestration": 13
}
},
{
"appVersion": "1.4.74-rc.0",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 21,
"orca-emulator": 1,
"orchestration": 14
}
},
{
"appVersion": "1.4.78-rc.1",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 22,
"orca-emulator": 1,
"orchestration": 15
}
},
{
"appVersion": "1.4.78-rc.2",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 23,
"orca-emulator": 1,
"orchestration": 16
}
},
{
"appVersion": "1.4.81-rc.2",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 24,
"orca-emulator": 1,
"orchestration": 16
}
},
{
"appVersion": "1.4.81-rc.3",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 25,
"orca-emulator": 1,
"orchestration": 16
}
},
{
"appVersion": "1.4.88",
"skills": {
"computer-use": 3,
"linear-tickets": 2,
"orca-cli": 26,
"orca-emulator": 1,
"orchestration": 17
}
},
{
"appVersion": "1.4.90",
"skills": {
"computer-use": 3,
"linear-tickets": 3,
"orca-cli": 26,
"orca-emulator": 1,
"orca-linear": 1,
"orchestration": 17
}
},
{
"appVersion": "1.4.92-rc.1",
"skills": {
"computer-use": 3,
"linear-tickets": 3,
"orca-cli": 27,
"orca-emulator": 1,
"orca-linear": 1,
"orchestration": 17
}
},
{
"appVersion": "1.4.96-rc.1",
"skills": {
"computer-use": 3,
"linear-tickets": 3,
"orca-cli": 28,
"orca-emulator": 1,
"orca-linear": 1,
"orchestration": 17
}
},
{
"appVersion": "1.4.105-rc.2",
"skills": {
"computer-use": 3,
"linear-tickets": 3,
"orca-cli": 29,
"orca-emulator": 2,
"orca-emulator-android": 1,
"orca-linear": 1,
"orchestration": 18
}
},
{
"appVersion": "1.4.111-rc.0",
"skills": {
"computer-use": 3,
"linear-tickets": 3,
"orca-cli": 29,
"orca-emulator": 2,
"orca-emulator-android": 1,
"orca-linear": 1,
"orca-per-workspace-env": 1,
"orchestration": 18
}
},
{
"appVersion": "1.4.124-rc.6",
"skills": {
"computer-use": 3,
"linear-tickets": 3,
"orca-cli": 29,
"orca-emulator": 2,
"orca-emulator-android": 1,
"orca-linear": 1,
"orca-per-workspace-env": 2,
"orchestration": 18
}
},
{
"appVersion": "1.4.124-rc.9",
"skills": {
"computer-use": 3,
"linear-tickets": 3,
"orca-cli": 29,
"orca-emulator": 2,
"orca-emulator-android": 1,
"orca-linear": 1,
"orca-per-workspace-env": 2,
"orchestration": 19
}
},
{
"appVersion": "1.4.124",
"skills": {
"computer-use": 3,
"linear-tickets": 4,
"orca-cli": 29,
"orca-emulator": 2,
"orca-emulator-android": 1,
"orca-linear": 2,
"orca-per-workspace-env": 2,
"orchestration": 20
}
},
{
"appVersion": "1.4.137-rc.1",
"skills": {
"computer-use": 3,
"linear-tickets": 4,
"orca-cli": 30,
"orca-emulator": 2,
"orca-emulator-android": 1,
"orca-linear": 2,
"orca-per-workspace-env": 2,
"orchestration": 21
}
},
{
"appVersion": "1.4.138-rc.0",
"skills": {
"computer-use": 4,
"linear-tickets": 4,
"orca-cli": 31,
"orca-emulator": 3,
"orca-emulator-android": 1,
"orca-linear": 2,
"orca-per-workspace-env": 2,
"orchestration": 22
}
},
{
"appVersion": "1.4.138-rc.3",
"skills": {
"computer-use": 4,
"linear-tickets": 4,
"orca-cli": 31,
"orca-emulator": 3,
"orca-emulator-android": 1,
"orca-linear": 2,
"orca-per-workspace-env": 2,
"orchestration": 23
}
},
{
"appVersion": "1.4.138-rc.4",
"skills": {
"computer-use": 4,
"linear-tickets": 4,
"orca-cli": 31,
"orca-emulator": 3,
"orca-emulator-android": 1,
"orca-linear": 2,
"orca-per-workspace-env": 2,
"orchestration": 24
}
},
{
"appVersion": "1.4.141",
"skills": {
"computer-use": 5,
"linear-tickets": 4,
"orca-cli": 32,
"orca-emulator": 4,
"orca-emulator-android": 2,
"orca-linear": 2,
"orca-per-workspace-env": 2,
"orchestration": 24
}
}
]
}

File diff suppressed because it is too large Load Diff

View File

@ -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,
inventorySkillFreshnessMock,
getDefaultWslDistroMock,
getWslHomeMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
discoverSkillsMock: vi.fn(),
inventorySkillFreshnessMock: 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/skill-freshness-inventory', () => ({
inventorySkillFreshness: inventorySkillFreshnessMock
}))
vi.mock('../wsl', () => ({
getDefaultWslDistro: getDefaultWslDistroMock,
getWslHome: getWslHomeMock
@ -39,6 +48,12 @@ describe('registerSkillsHandlers', () => {
getDefaultWslDistroMock.mockReset()
getWslHomeMock.mockReset()
discoverSkillsMock.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 })
inventorySkillFreshnessMock.mockResolvedValue({
schemaVersion: 1,
installations: [],
eligibleUpdateNames: [],
scannedAt: 1
})
getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\alice')
Object.defineProperty(process, 'platform', {
configurable: true,
@ -61,6 +76,17 @@ describe('registerSkillsHandlers', () => {
return call[1] as (_event: unknown, target?: unknown) => Promise<unknown>
}
function getFreshnessHandler() {
registerSkillsHandlers(store as never)
const call = handleMock.mock.calls.find(
(entry: unknown[]) => entry[0] === 'skills:freshnessInventory'
)
if (!call) {
throw new Error('skills:freshnessInventory handler was not registered')
}
return call[1] as (_event: unknown) => Promise<unknown>
}
it('uses host skill discovery when resolved project runtime overrides stale WSL target state', async () => {
const handler = getDiscoverHandler()
@ -136,4 +162,13 @@ describe('registerSkillsHandlers', () => {
).rejects.toThrow('Project runtime requires repair before skill discovery')
expect(discoverSkillsMock).not.toHaveBeenCalled()
})
it('keeps freshness inventory local and read-only over known repositories', async () => {
const handler = getFreshnessHandler()
await handler(null)
expect(inventorySkillFreshnessMock).toHaveBeenCalledWith({ repos })
expect(getWslHomeMock).not.toHaveBeenCalled()
})
})

View File

@ -2,7 +2,9 @@ import { ipcMain } from 'electron'
import type { Store } from '../persistence'
import { discoverSkills } from '../skills/discovery'
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills'
import type { SkillFreshnessInventory } from '../../shared/skill-freshness'
import { getDefaultWslDistro, getWslHome } from '../wsl'
import { inventorySkillFreshness } from '../skills/skill-freshness-inventory'
type SkillDiscoveryRuntimeTarget =
| { runtime: 'host' }
@ -55,4 +57,10 @@ export function registerSkillsHandlers(store: Store): void {
return cwd ? discoverSkills({ repos: [], cwd }) : discoverSkills({ repos: store.getRepos() })
}
)
ipcMain.handle('skills:freshnessInventory', async (): Promise<SkillFreshnessInventory> => {
// Why: the update command targets this machine's global homes. WSL and SSH
// inventories stay out until their installer rail has an equivalent proof.
return inventorySkillFreshness({ repos: store.getRepos() })
})
}

View File

@ -83,6 +83,41 @@ describe('skill discovery', () => {
}
})
it('does not add runtime-owned repository paths to local scan roots', () => {
const runtimeRepo = makeRepo('/runtime/repo')
runtimeRepo.executionHostId = 'runtime:environment-1'
const roots = buildSkillDiscoverySources({
homeDir: '/home/test',
cwd: '/workspace/current',
repos: [runtimeRepo]
})
expect(roots.map((root) => root.path.replace(/\\/g, '/'))).not.toContain(
'/runtime/repo/.agents/skills'
)
})
it('can exclude the implicit cwd without excluding explicit local repositories', () => {
const defaultRoots = buildSkillDiscoverySources({
homeDir: '/home/test',
cwd: '/workspace/current',
repos: [makeRepo('/workspace/known')]
})
const explicitRoots = buildSkillDiscoverySources({
homeDir: '/home/test',
cwd: '/workspace/current',
repos: [makeRepo('/workspace/known')],
includeCwd: false
})
const normalizedDefaultPaths = defaultRoots.map((root) => root.path.replace(/\\/g, '/'))
const normalizedExplicitPaths = explicitRoots.map((root) => root.path.replace(/\\/g, '/'))
expect(normalizedDefaultPaths).toContain('/workspace/current/.agents/skills')
expect(normalizedExplicitPaths).not.toContain('/workspace/current/.agents/skills')
expect(normalizedExplicitPaths).toContain('/workspace/known/.agents/skills')
})
it('discovers skill packages through symlinked skill directories', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
const home = join(root, 'home')

View File

@ -234,6 +234,7 @@ export async function discoverSkills(args: {
repos?: Repo[]
homeDir?: string
cwd?: string
includeCwd?: boolean
}): Promise<SkillDiscoveryResult> {
const roots = buildSkillDiscoverySources(args)
const sources: SkillDiscoverySource[] = []

View File

@ -0,0 +1,37 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { loadSkillBundleArtifacts } from './skill-bundle-artifacts'
const temporaryDirectories: string[] = []
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true })))
})
describe('skill bundle artifacts', () => {
it('rejects malformed nested release entries before building provenance', async () => {
const resourceRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-'))
temporaryDirectories.push(resourceRoot)
const target = join(resourceRoot, 'skills')
const source = resolve('resources', 'skills')
await mkdir(target, { recursive: true })
const [manifest, registry, releaseMapping] = await Promise.all(
['current-manifest.json', 'snapshot-registry.json', 'release-mapping.json'].map((name) =>
readFile(join(source, name), 'utf8')
)
)
const malformedMapping = JSON.parse(releaseMapping)
malformedMapping.releases[0] = { appVersion: 'invalid' }
await Promise.all([
writeFile(join(target, 'current-manifest.json'), manifest),
writeFile(join(target, 'snapshot-registry.json'), registry),
writeFile(join(target, 'release-mapping.json'), JSON.stringify(malformedMapping))
])
await expect(loadSkillBundleArtifacts(resourceRoot)).rejects.toThrow(
'Invalid skill release mapping'
)
})
})

View File

@ -0,0 +1,164 @@
import { app } from 'electron'
import { readFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { z, type ZodType } from 'zod'
import type {
SkillBundleManifest,
SkillKnownSnapshot,
SkillReleaseMapping,
SkillSnapshotRegistry
} from '../../shared/skill-freshness'
export type SkillBundleArtifacts = {
manifest: SkillBundleManifest
registry: SkillSnapshotRegistry
releaseMapping: SkillReleaseMapping
knownSnapshots: Record<string, SkillKnownSnapshot[]>
releasedAppVersions: Record<string, Record<number, string>>
}
const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/)
const snapshotShape = {
releaseRevision: z.number().int().positive(),
packageDigest: sha256Schema,
gitTreeSha: z.string().regex(/^[a-f0-9]{40}$/),
files: z
.array(
z
.object({
path: z.string().min(1),
size: z.number().int().nonnegative(),
executable: z.boolean(),
classification: z.enum(['text', 'binary']),
exactSha256: sha256Schema,
textNormalizedSha256: sha256Schema.nullable(),
identitySha256: sha256Schema
})
.strict()
)
.min(1)
}
const knownSnapshotSchema = z.object(snapshotShape).strict()
const manifestSchema = z
.object({
schemaVersion: z.literal(1),
appVersion: z.string().min(1),
skills: z.array(
z
.object({
name: z.string().regex(/^[a-z0-9][a-z0-9._-]*$/),
sourcePath: z.string().min(1),
appVersion: z.string().min(1),
...snapshotShape
})
.strict()
)
})
.strict()
const registrySchema = z
.object({
schemaVersion: z.literal(1),
skills: z.record(z.string().min(1), z.array(knownSnapshotSchema).min(1))
})
.strict()
const releaseMappingSchema = z
.object({
schemaVersion: z.literal(1),
releases: z.array(
z
.object({
appVersion: z.string().min(1),
skills: z.record(z.string().min(1), z.number().int().positive())
})
.strict()
)
})
.strict()
function parseArtifact<T>(schema: ZodType<T>, value: unknown, label: string): T {
const result = schema.safeParse(value)
if (!result.success) {
throw new Error(`Invalid ${label}: ${result.error.issues[0]?.message ?? 'schema mismatch'}`)
}
return result.data
}
const artifactsByResourceRoot = new Map<string, Promise<SkillBundleArtifacts>>()
// Why: the artifacts ship with the binary and never change within a run, while
// focus-triggered rescans would otherwise re-read and re-parse them every time.
export function loadSkillBundleArtifacts(
resourceRoot = app.isPackaged ? process.resourcesPath : resolve(process.cwd(), 'resources')
): Promise<SkillBundleArtifacts> {
const cached = artifactsByResourceRoot.get(resourceRoot)
if (cached) {
return cached
}
const loading = readSkillBundleArtifacts(resourceRoot)
artifactsByResourceRoot.set(resourceRoot, loading)
loading.catch(() => {
artifactsByResourceRoot.delete(resourceRoot)
})
return loading
}
async function readSkillBundleArtifacts(resourceRoot: string): Promise<SkillBundleArtifacts> {
const bundleRoot = join(resourceRoot, 'skills')
const [manifestValue, registryValue, releaseMappingValue] = await Promise.all([
readFile(join(bundleRoot, 'current-manifest.json'), 'utf8').then(JSON.parse),
readFile(join(bundleRoot, 'snapshot-registry.json'), 'utf8').then(JSON.parse),
readFile(join(bundleRoot, 'release-mapping.json'), 'utf8').then(JSON.parse)
])
const manifest: SkillBundleManifest = parseArtifact(
manifestSchema,
manifestValue,
'skill bundle manifest'
)
const registry: SkillSnapshotRegistry = parseArtifact(
registrySchema,
registryValue,
'skill snapshot registry'
)
const releaseMapping: SkillReleaseMapping = parseArtifact(
releaseMappingSchema,
releaseMappingValue,
'skill release mapping'
)
for (const current of manifest.skills) {
if (
current.appVersion !== manifest.appVersion ||
!registry.skills[current.name]?.some(
(snapshot) =>
snapshot.releaseRevision === current.releaseRevision &&
snapshot.packageDigest === current.packageDigest
)
) {
throw new Error(`Inconsistent current skill snapshot: ${current.name}`)
}
}
const releasedAppVersions: Record<string, Record<number, string>> = {}
for (const release of releaseMapping.releases) {
for (const [name, revision] of Object.entries(release.skills)) {
if (!registry.skills[name]?.some((snapshot) => snapshot.releaseRevision === revision)) {
throw new Error(`Unknown released skill revision: ${name}@${revision}`)
}
releasedAppVersions[name] ??= {}
releasedAppVersions[name][revision] ??= release.appVersion
}
}
for (const current of manifest.skills) {
releasedAppVersions[current.name] ??= {}
releasedAppVersions[current.name][current.releaseRevision] = current.appVersion
}
return {
manifest,
registry,
releaseMapping,
// Why: newer-known classification needs every identity packaged with this
// build, while release mapping remains the provenance record for shipped revisions.
knownSnapshots: registry.skills,
releasedAppVersions
}
}

View File

@ -0,0 +1,24 @@
const MAX_CONCURRENT_SKILL_CANDIDATES = 4
export async function runSkillCandidateTasks<T>(
tasks: readonly (() => Promise<T>)[]
): Promise<T[]> {
const results = Array.from<T>({ length: tasks.length })
let nextIndex = 0
async function worker(): Promise<void> {
for (;;) {
const index = nextIndex
nextIndex += 1
if (index >= tasks.length) {
return
}
results[index] = await tasks[index]()
}
}
await Promise.all(
Array.from({ length: Math.min(MAX_CONCURRENT_SKILL_CANDIDATES, tasks.length) }, () => worker())
)
return results
}

View File

@ -3,6 +3,7 @@ import { homedir } from 'node:os'
import { basename, join } from 'node:path'
import type { SkillDiscoverySource, SkillProvider, SkillSourceKind } from '../../shared/skills'
import type { Repo } from '../../shared/types'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
export type SkillScanRoot = Omit<SkillDiscoverySource, 'exists' | 'skippedReason'>
@ -25,6 +26,7 @@ export function buildSkillDiscoverySources(
homeDir?: string
cwd?: string
repos?: Repo[]
includeCwd?: boolean
} = {}
): SkillScanRoot[] {
const home = args.homeDir ?? homedir()
@ -62,12 +64,16 @@ export function buildSkillDiscoverySources(
const projectPaths = new Set<string>()
for (const repo of args.repos ?? []) {
if (repo.connectionId) {
// Why: runtime-owned repos can have no legacy connectionId while their
// paths are meaningful only on a remote host.
if (getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) {
continue
}
projectPaths.add(repo.path)
}
projectPaths.add(cwd)
if (args.includeCwd !== false) {
projectPaths.add(cwd)
}
for (const repoPath of projectPaths) {
const label = `Repo ${basename(repoPath)}`

View File

@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest'
import {
buildTargetedSkillUpdateCommand,
type SkillFreshnessInstallation
} from '../../shared/skill-freshness'
import { eligibleSkillUpdateNames } from './skill-freshness-eligibility'
function placement(
name: string,
overrides: Partial<SkillFreshnessInstallation> = {}
): SkillFreshnessInstallation {
return {
id: `${name}-${overrides.rootId ?? 'home-agents'}`,
name,
rootId: 'home-agents',
providers: ['agent-skills'],
sourceKind: 'home',
sourceLabel: 'Agent skills home',
unresolvedPath: `/home/.agents/skills/${name}`,
resolvedPath: `/home/.agents/skills/${name}`,
physicalIdentity: `physical-${name}`,
topology: 'canonical-copy',
status: 'outdated',
installedReleaseRevision: 1,
installedAppVersion: '1.0.0',
currentReleaseRevision: 2,
currentPackageDigest: 'current',
currentAppVersion: '2.0.0',
observedPackageDigest: 'old',
errorCategory: null,
...overrides
}
}
describe('skill freshness name-scoped update eligibility', () => {
it('offers a name when at least one supported placement is outdated and all are official', () => {
expect(
eligibleSkillUpdateNames([
placement('orca-cli'),
placement('orca-cli', {
id: 'orca-cli-claude',
rootId: 'home-claude',
topology: 'provider-alias',
status: 'current'
})
])
).toEqual(['orca-cli'])
})
it.each([
['newer-known', 'independent-copy'],
['unrecognized', 'independent-copy'],
['inaccessible', 'broken-link'],
['current', 'external-link'],
['current', 'read-only'],
['current', 'repo-scope'],
['current', 'plugin-cache']
] as const)('poisons a name for a %s placement in %s topology', (status, topology) => {
expect(
eligibleSkillUpdateNames([
placement('orca-cli'),
placement('orca-cli', { id: `poison-${status}-${topology}`, status, topology })
])
).toEqual([])
})
it('still updates the canonical copy when a clean standalone duplicate exists', () => {
// Why: a duplicate no longer omits the whole name — the canonical copy converges
// and the duplicate row is flagged as maybe-not-reached rather than blocking.
expect(
eligibleSkillUpdateNames([
placement('orca-cli'),
placement('orca-cli', {
id: 'orca-cli-gemini',
rootId: 'home-gemini',
unresolvedPath: '/home/.gemini/skills/orca-cli',
resolvedPath: '/home/.gemini/skills/orca-cli',
topology: 'independent-copy',
status: 'current'
})
])
).toEqual(['orca-cli'])
})
it('does not offer a skill that exists only as a standalone copy', () => {
// Why: with no canonical or alias to anchor `--global`, the command has no
// reliable target, so a duplicate-only skill stays unoffered.
expect(
eligibleSkillUpdateNames([
placement('orca-cli', {
rootId: 'home-gemini',
unresolvedPath: '/home/.gemini/skills/orca-cli',
resolvedPath: '/home/.gemini/skills/orca-cli',
topology: 'independent-copy',
status: 'outdated'
})
])
).toEqual([])
})
it('does not offer an all-current name or let another safe name hide a poisoned one', () => {
expect(
eligibleSkillUpdateNames([
placement('computer-use', { status: 'current' }),
placement('orchestration'),
placement('orchestration', {
id: 'orchestration-project',
status: 'unrecognized',
topology: 'repo-scope'
})
])
).toEqual([])
})
it('builds only an explicit, deterministic global command', () => {
expect(buildTargetedSkillUpdateCommand(['orchestration', 'orca-cli', 'orca-cli'])).toBe(
'npx skills update orca-cli orchestration --global'
)
expect(buildTargetedSkillUpdateCommand([])).toBeNull()
expect(buildTargetedSkillUpdateCommand(['orca-cli;echo unsafe'])).toBeNull()
})
})

View File

@ -0,0 +1,41 @@
import {
SUPPORTED_GLOBAL_SKILL_TOPOLOGIES,
type SkillFreshnessInstallation
} from '../../shared/skill-freshness'
export function eligibleSkillUpdateNames(
installations: readonly SkillFreshnessInstallation[]
): string[] {
const byName = new Map<string, SkillFreshnessInstallation[]>()
for (const installation of installations) {
const entries = byName.get(installation.name) ?? []
entries.push(installation)
byName.set(installation.name, entries)
}
const eligible: string[] = []
for (const [name, entries] of byName) {
const hasOutdated = entries.some((entry) => entry.status === 'outdated')
const everyPlacementIsOfficialAndUpdatable = entries.every(
(entry) =>
(entry.status === 'current' || entry.status === 'outdated') &&
// Why: the rail reliably converges the canonical copy and its symlink aliases.
// A standalone duplicate no longer blocks the whole name — the canonical copy
// still updates and the duplicate row is flagged as maybe-not-reached — while
// data-loss topologies (unrecognized/read-only/etc.) still poison via these checks.
(SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology) ||
entry.topology === 'independent-copy') &&
Boolean(entry.resolvedPath && entry.physicalIdentity)
)
// Why: only offer the global command when a reliably-convergent placement anchors it,
// so a skill that exists solely as a standalone copy never draws a command that could
// no-op or error against a canonical install that isn't there.
const hasReliableTarget = entries.some((entry) =>
SUPPORTED_GLOBAL_SKILL_TOPOLOGIES.has(entry.topology)
)
if (hasOutdated && everyPlacementIsOfficialAndUpdatable && hasReliableTarget) {
eligible.push(name)
}
}
return eligible.sort((left, right) => left.localeCompare(right, 'en'))
}

View File

@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import {
boundRepositorySkillRoots,
MAXIMUM_REPOSITORY_SKILL_ROOTS
} from './skill-freshness-inventory'
import type { SkillScanRoot } from './skill-discovery-sources'
describe('skill freshness inventory limits', () => {
it('caps repository roots before creating candidate probes', () => {
const roots = Array.from(
{ length: MAXIMUM_REPOSITORY_SKILL_ROOTS + 3 },
(_, index): SkillScanRoot => ({
id: `repo-${index}`,
label: `Repo ${index}`,
path: `/repo-${index}/.agents/skills`,
sourceKind: 'repo',
providers: ['agent-skills']
})
)
const bounded = boundRepositorySkillRoots(roots)
expect(bounded.scanned).toHaveLength(MAXIMUM_REPOSITORY_SKILL_ROOTS)
expect(bounded.omitted).toHaveLength(3)
})
})

View File

@ -0,0 +1,363 @@
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { Repo } from '../../shared/types'
import type {
SkillBundleFileIdentity,
SkillCurrentBundleEntry,
SkillKnownSnapshot
} from '../../shared/skill-freshness'
import {
inventorySkillFreshness,
MAXIMUM_REPOSITORY_SKILL_ROOTS
} from './skill-freshness-inventory'
import { describeObservedSkillFile, skillPackageDigest } from './skill-package-identity'
const temporaryDirectories: string[] = []
function snapshot(releaseRevision: number, markdown: string): SkillKnownSnapshot {
const observed = describeObservedSkillFile('SKILL.md', Buffer.from(markdown), false)
const file: SkillBundleFileIdentity = {
path: observed.path,
size: observed.size,
executable: observed.executable,
classification: observed.classification,
exactSha256: observed.exactSha256,
textNormalizedSha256: observed.textNormalizedSha256,
identitySha256: observed.identitySha256
}
return {
releaseRevision,
packageDigest: skillPackageDigest([file]),
gitTreeSha: releaseRevision.toString(16).padStart(40, '0'),
files: [file]
}
}
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'orca-skill-inventory-'))
temporaryDirectories.push(root)
const homeDir = join(root, 'home')
const resourceRoot = join(root, 'resources')
const skillResourceRoot = join(resourceRoot, 'skills')
await mkdir(skillResourceRoot, { recursive: true })
const oldMarkdown = '---\nname: orca-cli\ndescription: Old official guide.\n---\n\n# Old\n'
const currentMarkdown =
'---\nname: orca-cli\ndescription: Current official guide.\n---\n\n# Current\n'
const newerMarkdown = '---\nname: orca-cli\ndescription: Newer official guide.\n---\n\n# Newer\n'
const snapshots = [
snapshot(1, oldMarkdown),
snapshot(2, currentMarkdown),
snapshot(3, newerMarkdown)
]
const current: SkillCurrentBundleEntry = {
name: 'orca-cli',
sourcePath: 'skills/orca-cli',
appVersion: '2.0.0',
...snapshots[1]
}
await Promise.all([
writeFile(
join(skillResourceRoot, 'current-manifest.json'),
`${JSON.stringify({ schemaVersion: 1, appVersion: '2.0.0', skills: [current] }, null, 2)}\n`
),
writeFile(
join(skillResourceRoot, 'snapshot-registry.json'),
`${JSON.stringify({ schemaVersion: 1, skills: { 'orca-cli': snapshots } }, null, 2)}\n`
),
writeFile(
join(skillResourceRoot, 'release-mapping.json'),
`${JSON.stringify(
{
schemaVersion: 1,
releases: [
{ appVersion: '1.0.0', skills: { 'orca-cli': 1 } },
{ appVersion: '2.0.0', skills: { 'orca-cli': 2 } },
{ appVersion: '3.0.0', skills: { 'orca-cli': 3 } }
]
},
null,
2
)}\n`
)
])
const writeSkill = async (rootPath: string, markdown: string): Promise<string> => {
const directory = join(rootPath, 'orca-cli')
await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'SKILL.md'), markdown)
return directory
}
return {
root,
homeDir,
resourceRoot,
oldMarkdown,
currentMarkdown,
newerMarkdown,
writeSkill
}
}
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true })))
})
describe('read-only skill freshness inventory', () => {
it('offers an exact older official name only when all global placements are safe', async () => {
const test = await fixture()
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown)
const inventory = await inventorySkillFreshness({
homeDir: test.homeDir,
repos: [],
resourceRoot: test.resourceRoot
})
expect(inventory.installations.map((entry) => entry.status)).toEqual(['outdated'])
expect(inventory.installations[0]?.installedAppVersion).toBe('1.0.0')
expect(inventory.eligibleUpdateNames).toEqual(['orca-cli'])
})
it('labels newer known and unrecognized bytes honestly without calling them modified', async () => {
const test = await fixture()
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.newerMarkdown)
await test.writeSkill(
join(test.homeDir, '.claude', 'skills'),
'---\nname: orca-cli\ndescription: User copy.\n---\n'
)
const inventory = await inventorySkillFreshness({
homeDir: test.homeDir,
repos: [],
resourceRoot: test.resourceRoot
})
expect(inventory.installations.map((entry) => entry.status)).toEqual([
'newer-known',
'unrecognized'
])
expect(inventory.eligibleUpdateNames).toEqual([])
})
it('retains full-file identity without projecting unused metadata', async () => {
const test = await fixture()
const lateDescription = 'Description beyond the metadata parsing budget.'
await test.writeSkill(
join(test.homeDir, '.agents', 'skills'),
`${' '.repeat(256 * 1024)}\n${lateDescription}`
)
const inventory = await inventorySkillFreshness({
homeDir: test.homeDir,
repos: [],
resourceRoot: test.resourceRoot
})
expect(inventory.installations[0]).toMatchObject({
status: 'unrecognized'
})
expect(inventory.installations[0]).not.toHaveProperty('description')
expect(inventory.installations[0]?.observedPackageDigest).toMatch(/^[a-f0-9]{64}$/)
})
it.runIf(process.platform !== 'win32')(
'deduplicates a provider alias to the canonical copy',
async () => {
const test = await fixture()
const canonical = await test.writeSkill(
join(test.homeDir, '.agents', 'skills'),
test.oldMarkdown
)
const claudeRoot = join(test.homeDir, '.claude', 'skills')
await mkdir(claudeRoot, { recursive: true })
await symlink(canonical, join(claudeRoot, 'orca-cli'))
const inventory = await inventorySkillFreshness({
homeDir: test.homeDir,
repos: [],
resourceRoot: test.resourceRoot
})
expect(inventory.installations).toHaveLength(1)
expect(inventory.installations[0]?.providers).toEqual(['agent-skills', 'claude'])
expect(inventory.installations[0]?.topology).toBe('canonical-copy')
expect(inventory.eligibleUpdateNames).toEqual(['orca-cli'])
}
)
it.runIf(process.platform !== 'win32')(
'deduplicates aliases within an unsupported topology without hiding its poison',
async () => {
const test = await fixture()
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown)
const shared = await test.writeSkill(join(test.root, 'shared'), test.currentMarkdown)
const repos = await Promise.all(
['one', 'two'].map(async (id) => {
const repoPath = join(test.root, `repo-${id}`)
const root = join(repoPath, '.agents', 'skills')
await mkdir(root, { recursive: true })
await symlink(shared, join(root, 'orca-cli'))
return { id, path: repoPath } as unknown as Repo
})
)
const inventory = await inventorySkillFreshness({
homeDir: test.homeDir,
repos,
resourceRoot: test.resourceRoot
})
expect(
inventory.installations.filter((entry) => entry.topology === 'repo-scope')
).toHaveLength(1)
expect(inventory.eligibleUpdateNames).toEqual([])
}
)
it('keeps inaccessible placements visible and lets them poison the name', async () => {
const test = await fixture()
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown)
const inaccessiblePath = join(test.homeDir, '.codex', 'skills', 'orca-cli')
const inventory = await inventorySkillFreshness({
homeDir: test.homeDir,
repos: [],
resourceRoot: test.resourceRoot,
candidateLstat: async (path) => {
if (path === inaccessiblePath) {
throw Object.assign(new Error('permission denied'), { code: 'EACCES' })
}
return import('node:fs/promises').then(({ lstat }) => lstat(path))
}
})
expect(inventory.installations.map((entry) => entry.status)).toEqual([
'outdated',
'inaccessible'
])
expect(inventory.eligibleUpdateNames).toEqual([])
})
it('does not lose an inaccessible known repository placement', async () => {
const test = await fixture()
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown)
const repoPath = join(test.root, 'repo')
const inaccessiblePath = join(repoPath, '.agents', 'skills', 'orca-cli')
const inventory = await inventorySkillFreshness({
homeDir: test.homeDir,
repos: [{ id: 'repo', path: repoPath }] as unknown as Repo[],
resourceRoot: test.resourceRoot,
candidateLstat: async (path) => {
if (path === inaccessiblePath) {
throw Object.assign(new Error('permission denied'), { code: 'EACCES' })
}
return import('node:fs/promises').then(({ lstat }) => lstat(path))
}
})
expect(inventory.installations).toEqual(
expect.arrayContaining([
expect.objectContaining({
unresolvedPath: inaccessiblePath,
topology: 'repo-scope',
status: 'inaccessible'
})
])
)
expect(inventory.eligibleUpdateNames).toEqual([])
})
it.each([
['repo', 'repo-scope'],
['plugin', 'plugin-cache']
] as const)(
'keeps an official %s placement informational and name-poisoning',
async (kind, topology) => {
const test = await fixture()
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown)
let repos: Repo[] = []
if (kind === 'repo') {
const repoPath = join(test.root, 'repo')
await test.writeSkill(join(repoPath, '.agents', 'skills'), test.currentMarkdown)
repos = [{ id: 'repo', path: repoPath }] as unknown as Repo[]
} else {
await test.writeSkill(
join(test.homeDir, '.codex', 'plugins', 'cache', 'vendor', 'skills'),
test.currentMarkdown
)
}
const inventory = await inventorySkillFreshness({
homeDir: test.homeDir,
repos,
resourceRoot: test.resourceRoot
})
expect(inventory.installations.some((entry) => entry.topology === topology)).toBe(true)
expect(inventory.eligibleUpdateNames).toEqual([])
}
)
it('accepts CRLF as the same official text identity', async () => {
const test = await fixture()
await test.writeSkill(
join(test.homeDir, '.agents', 'skills'),
test.oldMarkdown.replaceAll('\n', '\r\n')
)
const inventory = await inventorySkillFreshness({
homeDir: test.homeDir,
repos: [],
resourceRoot: test.resourceRoot
})
expect(inventory.installations[0]?.status).toBe('outdated')
})
it('classifies exact current bytes as current when a later snapshot reuses them', async () => {
const test = await fixture()
const resourceRoot = join(test.resourceRoot, 'skills')
const registryPath = join(resourceRoot, 'snapshot-registry.json')
const registry = JSON.parse(await readFile(registryPath, 'utf8'))
registry.skills['orca-cli'].push(snapshot(4, test.currentMarkdown))
await writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`)
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown)
const inventory = await inventorySkillFreshness({
homeDir: test.homeDir,
repos: [],
resourceRoot: test.resourceRoot
})
expect(inventory.installations[0]).toMatchObject({
status: 'current',
installedReleaseRevision: 2,
installedAppVersion: '2.0.0'
})
})
it('withholds updates when stored repositories exceed the probe budget', async () => {
const test = await fixture()
await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown)
const repos = Array.from(
{ length: MAXIMUM_REPOSITORY_SKILL_ROOTS / 2 + 1 },
(_, index) => ({ id: `repo-${index}`, path: join(test.root, `repo-${index}`) }) as Repo
)
const inventory = await inventorySkillFreshness({
homeDir: test.homeDir,
repos,
resourceRoot: test.resourceRoot
})
expect(inventory.installations).toEqual(
expect.arrayContaining([
expect.objectContaining({ errorCategory: 'repository-scan-limit', status: 'inaccessible' })
])
)
expect(inventory.eligibleUpdateNames).toEqual([])
})
})

View File

@ -0,0 +1,179 @@
import { lstat } from 'node:fs/promises'
import { join } from 'node:path'
import type { Repo } from '../../shared/types'
import type {
SkillFreshnessInstallation,
SkillFreshnessInventory
} from '../../shared/skill-freshness'
import { buildSkillDiscoverySources, type SkillScanRoot } from './skill-discovery-sources'
import { loadSkillBundleArtifacts } from './skill-bundle-artifacts'
import { eligibleSkillUpdateNames } from './skill-freshness-eligibility'
import { runSkillCandidateTasks } from './skill-candidate-concurrency'
import {
classifyHomeSkillCandidate,
classifyUnsupportedSkillCandidate,
dedupeSkillFreshnessPlacements,
observeSkillFreshnessInstallation,
type CandidateLstat
} from './skill-freshness-placement-observation'
import { scanKnownPluginSkillCandidates } from './skill-plugin-cache-scan'
export const MAXIMUM_REPOSITORY_SKILL_ROOTS = 128
export function boundRepositorySkillRoots(roots: readonly SkillScanRoot[]): {
scanned: SkillScanRoot[]
omitted: SkillScanRoot[]
} {
return {
scanned: roots.slice(0, MAXIMUM_REPOSITORY_SKILL_ROOTS),
omitted: roots.slice(MAXIMUM_REPOSITORY_SKILL_ROOTS)
}
}
export async function inventorySkillFreshness(
args: {
homeDir?: string
cwd?: string
repos?: Repo[]
resourceRoot?: string
candidateLstat?: CandidateLstat
} = {}
): Promise<SkillFreshnessInventory> {
const artifacts = await loadSkillBundleArtifacts(args.resourceRoot)
const currentByName = new Map(artifacts.manifest.skills.map((skill) => [skill.name, skill]))
const discoveryArgs = {
homeDir: args.homeDir,
cwd: args.cwd,
repos: args.repos,
// Why: freshness scans known repositories explicitly; treating the app's
// launch cwd as another repo would create phantom poison placements.
includeCwd: false
}
const roots = buildSkillDiscoverySources(discoveryArgs)
const homeRoots = roots.filter((root) => root.sourceKind === 'home')
const allRepoRoots = roots.filter((root) => root.sourceKind === 'repo')
const { scanned: repoRoots, omitted: omittedRepoRoots } = boundRepositorySkillRoots(allRepoRoots)
const pluginRoots = roots.filter((root) => root.sourceKind === 'plugin')
const canonicalRootPath = homeRoots.find((root) => root.id === 'home-agents')?.path
if (!canonicalRootPath) {
throw new Error('Missing canonical agent skills root')
}
const homeTasks = artifacts.manifest.skills.flatMap((current) =>
homeRoots.map(
(root) => () =>
classifyHomeSkillCandidate({
root,
current,
artifacts,
canonicalRootPath,
candidateLstat: args.candidateLstat ?? ((path) => lstat(path))
})
)
)
// Why: each observation may retain the package byte ceiling while hashing;
// launch/focus scans must not fan out across every known placement.
const homeInstallations = (await runSkillCandidateTasks(homeTasks)).filter(
(installation): installation is SkillFreshnessInstallation => installation !== null
)
const candidateLstat = args.candidateLstat ?? ((path) => lstat(path))
const repoTasks = artifacts.manifest.skills.flatMap((current) =>
repoRoots.map(
(root) => () =>
classifyUnsupportedSkillCandidate({
root,
current,
artifacts,
unresolvedPath: join(root.path, current.name),
candidateLstat
})
)
)
// Why: stored repositories can grow without bound. If the probe budget is
// exhausted, one sentinel per name preserves safety without hashing more packages.
const omittedRepoTasks =
omittedRepoRoots.length === 0
? []
: artifacts.manifest.skills.map(
(current) => () =>
observeSkillFreshnessInstallation({
current,
artifacts,
rootId: 'repo-scan-limit',
providers: [...new Set(omittedRepoRoots.flatMap((root) => root.providers))],
sourceKind: 'repo',
sourceLabel: 'Additional repositories',
unresolvedPath: omittedRepoRoots[0]?.path ?? 'repo-scan-limit',
topology: {
topology: 'repo-scope',
resolvedPath: null,
identity: null,
errorCategory: 'repository-scan-limit'
}
})
)
const pluginScans = await Promise.all(
pluginRoots.map(async (root) => ({
root,
scan: await scanKnownPluginSkillCandidates(root.path, new Set(currentByName.keys()))
}))
)
const pluginTasks = pluginScans.flatMap(({ root, scan }) => [
...scan.candidates.flatMap((candidate) => {
const current = currentByName.get(candidate.name)
return current
? [
() =>
classifyUnsupportedSkillCandidate({
root,
current,
artifacts,
unresolvedPath: candidate.path,
candidateLstat
})
]
: []
}),
// Why: unreadable plugin subtrees could hide any official name. An
// incomplete scan must conservatively poison every name rather than imply absence.
...scan.incompletePaths.flatMap((incompletePath) =>
artifacts.manifest.skills.map(
(current) => () =>
observeSkillFreshnessInstallation({
current,
artifacts,
rootId: root.id,
providers: root.providers,
sourceKind: 'plugin',
sourceLabel: root.label,
unresolvedPath: join(incompletePath, current.name),
topology: {
topology: 'plugin-cache',
resolvedPath: null,
identity: null,
errorCategory: 'plugin-cache-scan-incomplete'
}
})
)
)
])
const unsupportedInstallations = (
await runSkillCandidateTasks([...repoTasks, ...omittedRepoTasks, ...pluginTasks])
).filter((installation): installation is SkillFreshnessInstallation => installation !== null)
const installations = dedupeSkillFreshnessPlacements([
...homeInstallations,
...unsupportedInstallations
]).sort(
(left, right) =>
left.name.localeCompare(right.name, 'en') ||
left.unresolvedPath.localeCompare(right.unresolvedPath, 'en')
)
return {
schemaVersion: 1,
installations,
eligibleUpdateNames: eligibleSkillUpdateNames(installations),
scannedAt: Date.now()
}
}

View File

@ -0,0 +1,251 @@
import type { Stats } from 'node:fs'
import { join } from 'node:path'
import type {
SkillCurrentBundleEntry,
SkillFreshnessInstallation,
SkillFreshnessStatus,
SkillKnownSnapshot
} from '../../shared/skill-freshness'
import type { SkillScanRoot } from './skill-discovery-sources'
import type { SkillBundleArtifacts } from './skill-bundle-artifacts'
import { matchingKnownSnapshot, observeSkillPackage } from './skill-package-identity'
import {
classifyHomeSkillTopology,
classifyUnsupportedSkillTopology,
skillPlacementId,
skillTopologyPriority,
type ClassifiedSkillTopology
} from './skill-installation-topology'
export type CandidateLstat = (path: string) => Promise<Stats>
function freshnessStatus(
snapshot: SkillKnownSnapshot | null,
current: SkillCurrentBundleEntry
): SkillFreshnessStatus {
if (!snapshot) {
return 'unrecognized'
}
if (snapshot.releaseRevision > current.releaseRevision) {
return 'newer-known'
}
return snapshot.packageDigest === current.packageDigest ? 'current' : 'outdated'
}
function errorCategory(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback
}
function isInaccessibleError(error: unknown): boolean {
return Boolean(
error &&
typeof error === 'object' &&
'code' in error &&
(error.code === 'EACCES' || error.code === 'EPERM')
)
}
function knownSnapshots(
artifacts: SkillBundleArtifacts,
current: SkillCurrentBundleEntry
): SkillKnownSnapshot[] {
const snapshots = artifacts.knownSnapshots[current.name] ?? []
return snapshots.some((snapshot) => snapshot.packageDigest === current.packageDigest)
? snapshots
: [...snapshots, current]
}
export async function observeSkillFreshnessInstallation(args: {
current: SkillCurrentBundleEntry
artifacts: SkillBundleArtifacts
rootId: string
providers: SkillFreshnessInstallation['providers']
sourceKind: SkillFreshnessInstallation['sourceKind']
sourceLabel: string
unresolvedPath: string
topology: ClassifiedSkillTopology
}): Promise<SkillFreshnessInstallation> {
const base = {
id: skillPlacementId(args.unresolvedPath, args.current.name),
name: args.current.name,
rootId: args.rootId,
providers: args.providers,
sourceKind: args.sourceKind,
sourceLabel: args.sourceLabel,
unresolvedPath: args.unresolvedPath,
resolvedPath: args.topology.resolvedPath,
physicalIdentity: args.topology.identity,
topology: args.topology.topology,
currentReleaseRevision: args.current.releaseRevision,
currentPackageDigest: args.current.packageDigest,
currentAppVersion: args.current.appVersion,
errorCategory: args.topology.errorCategory
}
if (!args.topology.resolvedPath || !args.topology.identity) {
return {
...base,
status: 'inaccessible',
installedReleaseRevision: null,
installedAppVersion: null,
observedPackageDigest: null
}
}
try {
const observed = await observeSkillPackage(args.topology.resolvedPath)
const matchedSnapshot = matchingKnownSnapshot(
observed,
knownSnapshots(args.artifacts, args.current)
)
// Why: a later release can reintroduce identical bytes. Exact current
// identity is still current, and cannot honestly be attributed to the later tag.
const snapshot =
observed.observedDigest === args.current.packageDigest ? args.current : matchedSnapshot
return {
...base,
status: freshnessStatus(snapshot, args.current),
installedReleaseRevision: snapshot?.releaseRevision ?? null,
installedAppVersion: snapshot
? (args.artifacts.releasedAppVersions[args.current.name]?.[snapshot.releaseRevision] ??
null)
: null,
observedPackageDigest: observed.observedDigest
}
} catch (error) {
return {
...base,
status: isInaccessibleError(error) ? 'inaccessible' : 'unrecognized',
installedReleaseRevision: null,
installedAppVersion: null,
observedPackageDigest: null,
errorCategory: errorCategory(error, 'skill-package-read-failed')
}
}
}
export async function classifyHomeSkillCandidate(args: {
root: SkillScanRoot
current: SkillCurrentBundleEntry
artifacts: SkillBundleArtifacts
canonicalRootPath: string
candidateLstat: CandidateLstat
}): Promise<SkillFreshnessInstallation | null> {
const unresolvedPath = join(args.root.path, args.current.name)
try {
await args.candidateLstat(unresolvedPath)
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return null
}
return observeSkillFreshnessInstallation({
current: args.current,
artifacts: args.artifacts,
rootId: args.root.id,
providers: args.root.providers,
sourceKind: args.root.sourceKind,
sourceLabel: args.root.label,
unresolvedPath,
topology: {
topology: 'broken-link',
resolvedPath: null,
identity: null,
errorCategory: errorCategory(error, 'skill-candidate-inaccessible')
}
})
}
let topology: ClassifiedSkillTopology
try {
topology = await classifyHomeSkillTopology(args.root, unresolvedPath, args.canonicalRootPath)
} catch (error) {
topology = {
topology: 'broken-link',
resolvedPath: null,
identity: null,
errorCategory: errorCategory(error, 'skill-candidate-topology-failed')
}
}
return observeSkillFreshnessInstallation({
current: args.current,
artifacts: args.artifacts,
rootId: args.root.id,
providers: args.root.providers,
sourceKind: args.root.sourceKind,
sourceLabel: args.root.label,
unresolvedPath,
topology
})
}
export async function classifyUnsupportedSkillCandidate(args: {
root: SkillScanRoot
current: SkillCurrentBundleEntry
artifacts: SkillBundleArtifacts
unresolvedPath: string
candidateLstat: CandidateLstat
}): Promise<SkillFreshnessInstallation | null> {
try {
await args.candidateLstat(args.unresolvedPath)
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return null
}
return observeSkillFreshnessInstallation({
current: args.current,
artifacts: args.artifacts,
rootId: args.root.id,
providers: args.root.providers,
sourceKind: args.root.sourceKind,
sourceLabel: args.root.label,
unresolvedPath: args.unresolvedPath,
topology: {
topology: args.root.sourceKind === 'repo' ? 'repo-scope' : 'plugin-cache',
resolvedPath: null,
identity: null,
errorCategory: errorCategory(error, 'unsupported-candidate-inaccessible')
}
})
}
return observeSkillFreshnessInstallation({
current: args.current,
artifacts: args.artifacts,
rootId: args.root.id,
providers: args.root.providers,
sourceKind: args.root.sourceKind,
sourceLabel: args.root.label,
unresolvedPath: args.unresolvedPath,
topology: await classifyUnsupportedSkillTopology(
args.unresolvedPath,
args.root.sourceKind === 'repo' ? 'repo' : 'plugin'
)
})
}
function topologyDedupeBucket(installation: SkillFreshnessInstallation): string {
return installation.topology === 'canonical-copy' || installation.topology === 'provider-alias'
? 'managed-global'
: installation.topology
}
export function dedupeSkillFreshnessPlacements(
installations: readonly SkillFreshnessInstallation[]
): SkillFreshnessInstallation[] {
const deduped = new Map<string, SkillFreshnessInstallation>()
for (const installation of installations) {
const key = installation.physicalIdentity
? `${installation.name}\0${installation.physicalIdentity}\0${topologyDedupeBucket(installation)}`
: `logical\0${installation.id}`
const existing = deduped.get(key)
if (!existing) {
deduped.set(key, installation)
continue
}
const providers = [...new Set([...existing.providers, ...installation.providers])]
if (skillTopologyPriority(installation.topology) > skillTopologyPriority(existing.topology)) {
deduped.set(key, { ...installation, providers })
} else {
existing.providers = providers
}
}
return [...deduped.values()]
}

View File

@ -0,0 +1,172 @@
import { createHash } from 'node:crypto'
import { constants } from 'node:fs'
import { access, lstat, realpath, stat } from 'node:fs/promises'
import { dirname, normalize, resolve } from 'node:path'
import type { SkillInstallationTopology } from '../../shared/skill-freshness'
import type { SkillScanRoot } from './skill-discovery-sources'
export type ClassifiedSkillTopology = {
topology: SkillInstallationTopology
resolvedPath: string | null
identity: string | null
errorCategory: string | null
}
export function skillPlacementId(unresolvedPath: string, name: string): string {
return createHash('sha256')
.update(normalizedSkillIdentityPath(unresolvedPath))
.update('\0')
.update(name)
.digest('hex')
.slice(0, 24)
}
export function normalizedSkillIdentityPath(value: string): string {
const normalized = normalize(value)
return process.platform === 'win32' ? normalized.toLocaleLowerCase('en-US') : normalized
}
export function skillPhysicalIdentity(
resolvedPath: string,
fileStat: Awaited<ReturnType<typeof stat>>
): string {
const inodeIdentity = fileStat.dev || fileStat.ino ? `${fileStat.dev}:${fileStat.ino}` : null
return inodeIdentity ?? normalizedSkillIdentityPath(resolvedPath)
}
export function skillTopologyPriority(topology: SkillInstallationTopology): number {
switch (topology) {
case 'canonical-copy':
return 3
case 'independent-copy':
return 2
case 'provider-alias':
return 1
case 'external-link':
case 'broken-link':
case 'read-only':
case 'repo-scope':
case 'plugin-cache':
return 0
}
}
async function writableDestination(path: string): Promise<boolean> {
try {
await Promise.all([
access(path, constants.R_OK | constants.W_OK),
access(dirname(path), constants.W_OK)
])
return true
} catch {
return false
}
}
async function hasSymlinkedAncestor(path: string, boundary: string): Promise<boolean> {
let current = resolve(path)
const stop = resolve(boundary)
for (;;) {
const entry = await lstat(current).catch(() => null)
if (!entry || entry.isSymbolicLink()) {
return true
}
const parent = dirname(current)
if (current === stop) {
return false
}
if (parent === current) {
return true
}
current = parent
}
}
export async function classifyHomeSkillTopology(
root: SkillScanRoot,
unresolvedPath: string,
canonicalRootPath: string
): Promise<ClassifiedSkillTopology> {
let logicalStat: Awaited<ReturnType<typeof lstat>>
try {
logicalStat = await lstat(unresolvedPath)
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return {
topology: 'broken-link',
resolvedPath: null,
identity: null,
errorCategory: 'missing'
}
}
throw error
}
const linked = logicalStat.isSymbolicLink()
let resolvedPath: string
let resolvedStat: Awaited<ReturnType<typeof stat>>
try {
resolvedPath = await realpath(unresolvedPath)
resolvedStat = await stat(resolvedPath)
} catch {
return {
topology: 'broken-link',
resolvedPath: null,
identity: null,
errorCategory: 'dangling-link'
}
}
if (!resolvedStat.isDirectory()) {
return {
topology: 'broken-link',
resolvedPath,
identity: null,
errorCategory: 'not-directory'
}
}
const identity = skillPhysicalIdentity(resolvedPath, resolvedStat)
const canonicalRoot = await realpath(canonicalRootPath).catch(() => resolve(canonicalRootPath))
const homeBoundary = dirname(dirname(canonicalRootPath))
const rootOrProviderParentLinked = await hasSymlinkedAncestor(root.path, homeBoundary)
const isCanonicalTarget =
normalizedSkillIdentityPath(dirname(resolvedPath)) ===
normalizedSkillIdentityPath(canonicalRoot)
let topology: SkillInstallationTopology
if (linked) {
topology = isCanonicalTarget ? 'provider-alias' : 'external-link'
} else if (rootOrProviderParentLinked) {
topology = 'external-link'
} else {
topology = root.id === 'home-agents' ? 'canonical-copy' : 'independent-copy'
}
if (topology !== 'external-link' && !(await writableDestination(resolvedPath))) {
topology = 'read-only'
}
return { topology, resolvedPath, identity, errorCategory: null }
}
export async function classifyUnsupportedSkillTopology(
directoryPath: string,
sourceKind: 'repo' | 'plugin'
): Promise<ClassifiedSkillTopology> {
try {
const resolvedPath = await realpath(directoryPath)
const resolvedStat = await stat(resolvedPath)
if (!resolvedStat.isDirectory()) {
throw new Error('not-directory')
}
return {
topology: sourceKind === 'repo' ? 'repo-scope' : 'plugin-cache',
resolvedPath,
identity: skillPhysicalIdentity(resolvedPath, resolvedStat),
errorCategory: null
}
} catch (error) {
return {
topology: sourceKind === 'repo' ? 'repo-scope' : 'plugin-cache',
resolvedPath: null,
identity: null,
errorCategory: error instanceof Error ? error.message : 'read-failed'
}
}
}

View File

@ -0,0 +1,92 @@
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
describeObservedSkillFile,
matchingKnownSnapshot,
observeSkillPackage,
skillPackageDigest
} from './skill-package-identity'
const temporaryDirectories: string[] = []
async function temporarySkill(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-skill-freshness-'))
temporaryDirectories.push(root)
return root
}
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true })))
})
describe('skill package identity', () => {
it('matches CRLF installed text to an LF official snapshot', async () => {
const root = await temporarySkill()
await writeFile(join(root, 'SKILL.md'), 'first\r\nsecond\r\n')
const observed = await observeSkillPackage(root)
const expected = describeObservedSkillFile('SKILL.md', Buffer.from('first\nsecond\n'), false)
// Why: scans can observe several package byte budgets concurrently; only
// hashes, not raw file buffers, should survive each file's identity pass.
expect(observed.files[0]).not.toHaveProperty('bytes')
expect(
matchingKnownSnapshot(observed, [
{
releaseRevision: 1,
packageDigest: skillPackageDigest([expected]),
gitTreeSha: 'tree',
files: [expected]
}
])?.releaseRevision
).toBe(1)
})
it('uses exact bytes for executable and binary files', async () => {
const executable = describeObservedSkillFile('run.sh', Buffer.from('#!/bin/sh\r\n'), true)
const binary = describeObservedSkillFile('asset.bin', Buffer.from([0, 13, 10]), false)
expect(executable.identitySha256).toBe(executable.exactSha256)
expect(binary.identitySha256).toBe(binary.exactSha256)
expect(binary.classification).toBe('binary')
})
it('orders package files by locale-independent code units', async () => {
const root = await temporarySkill()
await writeFile(join(root, 'apple.md'), 'apple')
await writeFile(join(root, 'Zebra.md'), 'zebra')
const observed = await observeSkillPackage(root)
expect(observed.files.map((file) => file.path)).toEqual(['Zebra.md', 'apple.md'])
})
it('rejects links and bounded-observation overflows', async () => {
const root = await temporarySkill()
await writeFile(join(root, 'SKILL.md'), 'skill')
if (process.platform !== 'win32') {
await symlink(join(root, 'SKILL.md'), join(root, 'linked.md'))
await expect(observeSkillPackage(root)).rejects.toThrow('skill-package-link')
await rm(join(root, 'linked.md'))
}
await expect(
observeSkillPackage(root, {
maximumDepth: 1,
maximumEntries: 0,
maximumFiles: 1,
maximumSingleFileBytes: 10,
maximumTotalBytes: 10
})
).rejects.toThrow('skill-package-entry-limit')
})
it.runIf(process.platform !== 'win32')('tracks executable mode in package identity', async () => {
const root = await temporarySkill()
await mkdir(join(root, 'scripts'))
const script = join(root, 'scripts', 'run.sh')
await writeFile(script, '#!/bin/sh\n')
await chmod(script, 0o755)
const observed = await observeSkillPackage(root)
expect(observed.files[0]?.executable).toBe(true)
})
})

View File

@ -0,0 +1,229 @@
import { createHash } from 'node:crypto'
import type { Dirent } from 'node:fs'
import { lstat, open, opendir } from 'node:fs/promises'
import { isAbsolute, join, relative, sep } from 'node:path'
import type { SkillBundleFileIdentity, SkillKnownSnapshot } from '../../shared/skill-freshness'
type ObservedSkillFile = SkillBundleFileIdentity
export type ObservedSkillPackage = {
files: ObservedSkillFile[]
observedDigest: string
}
export const SKILL_PACKAGE_OBSERVATION_LIMITS = {
maximumDepth: 16,
maximumEntries: 2_048,
maximumFiles: 512,
maximumSingleFileBytes: 4 * 1024 * 1024,
maximumTotalBytes: 32 * 1024 * 1024
} as const
type SkillPackageObservationLimits = {
maximumDepth: number
maximumEntries: number
maximumFiles: number
maximumSingleFileBytes: number
maximumTotalBytes: number
}
async function readBoundedSkillFile(
path: string,
remainingTotalBytes: number,
maximumSingleFileBytes: number
): Promise<Buffer> {
const handle = await open(path, 'r')
try {
const before = await handle.stat()
if (before.size > maximumSingleFileBytes) {
throw new Error('skill-package-file-size-limit')
}
if (before.size > remainingTotalBytes) {
throw new Error('skill-package-total-size-limit')
}
const bytes = Buffer.alloc(before.size)
let offset = 0
while (offset < bytes.length) {
const result = await handle.read(bytes, offset, bytes.length - offset, offset)
if (result.bytesRead === 0) {
throw new Error('skill-package-changed-during-read')
}
offset += result.bytesRead
}
if ((await handle.stat()).size !== before.size) {
throw new Error('skill-package-changed-during-read')
}
return bytes
} finally {
await handle.close()
}
}
function sha256(bytes: Buffer): string {
return createHash('sha256').update(bytes).digest('hex')
}
function compareCodeUnits(left: string, right: string): number {
return left === right ? 0 : left < right ? -1 : 1
}
function normalizedText(bytes: Buffer): Buffer {
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8')
}
export function describeObservedSkillFile(
path: string,
bytes: Buffer,
executable: boolean
): ObservedSkillFile {
let normalized: Buffer | null = null
if (!bytes.includes(0)) {
try {
normalized = normalizedText(bytes)
} catch {
normalized = null
}
}
const classification = normalized ? 'text' : 'binary'
const exactSha256 = sha256(bytes)
const textNormalizedSha256 = normalized ? sha256(normalized) : null
return {
path,
size: bytes.length,
executable,
classification,
exactSha256,
textNormalizedSha256,
identitySha256:
textNormalizedSha256 !== null && !executable ? textNormalizedSha256 : exactSha256
}
}
export function skillPackageDigest(files: readonly SkillBundleFileIdentity[]): string {
return sha256(
Buffer.from(
JSON.stringify(
files.map((file) => ({
path: file.path,
executable: file.executable,
classification: file.classification,
identitySha256: file.identitySha256
}))
)
)
)
}
function matchesFileIdentity(
actual: ObservedSkillFile,
expected: SkillBundleFileIdentity
): boolean {
if (
actual.path !== expected.path ||
actual.executable !== expected.executable ||
actual.classification !== expected.classification
) {
return false
}
return expected.classification === 'text' && !expected.executable
? actual.textNormalizedSha256 === expected.textNormalizedSha256
: actual.exactSha256 === expected.exactSha256
}
export async function observeSkillPackage(
packageRoot: string,
limits: SkillPackageObservationLimits = SKILL_PACKAGE_OBSERVATION_LIMITS
): Promise<ObservedSkillPackage> {
const files: ObservedSkillFile[] = []
const caseFoldedPaths = new Map<string, string>()
let entryCount = 0
let totalBytes = 0
async function visit(directory: string, depth: number): Promise<void> {
const directoryHandle = await opendir(directory)
const entries: Dirent[] = []
try {
for (;;) {
const entry = await directoryHandle.read()
if (!entry) {
break
}
entryCount += 1
if (entryCount > limits.maximumEntries) {
throw new Error('skill-package-entry-limit')
}
entries.push(entry)
}
} finally {
await directoryHandle.close().catch(() => undefined)
}
// Why: runtime Electron and the build's Node may carry different ICU data;
// identity order must match the generator without locale-sensitive collation.
entries.sort((left, right) => compareCodeUnits(left.name, right.name))
for (const entry of entries) {
const absolutePath = join(directory, entry.name)
const relativePath = relative(packageRoot, absolutePath)
if (
isAbsolute(relativePath) ||
relativePath === '..' ||
relativePath.startsWith(`..${sep}`)
) {
throw new Error('skill-path-escape')
}
const manifestPath = relativePath.split(sep).join('/')
const folded = manifestPath.toLocaleLowerCase('en-US')
const collision = caseFoldedPaths.get(folded)
if (collision && collision !== manifestPath) {
throw new Error('skill-case-collision')
}
caseFoldedPaths.set(folded, manifestPath)
const fileStat = await lstat(absolutePath)
if (fileStat.isSymbolicLink()) {
throw new Error('skill-package-link')
}
if (fileStat.isDirectory()) {
if (depth >= limits.maximumDepth) {
throw new Error('skill-package-depth-limit')
}
await visit(absolutePath, depth + 1)
} else if (fileStat.isFile()) {
if (files.length >= limits.maximumFiles) {
throw new Error('skill-package-file-count-limit')
}
const bytes = await readBoundedSkillFile(
absolutePath,
limits.maximumTotalBytes - totalBytes,
limits.maximumSingleFileBytes
)
totalBytes += bytes.length
files.push(describeObservedSkillFile(manifestPath, bytes, (fileStat.mode & 0o111) !== 0))
} else {
throw new Error('skill-package-special-file')
}
}
}
await visit(packageRoot, 0)
return { files, observedDigest: skillPackageDigest(files) }
}
export function matchingKnownSnapshot(
observed: ObservedSkillPackage,
snapshots: readonly SkillKnownSnapshot[]
): SkillKnownSnapshot | null {
for (const snapshot of snapshots.toReversed()) {
if (snapshot.files.length !== observed.files.length) {
continue
}
if (
snapshot.files.every((expected, index) => {
const actual = observed.files[index]
return Boolean(actual && matchesFileIdentity(actual, expected))
})
) {
return snapshot
}
}
return null
}

View File

@ -0,0 +1,40 @@
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { scanKnownPluginSkillCandidates } from './skill-plugin-cache-scan'
const temporaryDirectories: string[] = []
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true })))
})
describe('plugin skill candidate scan', () => {
it('stops at the package candidate budget and marks the scan incomplete', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-skill-scan-'))
temporaryDirectories.push(root)
await Promise.all(
['one', 'two'].map((vendor) => mkdir(join(root, vendor, 'orca-cli'), { recursive: true }))
)
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']), 1)
expect(result.candidates).toHaveLength(1)
expect(result.incompletePaths).toEqual([root])
})
it('marks depth-truncated subtrees incomplete so hidden skills poison eligibility', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-skill-depth-'))
temporaryDirectories.push(root)
const segments = Array.from({ length: 11 }, (_, index) => `level-${index}`)
const hiddenSkill = join(root, ...segments, 'orca-cli')
await mkdir(hiddenSkill, { recursive: true })
const result = await scanKnownPluginSkillCandidates(root, new Set(['orca-cli']))
expect(result.candidates).toEqual([])
expect(result.incompletePaths).toHaveLength(1)
expect(hiddenSkill.startsWith(result.incompletePaths[0] ?? '')).toBe(true)
})
})

View File

@ -0,0 +1,142 @@
import type { Dirent } from 'node:fs'
import { opendir, realpath, stat } from 'node:fs/promises'
import { join } from 'node:path'
const MAXIMUM_PLUGIN_SCAN_DEPTH = 9
const MAXIMUM_PLUGIN_SCAN_ENTRIES = 4_096
export const MAXIMUM_PLUGIN_SKILL_CANDIDATES = 64
const MAXIMUM_PLUGIN_INCOMPLETE_PATHS = 16
export type KnownPluginSkillCandidate = {
name: string
path: string
}
export type KnownPluginSkillScan = {
candidates: KnownPluginSkillCandidate[]
incompletePaths: string[]
}
function errorCode(error: unknown): string | null {
return error && typeof error === 'object' && 'code' in error && typeof error.code === 'string'
? error.code
: null
}
export async function scanKnownPluginSkillCandidates(
rootPath: string,
knownNames: ReadonlySet<string>,
maximumCandidates = MAXIMUM_PLUGIN_SKILL_CANDIDATES
): Promise<KnownPluginSkillScan> {
const candidates: KnownPluginSkillCandidate[] = []
const incompletePaths = new Set<string>()
const visited = new Set<string>()
let entryCount = 0
let limitReached = false
function recordIncomplete(path: string): void {
if (incompletePaths.has(path)) {
return
}
if (incompletePaths.size >= MAXIMUM_PLUGIN_INCOMPLETE_PATHS) {
// Why: each incomplete path expands to one conservative row per official
// skill. Collapse a hostile cache into one poison sentinel before IPC/render fanout.
incompletePaths.clear()
incompletePaths.add(rootPath)
limitReached = true
return
}
incompletePaths.add(path)
}
async function visit(directory: string, depth: number): Promise<void> {
if (limitReached) {
return
}
if (depth > MAXIMUM_PLUGIN_SCAN_DEPTH) {
recordIncomplete(directory)
return
}
let resolved: string
try {
resolved = await realpath(directory)
} catch (error) {
if (errorCode(error) !== 'ENOENT') {
recordIncomplete(directory)
}
return
}
if (visited.has(resolved)) {
return
}
visited.add(resolved)
let handle: Awaited<ReturnType<typeof opendir>>
try {
handle = await opendir(directory)
} catch {
recordIncomplete(directory)
return
}
const entries: Dirent[] = []
try {
for (;;) {
const entry = await handle.read()
if (!entry) {
break
}
entryCount += 1
if (entryCount > MAXIMUM_PLUGIN_SCAN_ENTRIES) {
limitReached = true
recordIncomplete(rootPath)
break
}
entries.push(entry)
}
} catch {
recordIncomplete(directory)
} finally {
await handle.close().catch(() => undefined)
}
entries.sort((left, right) => (left.name === right.name ? 0 : left.name < right.name ? -1 : 1))
for (const entry of entries) {
if (limitReached) {
return
}
const entryPath = join(directory, entry.name)
let directoryEntry = entry.isDirectory()
if (entry.isSymbolicLink()) {
try {
directoryEntry = (await stat(entryPath)).isDirectory()
} catch {
if (knownNames.has(entry.name)) {
if (candidates.length >= maximumCandidates) {
limitReached = true
recordIncomplete(rootPath)
return
}
candidates.push({ name: entry.name, path: entryPath })
}
continue
}
}
if (!directoryEntry) {
continue
}
if (knownNames.has(entry.name)) {
if (candidates.length >= maximumCandidates) {
limitReached = true
recordIncomplete(rootPath)
return
}
candidates.push({ name: entry.name, path: entryPath })
continue
}
await visit(entryPath, depth + 1)
}
}
await visit(rootPath, 0)
return { candidates, incompletePaths: [...incompletePaths] }
}

View File

@ -321,6 +321,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 { SkillFreshnessInventory } from '../shared/skill-freshness'
import type {
CrashReportBreadcrumbData,
CrashReportCopyDiagnosticsArgs,
@ -2255,6 +2256,7 @@ export type PreloadApi = {
}
skills: {
discover: (target?: SkillDiscoveryTarget) => Promise<SkillDiscoveryResult>
freshnessInventory: () => Promise<SkillFreshnessInventory>
}
pet: {
import: () => Promise<CustomPet | null>

View File

@ -72,6 +72,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 { SkillFreshnessInventory } from '../shared/skill-freshness'
import type {
RuntimeBrowserDriverState,
RuntimeMobileSessionTabMove,
@ -2216,7 +2217,9 @@ const api = {
skills: {
discover: (target?: SkillDiscoveryTarget): Promise<SkillDiscoveryResult> =>
ipcRenderer.invoke('skills:discover', target)
ipcRenderer.invoke('skills:discover', target),
freshnessInventory: (): Promise<SkillFreshnessInventory> =>
ipcRenderer.invoke('skills:freshnessInventory')
},
pet: {

View File

@ -59,6 +59,8 @@ 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 { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge'
import { SkillFreshnessUpdateDialog } from './components/skills/SkillFreshnessUpdateDialog'
import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface'
import { ZoomOverlay } from './components/ZoomOverlay'
import { onOnboardingReopened } from './components/onboarding/show-onboarding-event'
@ -2828,10 +2830,20 @@ function App(): React.JSX.Element {
>
<RecentTabSwitcher />
</RecoverableRenderErrorBoundary>
{/* Why: the dialog hosts a live terminal pane, which requires the
link-routing preference context; mounting outside crashes it. */}
<RecoverableRenderErrorBoundary
boundaryId="overlay.skill-freshness-update-dialog"
surface="overlay"
compact
>
<SkillFreshnessUpdateDialog />
</RecoverableRenderErrorBoundary>
</LinkRoutingPreferenceDialogProvider>
</ConfirmationDialogProvider>
</TooltipProvider>
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
<SkillFreshnessNudge />
<PinnedTabCloseDialog />
{/* Why: rendered last so it sits after all -webkit-app-region:drag elements
in DOM order. Electron's hit-test for drag regions is DOM-order-based and

View File

@ -2,6 +2,7 @@ import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { Copy, Loader2, RefreshCw, Terminal } from 'lucide-react'
import { toast } from 'sonner'
import { IntegrationStatusPill } from '../integration-status-pill'
import { SkillFreshnessStatusPill } from '../skills/SkillFreshnessStatusPill'
import { OnboardingInlineCommandTerminal } from '../onboarding/OnboardingInlineCommandTerminal'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
@ -47,6 +48,10 @@ type AgentSkillSetupPanelProps = {
openingHint?: ReactNode
footer?: ReactNode
onRecheck: () => void | Promise<unknown>
// Why: when set, the installed pill reflects skill freshness and Re-check also
// refreshes the freshness inventory. Callers omit it for non-local runtimes,
// which the local-host-only freshness scan cannot vouch for.
freshnessSkillName?: string
}
export function AgentSkillSetupPanel({
@ -79,7 +84,8 @@ export function AgentSkillSetupPanel({
actionHint,
openingHint,
footer,
onRecheck
onRecheck,
freshnessSkillName
}: AgentSkillSetupPanelProps): React.JSX.Element {
const [terminalOpen, setTerminalOpen] = useState(false)
const [terminalCommand, setTerminalCommand] = useState<string | null>(null)
@ -211,7 +217,12 @@ export function AgentSkillSetupPanel({
variant="ghost"
size="sm"
className="gap-1.5"
onClick={() => void onRecheck()}
onClick={() => {
void onRecheck()
if (freshnessSkillName) {
notifyInstalledAgentSkillsChanged()
}
}}
disabled={loading}
>
<RefreshCw className={cn('size-3.5', loading && 'animate-spin')} />
@ -264,12 +275,16 @@ export function AgentSkillSetupPanel({
)}
</IntegrationStatusPill>
) : installed ? (
<IntegrationStatusPill tone="connected">
{translate(
'auto.components.settings.AgentSkillSetupPanel.9fcebceb2a',
'Installed'
)}
</IntegrationStatusPill>
freshnessSkillName ? (
<SkillFreshnessStatusPill skillName={freshnessSkillName} />
) : (
<IntegrationStatusPill tone="connected">
{translate(
'auto.components.settings.AgentSkillSetupPanel.9fcebceb2a',
'Installed'
)}
</IntegrationStatusPill>
)
) : (
<IntegrationStatusPill tone="attention">
{translate(

View File

@ -390,6 +390,7 @@ export function CliSection({
}))
}}
onRecheck={refreshCliSkill}
freshnessSkillName={agentRuntime.runtime === 'host' ? ORCA_CLI_SKILL_NAME : undefined}
/>
</div>
) : null}

View File

@ -79,6 +79,9 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element {
: ensureOrcaCliAvailableForAgentSkillTerminal())
}}
onRecheck={refreshComputerUseSkill}
freshnessSkillName={
activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : COMPUTER_USE_SKILL_NAME
}
/>
)
}

View File

@ -163,6 +163,9 @@ export function EphemeralVmsPane(): React.JSX.Element {
: ensureOrcaCliAvailableForAgentSkillTerminal())
}}
onRecheck={refreshSkill}
freshnessSkillName={
activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : EPHEMERAL_VMS_SKILL_NAME
}
/>
<div className="space-y-3 rounded-lg border border-border/60 bg-card/30 p-4">

View File

@ -7,6 +7,7 @@ import {
ORCHESTRATION_SKILL_INSTALL_COMMAND,
ORCHESTRATION_SKILL_UPDATE_COMMAND
} from '@/lib/orchestration-install-command'
import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands'
import type { InstalledAgentSkillState } from '@/hooks/useInstalledAgentSkills'
import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime'
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
@ -75,6 +76,9 @@ export function OrchestrationSetupCard(props: {
: ensureOrcaCliAvailableForAgentSkillTerminal())
}}
onRecheck={skill.refresh}
freshnessSkillName={
activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : ORCHESTRATION_SKILL_NAME
}
/>
)

View File

@ -100,6 +100,7 @@ import {
useInstalledAgentSkill
} from '@/hooks/useInstalledAgentSkills'
import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime'
import { useSkillFreshness } from '@/hooks/useSkillFreshness'
import { deriveNeededSectionIds, getInitialMountedSectionIds } from './settings-load-performance'
import { translate } from '@/i18n/i18n'
import { getProjectHostSetupProjectionFromState } from '../../store/selectors'
@ -207,11 +208,15 @@ function getSettingsNavGroupDefinitionsForSearch(
function getSkillNavInstallStatus(skill: {
installed: boolean
loading: boolean
updateAvailable?: boolean
}): SettingsNavInstallStatus {
if (skill.loading) {
return 'checking'
}
return skill.installed ? 'installed' : 'install'
if (!skill.installed) {
return 'install'
}
return skill.updateAvailable ? 'update-available' : 'installed'
}
function hasReadyVoiceModel(
@ -346,6 +351,10 @@ function Settings(): React.JSX.Element {
discoveryTarget: activeSkillRuntime.discoveryTarget,
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
// Why: mirror the setup cards — freshness only speaks for the validated global
// rail, which doesn't run under WSL, so the nav pill stays presence-only there.
const { inventory: skillFreshnessInventory } = useSkillFreshness()
const skillFreshnessApplies = activeSkillRuntime.agentRuntime?.runtime !== 'wsl'
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
@ -724,13 +733,16 @@ function Settings(): React.JSX.Element {
orchestrationSkill
const { installed: computerUseSkillInstalled, loading: computerUseSkillLoading } =
computerUseSkill
const eligibleUpdateSkillNames = skillFreshnessInventory?.eligibleUpdateNames
const capabilityInstallStatusBySectionId = useMemo(() => {
const eligibleUpdates = new Set(skillFreshnessApplies ? (eligibleUpdateSkillNames ?? []) : [])
const next = new Map<string, SettingsNavInstallStatus>([
[
'orchestration',
getSkillNavInstallStatus({
installed: orchestrationSkillInstalled,
loading: orchestrationSkillLoading
loading: orchestrationSkillLoading,
updateAvailable: eligibleUpdates.has(ORCHESTRATION_SKILL_NAME)
})
]
])
@ -739,7 +751,8 @@ function Settings(): React.JSX.Element {
'computer-use',
getSkillNavInstallStatus({
installed: computerUseSkillInstalled,
loading: computerUseSkillLoading
loading: computerUseSkillLoading,
updateAvailable: eligibleUpdates.has(COMPUTER_USE_SKILL_NAME)
})
)
if (settings) {
@ -757,11 +770,13 @@ function Settings(): React.JSX.Element {
}, [
computerUseSkillInstalled,
computerUseSkillLoading,
eligibleUpdateSkillNames,
modelStates,
orchestrationSkillInstalled,
orchestrationSkillLoading,
settings,
showDesktopOnlySettings,
skillFreshnessApplies,
voiceModelStatesLoading
])
const navSections = useMemo(

View File

@ -155,6 +155,11 @@ export function SettingsSidebar({
)
case 'installed':
return translate('auto.components.settings.AgentSkillSetupPanel.9fcebceb2a', 'Installed')
case 'update-available':
return translate(
'auto.components.skills.SkillFreshnessStatusPill.updateAvailable',
'Update available'
)
case 'checking':
return translate('auto.components.settings.AgentSkillSetupPanel.68a468752e', 'Checking...')
}
@ -164,9 +169,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 === 'update-available'
? '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 (

View File

@ -0,0 +1,230 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
SkillFreshnessInstallation,
SkillFreshnessInventory
} from '../../../../shared/skill-freshness'
import { SkillFreshnessNudge } from './SkillFreshnessNudge'
const mocks = vi.hoisted(() => ({
dismissed: [] as string[],
updateSettings: vi.fn(),
toastInfo: vi.fn(),
toastDismiss: vi.fn(),
requestDialog: vi.fn(),
settingsLoaded: true,
inventory: null as SkillFreshnessInventory | null,
error: null as string | null
}))
function placement(
overrides: Partial<SkillFreshnessInstallation> = {}
): SkillFreshnessInstallation {
return {
id: 'orca-cli',
name: 'orca-cli',
rootId: 'home-agents',
providers: ['agent-skills'],
sourceKind: 'home',
sourceLabel: 'Agent skills home',
unresolvedPath: '/home/.agents/skills/orca-cli',
resolvedPath: '/home/.agents/skills/orca-cli',
physicalIdentity: 'physical-orca-cli',
topology: 'canonical-copy',
status: 'outdated',
installedReleaseRevision: 1,
installedAppVersion: '1.0.0',
currentReleaseRevision: 2,
currentPackageDigest: 'current',
currentAppVersion: '2.0.0',
observedPackageDigest: 'old',
errorCategory: null,
...overrides
}
}
function eligibleInventory(): SkillFreshnessInventory {
return {
schemaVersion: 1,
installations: [placement()],
eligibleUpdateNames: ['orca-cli'],
scannedAt: 1
}
}
vi.mock('@/hooks/useSkillFreshness', () => ({
useSkillFreshness: () => ({
inventory: mocks.inventory,
loading: false,
error: mocks.error,
refresh: vi.fn()
})
}))
vi.mock('sonner', () => ({
toast: { info: mocks.toastInfo, dismiss: mocks.toastDismiss }
}))
vi.mock('./skill-freshness-update-dialog', () => ({
requestSkillFreshnessUpdateDialog: mocks.requestDialog
}))
vi.mock('@/store', () => {
const state = () => ({
settings: mocks.settingsLoaded ? { dismissedSkillFreshnessNudges: mocks.dismissed } : null,
updateSettings: mocks.updateSettings
})
const useAppStore = (selector: (value: ReturnType<typeof state>) => unknown) => selector(state())
useAppStore.getState = state
return { useAppStore }
})
let root: Root | null = null
let container: HTMLDivElement | null = null
async function renderNudge(): Promise<void> {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(<SkillFreshnessNudge />)
})
}
async function rerenderNudge(): Promise<void> {
await act(async () => {
root?.render(<SkillFreshnessNudge />)
})
}
const DISMISSAL_KEY = ['physical-orca-cli', 'orca-cli', '2'].join('\0')
describe('SkillFreshnessNudge', () => {
beforeEach(() => {
mocks.dismissed = []
mocks.settingsLoaded = true
mocks.inventory = eligibleInventory()
mocks.error = null
mocks.updateSettings.mockReset()
mocks.updateSettings.mockResolvedValue(undefined)
mocks.toastInfo.mockReset()
mocks.toastInfo.mockReturnValue('freshness-toast')
mocks.toastDismiss.mockReset()
mocks.requestDialog.mockReset()
})
afterEach(async () => {
if (root) {
await act(async () => root?.unmount())
}
root = null
container?.remove()
container = null
})
it('lingers without an auto-close timer or auto-close dismissal write', async () => {
await renderNudge()
expect(mocks.toastInfo).toHaveBeenCalledTimes(1)
const options = mocks.toastInfo.mock.calls[0]?.[1]
expect(options.duration).toBe(Number.POSITIVE_INFINITY)
expect(options.onAutoClose).toBeUndefined()
expect(mocks.updateSettings).not.toHaveBeenCalled()
})
it('opens the update dialog on action click without persisting a dismissal', async () => {
await renderNudge()
const options = mocks.toastInfo.mock.calls[0]?.[1]
options.action.onClick()
options.onDismiss()
expect(mocks.requestDialog).toHaveBeenCalledTimes(1)
expect(mocks.updateSettings).not.toHaveBeenCalled()
})
it('retracts a resolved nudge without recording a dismissal', async () => {
await renderNudge()
const options = mocks.toastInfo.mock.calls[0]?.[1]
mocks.inventory = {
schemaVersion: 1,
installations: [placement({ status: 'current', observedPackageDigest: 'current' })],
eligibleUpdateNames: [],
scannedAt: 2
}
await rerenderNudge()
// Sonner invokes onDismiss for programmatic dismissals on its next render.
options.onDismiss()
expect(mocks.toastDismiss).toHaveBeenCalledWith('freshness-toast')
expect(mocks.updateSettings).not.toHaveBeenCalled()
})
it('retracts a stale nudge when re-inventory fails', async () => {
await renderNudge()
const options = mocks.toastInfo.mock.calls[0]?.[1]
mocks.inventory = null
mocks.error = 'scan failed'
await rerenderNudge()
options.onDismiss()
expect(mocks.toastDismiss).toHaveBeenCalledWith('freshness-toast')
expect(mocks.updateSettings).not.toHaveBeenCalled()
})
it('persists the exact placement/revision key once on explicit dismissal', async () => {
await renderNudge()
const options = mocks.toastInfo.mock.calls[0]?.[1]
options.onDismiss()
options.onDismiss()
expect(mocks.updateSettings).toHaveBeenCalledTimes(1)
expect(mocks.updateSettings).toHaveBeenCalledWith({
dismissedSkillFreshnessNudges: [DISMISSAL_KEY]
})
})
it('does not repeat the same nudge within a session', async () => {
await renderNudge()
// A fresh inventory object with identical content re-runs the effect.
mocks.inventory = eligibleInventory()
await rerenderNudge()
expect(mocks.toastInfo).toHaveBeenCalledTimes(1)
})
it('does not repeat a nudge for an already dismissed exact tuple', async () => {
mocks.dismissed = [DISMISSAL_KEY]
await renderNudge()
expect(mocks.toastInfo).not.toHaveBeenCalled()
})
it('does not nudge for a poisoned name with no eligible update', async () => {
mocks.inventory = {
schemaVersion: 1,
installations: [placement(), placement({ id: 'repo-copy', topology: 'repo-scope' })],
eligibleUpdateNames: [],
scannedAt: 1
}
await renderNudge()
expect(mocks.toastInfo).not.toHaveBeenCalled()
})
it('waits for persisted settings before deciding whether to nudge', async () => {
mocks.settingsLoaded = false
await renderNudge()
expect(mocks.toastInfo).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,183 @@
import { useEffect, useRef } from 'react'
import { Terminal } from 'lucide-react'
import { toast } from 'sonner'
import { useSkillFreshness } from '@/hooks/useSkillFreshness'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { requestSkillFreshnessUpdateDialog } from './skill-freshness-update-dialog'
const MAX_DISMISSED_FRESHNESS_NUDGES = 512
const NO_DISMISSED_FRESHNESS_NUDGES: string[] = []
type ActiveFreshnessNudge = {
id: string | number
fingerprint: string
persistDismissal: boolean
}
function candidateKey(args: {
physicalIdentity: string
name: string
currentReleaseRevision: number
}): string {
return [args.physicalIdentity, args.name, args.currentReleaseRevision].join('\0')
}
export function SkillFreshnessNudge(): null {
const state = useSkillFreshness()
const settingsLoaded = useAppStore((store) => store.settings !== null)
const dismissed = useAppStore(
(store) => store.settings?.dismissedSkillFreshnessNudges ?? NO_DISMISSED_FRESHNESS_NUDGES
)
const updateSettings = useAppStore((store) => store.updateSettings)
const shownFingerprints = useRef(new Set<string>())
const persistedFingerprints = useRef(new Set<string>())
const activeNudgeRef = useRef<ActiveFreshnessNudge | null>(null)
useEffect(() => {
const inventory = state.inventory
if (!settingsLoaded) {
return
}
if (!inventory) {
const active = activeNudgeRef.current
if (state.error && active) {
// Why: a failed re-check cannot keep advertising authority derived from
// old bytes; retract without turning the scan failure into a dismissal.
active.persistDismissal = false
activeNudgeRef.current = null
toast.dismiss(active.id)
}
return
}
const eligibleNames = new Set(inventory.eligibleUpdateNames)
const candidates = inventory.installations.flatMap((installation) =>
installation.status === 'outdated' &&
eligibleNames.has(installation.name) &&
installation.physicalIdentity
? [
{
key: candidateKey({
physicalIdentity: installation.physicalIdentity,
name: installation.name,
currentReleaseRevision: installation.currentReleaseRevision
}),
name: installation.name
}
]
: []
)
const dismissedKeys = new Set(dismissed)
const unseen = candidates.filter((candidate) => !dismissedKeys.has(candidate.key))
if (unseen.length === 0) {
const active = activeNudgeRef.current
if (active) {
// Why: a resolved/replaced nudge is stale presentation, not an explicit
// user dismissal, so retract it without persisting its tuple keys.
active.persistDismissal = false
activeNudgeRef.current = null
toast.dismiss(active.id)
}
return
}
const fingerprint = unseen
.map((candidate) => candidate.key)
.sort((left, right) => left.localeCompare(right, 'en'))
.join('\n')
const active = activeNudgeRef.current
if (active?.fingerprint === fingerprint) {
return
}
if (active) {
active.persistDismissal = false
activeNudgeRef.current = null
toast.dismiss(active.id)
}
if (shownFingerprints.current.has(fingerprint)) {
return
}
shownFingerprints.current.add(fingerprint)
const persistDismissal = (): void => {
if (persistedFingerprints.current.has(fingerprint)) {
return
}
persistedFingerprints.current.add(fingerprint)
const current = useAppStore.getState().settings?.dismissedSkillFreshnessNudges ?? []
const next = [...new Set([...current, ...unseen.map((candidate) => candidate.key)])].slice(
-MAX_DISMISSED_FRESHNESS_NUDGES
)
void updateSettings({ dismissedSkillFreshnessNudges: next }).catch(() => {
persistedFingerprints.current.delete(fingerprint)
})
}
const names = new Set(unseen.map((candidate) => candidate.name))
// Why: name the outdated skills so the nudge is actionable without opening
// the modal; the sentence is translatable but the identifiers interpolate as-is.
const outdatedNames = [...names]
.sort((left, right) => left.localeCompare(right, 'en'))
.join(', ')
const nextActive: ActiveFreshnessNudge = {
id: '',
fingerprint,
persistDismissal: true
}
nextActive.id = toast.info(
names.size === 1
? translate(
'auto.components.skills.SkillFreshnessNudge.titleOne',
'An installed Orca skill is out of date'
)
: translate(
'auto.components.skills.SkillFreshnessNudge.titleMany',
'{{value0}} installed Orca skills are out of date',
{ value0: names.size }
),
{
description: translate(
'auto.components.skills.SkillFreshnessNudge.description',
'Update {{value0}} so agents follow the current instructions for this version of Orca.',
{ value0: outdatedNames }
),
// Why: the nudge lingers until the user acts. Ignoring it (app quit)
// records nothing, so a still-outdated skill may prompt once next launch.
duration: Number.POSITIVE_INFINITY,
// Why: only an explicit dismissal (the close button) records the keys;
// opening the review dialog is engagement, not a decision to hide it.
onDismiss: () => {
if (nextActive.persistDismissal) {
persistDismissal()
}
if (activeNudgeRef.current === nextActive) {
activeNudgeRef.current = null
}
},
action: {
label: (
<span className="inline-flex items-center gap-1.5">
<Terminal className="size-3.5" />
{names.size === 1
? translate('auto.components.skills.SkillFreshnessNudge.updateOne', 'Update skill')
: translate(
'auto.components.skills.SkillFreshnessNudge.updateMany',
'Update skills'
)}
</span>
),
onClick: () => {
// Sonner closes action toasts without onDismiss; clear ownership so
// a later inventory cannot treat the already-closed toast as active.
nextActive.persistDismissal = false
if (activeNudgeRef.current === nextActive) {
activeNudgeRef.current = null
}
requestSkillFreshnessUpdateDialog()
}
}
}
)
activeNudgeRef.current = nextActive
}, [dismissed, settingsLoaded, state.error, state.inventory, updateSettings])
return null
}

View File

@ -0,0 +1,107 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness'
import { SkillFreshnessStatusPill } from './SkillFreshnessStatusPill'
const mocks = vi.hoisted(() => ({
inventory: null as SkillFreshnessInventory | null
}))
vi.mock('@/hooks/useSkillFreshness', () => ({
useSkillFreshness: () => ({
inventory: mocks.inventory,
loading: false,
error: null,
refresh: vi.fn()
})
}))
function inventory(
entries: { name: string; status: 'current' | 'outdated' | 'unrecognized' }[],
eligibleUpdateNames: string[]
): SkillFreshnessInventory {
return {
schemaVersion: 1,
installations: entries.map((entry, index) => ({
id: `${entry.name}-${index}`,
name: entry.name,
rootId: 'home-agents',
providers: ['agent-skills'],
sourceKind: 'home',
sourceLabel: 'Agent skills home',
unresolvedPath: `/home/.agents/skills/${entry.name}`,
resolvedPath: `/home/.agents/skills/${entry.name}`,
physicalIdentity: `physical-${entry.name}-${index}`,
topology: 'canonical-copy',
status: entry.status,
installedReleaseRevision: 1,
installedAppVersion: '1.0.0',
currentReleaseRevision: 2,
currentPackageDigest: 'current',
currentAppVersion: '2.0.0',
observedPackageDigest: 'old',
errorCategory: null
})),
eligibleUpdateNames,
scannedAt: 1
}
}
let root: Root | null = null
let container: HTMLDivElement | null = null
async function renderPill(skillName: string): Promise<HTMLDivElement> {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(<SkillFreshnessStatusPill skillName={skillName} />)
})
return container
}
describe('SkillFreshnessStatusPill', () => {
beforeEach(() => {
mocks.inventory = null
})
afterEach(async () => {
if (root) {
await act(async () => root?.unmount())
}
root = null
container?.remove()
container = null
})
it('shows Update available for an eligible outdated skill', async () => {
mocks.inventory = inventory([{ name: 'orca-cli', status: 'outdated' }], ['orca-cli'])
expect((await renderPill('orca-cli')).textContent).toBe('Update available')
})
it('shows Up to date when every placement is current', async () => {
mocks.inventory = inventory([{ name: 'orca-cli', status: 'current' }], [])
expect((await renderPill('orca-cli')).textContent).toBe('Up to date')
})
it('falls back to Installed for a blocked outdated placement', async () => {
mocks.inventory = inventory(
[
{ name: 'orca-cli', status: 'outdated' },
{ name: 'orca-cli', status: 'unrecognized' }
],
[]
)
expect((await renderPill('orca-cli')).textContent).toBe('Installed')
})
it('falls back to Installed before the inventory loads', async () => {
expect((await renderPill('orca-cli')).textContent).toBe('Installed')
})
})

View File

@ -0,0 +1,40 @@
import { useSkillFreshness } from '@/hooks/useSkillFreshness'
import { translate } from '@/i18n/i18n'
import { IntegrationStatusPill } from '@/components/integration-status-pill'
// Why: the setup rails' Installed pill is presence-only; when freshness knows a
// safe update exists (or that every copy is current) the pill should say so.
// Falls back to plain Installed for blocked/unrecognized copies so an unsafe
// placement is never advertised as updatable here.
export function SkillFreshnessStatusPill({ skillName }: { skillName: string }): React.JSX.Element {
const { inventory } = useSkillFreshness()
if (inventory?.eligibleUpdateNames.includes(skillName)) {
return (
<IntegrationStatusPill tone="attention">
{translate(
'auto.components.skills.SkillFreshnessStatusPill.updateAvailable',
'Update available'
)}
</IntegrationStatusPill>
)
}
const placements = inventory?.installations.filter(
(installation) => installation.name === skillName
)
if (
placements &&
placements.length > 0 &&
placements.every((installation) => installation.status === 'current')
) {
return (
<IntegrationStatusPill tone="connected">
{translate('auto.components.skills.SkillFreshnessStatusPill.upToDate', 'Up to date')}
</IntegrationStatusPill>
)
}
return (
<IntegrationStatusPill tone="connected">
{translate('auto.components.skills.SkillFreshnessStatusPill.installed', 'Installed')}
</IntegrationStatusPill>
)
}

View File

@ -0,0 +1,371 @@
// @vitest-environment happy-dom
import { act, useState, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
SkillFreshnessInstallation,
SkillFreshnessInventory
} from '../../../../shared/skill-freshness'
import { SkillFreshnessUpdateDialog } from './SkillFreshnessUpdateDialog'
import {
consumeSkillFreshnessUpdateDialogRequest,
requestSkillFreshnessUpdateDialog
} from './skill-freshness-update-dialog'
const mocks = vi.hoisted(() => ({
inventory: null as SkillFreshnessInventory | null,
loading: false,
error: null as string | null,
refresh: vi.fn(),
terminalProps: [] as {
command: string
description: string
onInteracted?: (method: 'keyboard' | 'pointer', event?: { key?: string }) => void
onTerminalExit?: () => void
}[],
notifyChanged: vi.fn()
}))
vi.mock('@/hooks/useSkillFreshness', () => ({
useSkillFreshness: () => ({
inventory: mocks.inventory,
loading: mocks.loading,
error: mocks.error,
refresh: mocks.refresh
})
}))
vi.mock('@/hooks/useInstalledAgentSkills', () => ({
notifyInstalledAgentSkillsChanged: mocks.notifyChanged
}))
vi.mock('@/components/onboarding/OnboardingInlineCommandTerminal', () => ({
OnboardingInlineCommandTerminal: (props: (typeof mocks.terminalProps)[number]) => {
mocks.terminalProps.push(props)
return <div data-testid="update-terminal">{props.command}</div>
}
}))
// Radix Dialog/Collapsible internals (portal, focus-scope) are exercised in
// Electron QA; here the content logic is what matters, so use plain wrappers.
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ open, children }: { open: boolean; children?: ReactNode }) =>
open ? <div data-dialog-open="true">{children}</div> : null,
DialogContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogDescription: ({ children }: { children?: ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children?: ReactNode }) => <h2>{children}</h2>
}))
vi.mock('@/components/ui/collapsible', () => ({
Collapsible: ({
children,
defaultOpen = false
}: {
children?: ReactNode
defaultOpen?: boolean
}) => {
// Model Radix's uncontrolled default: changing defaultOpen only matters
// when the keyed disclosure remounts.
const [open] = useState(defaultOpen)
return <div data-collapsible-open={String(open)}>{children}</div>
},
CollapsibleTrigger: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
CollapsibleContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>
}))
function placement(
name: string,
overrides: Partial<SkillFreshnessInstallation> = {}
): SkillFreshnessInstallation {
return {
id: `${name}-${overrides.rootId ?? 'home-agents'}`,
name,
rootId: 'home-agents',
providers: ['agent-skills'],
sourceKind: 'home',
sourceLabel: 'Agent skills home',
unresolvedPath: `/home/.agents/skills/${name}`,
resolvedPath: `/home/.agents/skills/${name}`,
physicalIdentity: `physical-${name}`,
topology: 'canonical-copy',
status: 'outdated',
installedReleaseRevision: 1,
installedAppVersion: '1.0.0',
currentReleaseRevision: 2,
currentPackageDigest: 'current',
currentAppVersion: '2.0.0',
observedPackageDigest: 'old',
errorCategory: null,
...overrides
}
}
function eligibleInventory(): SkillFreshnessInventory {
return {
schemaVersion: 1,
installations: [placement('orca-cli')],
eligibleUpdateNames: ['orca-cli'],
scannedAt: 1
}
}
let root: Root | null = null
let container: HTMLDivElement | null = null
async function renderDialog(): Promise<void> {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
await act(async () => {
root?.render(<SkillFreshnessUpdateDialog />)
})
}
async function rerender(): Promise<void> {
await act(async () => {
root?.render(<SkillFreshnessUpdateDialog />)
})
}
async function openViaRequest(): Promise<void> {
await act(async () => {
requestSkillFreshnessUpdateDialog()
})
}
async function clickButton(label: string): Promise<void> {
const button = Array.from(container?.querySelectorAll('button') ?? []).find(
(candidate) => candidate.textContent?.trim() === label
)
expect(button).toBeDefined()
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
}
describe('SkillFreshnessUpdateDialog', () => {
beforeEach(() => {
consumeSkillFreshnessUpdateDialogRequest()
mocks.inventory = eligibleInventory()
mocks.loading = false
mocks.error = null
mocks.refresh.mockReset()
mocks.notifyChanged.mockReset()
mocks.terminalProps.length = 0
})
afterEach(async () => {
if (root) {
await act(async () => root?.unmount())
}
root = null
container?.remove()
container = null
})
it('stays closed until an open request arrives', async () => {
await renderDialog()
expect(container?.querySelector('[data-dialog-open]')).toBeNull()
})
it('shows the eligible summary and the exact pre-filled draft command', async () => {
await renderDialog()
await openViaRequest()
expect(container?.textContent).toContain('Update skills')
expect(container?.textContent).toContain('1 skill can be updated safely')
expect(mocks.terminalProps.at(-1)).toMatchObject({
command: 'npx skills update orca-cli --global',
description: 'Review the pre-filled command, then press Enter to run it.'
})
})
it('resolves a request made before inventory loads once a safe command arrives', async () => {
mocks.inventory = null
await openViaRequest()
await renderDialog()
expect(container?.querySelector('[data-dialog-open]')).not.toBeNull()
expect(mocks.terminalProps).toEqual([])
mocks.inventory = eligibleInventory()
await rerender()
expect(mocks.terminalProps.at(-1)?.command).toBe('npx skills update orca-cli --global')
})
it('shows the up-to-date state once every installation is current', async () => {
await renderDialog()
await openViaRequest()
mocks.inventory = {
schemaVersion: 1,
installations: [
placement('orca-cli', { status: 'current', observedPackageDigest: 'current' })
],
eligibleUpdateNames: [],
scannedAt: 2
}
await rerender()
expect(container?.textContent).toContain('All installed Orca skills are up to date.')
expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull()
})
it('auto-expands Details when a rescan changes the result to blocked', async () => {
await renderDialog()
await openViaRequest()
expect(container?.querySelector('[data-collapsible-open="false"]')).not.toBeNull()
mocks.inventory = {
schemaVersion: 1,
installations: [placement('orca-cli', { topology: 'read-only' })],
eligibleUpdateNames: [],
scannedAt: 2
}
await rerender()
expect(container?.querySelector('[data-collapsible-open="true"]')).not.toBeNull()
})
it('does not replace or tear down a submitted command during a rescan', async () => {
await renderDialog()
await openViaRequest()
await act(async () => {
mocks.terminalProps.at(-1)?.onInteracted?.('keyboard', { key: 'Enter' })
})
mocks.inventory = null
mocks.loading = true
await rerender()
expect(container?.querySelector('[data-testid="update-terminal"]')?.textContent).toBe(
'npx skills update orca-cli --global'
)
mocks.inventory = {
schemaVersion: 1,
installations: [
placement('orca-cli', { status: 'current', observedPackageDigest: 'current' })
],
eligibleUpdateNames: [],
scannedAt: 2
}
mocks.loading = false
await rerender()
expect(container?.textContent).toContain('All installed Orca skills are up to date.')
expect(container?.querySelector('[data-testid="update-terminal"]')?.textContent).toBe(
'npx skills update orca-cli --global'
)
})
it('removes an unsubmitted draft as soon as its inventory is invalidated', async () => {
await renderDialog()
await openViaRequest()
expect(container?.querySelector('[data-testid="update-terminal"]')).not.toBeNull()
mocks.inventory = null
mocks.loading = true
await rerender()
expect(container?.textContent).toContain('Checking installed Orca skills')
expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull()
})
it('shows a failed scan as an error instead of indefinite progress', async () => {
mocks.inventory = null
mocks.error = 'Could not inspect installed skills.'
await renderDialog()
await openViaRequest()
expect(container?.textContent).toContain('Could not inspect installed skills.')
expect(container?.textContent).not.toContain('Checking installed Orca skills')
expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull()
})
it('creates a fresh draft after a terminal exits and the rescan still finds an update', async () => {
await renderDialog()
await openViaRequest()
const firstTerminal = mocks.terminalProps.at(-1)
await act(async () => {
firstTerminal?.onTerminalExit?.()
})
expect(mocks.notifyChanged).toHaveBeenCalledTimes(1)
expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull()
mocks.inventory = { ...eligibleInventory(), scannedAt: 2 }
await rerender()
expect(container?.querySelector('[data-testid="update-terminal"]')?.textContent).toBe(
'npx skills update orca-cli --global'
)
expect(mocks.terminalProps.at(-1)).not.toBe(firstTerminal)
})
it('explains a poisoned sibling in Details without offering a command', async () => {
mocks.inventory = {
schemaVersion: 1,
installations: [
placement('orca-cli'),
placement('orca-cli', {
id: 'repo-copy',
rootId: 'repo',
sourceKind: 'repo',
topology: 'repo-scope',
status: 'unrecognized',
unresolvedPath: '/repo/.agents/skills/orca-cli'
})
],
eligibleUpdateNames: [],
scannedAt: 1
}
await renderDialog()
await openViaRequest()
expect(container?.textContent).toContain(
'it may be modified, or a different skill with the same name'
)
expect(container?.textContent).toContain('Unrecognized')
expect(container?.querySelector('[data-testid="update-terminal"]')).toBeNull()
})
it('explains a self-blocked read-only placement without blaming a sibling', async () => {
mocks.inventory = {
schemaVersion: 1,
installations: [placement('orca-cli', { topology: 'read-only' })],
eligibleUpdateNames: [],
scannedAt: 1
}
await renderDialog()
await openViaRequest()
expect(container?.textContent).toContain(
'read-only location, so Orca left it out of the update'
)
expect(container?.textContent).toContain('Read only')
})
it('re-inventories installed skills when the dialog closes', async () => {
await renderDialog()
await openViaRequest()
await clickButton('Close')
expect(mocks.notifyChanged).toHaveBeenCalledTimes(1)
expect(container?.querySelector('[data-dialog-open]')).toBeNull()
})
it('forces a re-check from the Re-check affordance', async () => {
await renderDialog()
await openViaRequest()
await clickButton('Re-check')
expect(mocks.refresh).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,310 @@
import {
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
type KeyboardEvent
} from 'react'
import { AlertTriangle, CheckCircle2, ChevronDown, Loader2, RefreshCw } from 'lucide-react'
import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness'
import { buildTargetedSkillUpdateCommand } from '../../../../shared/skill-freshness'
import { useSkillFreshness } from '@/hooks/useSkillFreshness'
import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills'
import { translate } from '@/i18n/i18n'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { OnboardingInlineCommandTerminal } from '@/components/onboarding/OnboardingInlineCommandTerminal'
import { TooltipProvider } from '@/components/ui/tooltip'
import { groupSkillFreshness } from './skill-freshness-grouping'
import { SkillFreshnessGroup } from './skill-freshness-group'
import {
consumeSkillFreshnessUpdateDialogRequest,
getSkillFreshnessUpdateDialogRequest,
subscribeSkillFreshnessUpdateDialog
} from './skill-freshness-update-dialog'
type FreshnessSummaryKind = 'loading' | 'empty' | 'eligible' | 'current' | 'attention'
function summarizeInventory(
inventory: SkillFreshnessInventory | null,
hasBlockedGroup: boolean
): FreshnessSummaryKind {
if (!inventory) {
return 'loading'
}
if (inventory.installations.length === 0) {
return 'empty'
}
if (inventory.eligibleUpdateNames.length > 0) {
return 'eligible'
}
// Why: with nothing eligible, the modal is either genuinely all-clear or has
// out-of-date skills it can't safely update; the group filter already dropped
// the up-to-date and unrecognized-only noise, so a blocked group is the signal.
return hasBlockedGroup ? 'attention' : 'current'
}
function SummaryHeadline({
kind,
eligibleCount
}: {
kind: FreshnessSummaryKind
eligibleCount: number
}): React.JSX.Element {
if (kind === 'loading') {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{translate(
'auto.components.skills.SkillFreshnessUpdateDialog.checking',
'Checking installed Orca skills…'
)}
</div>
)
}
if (kind === 'empty') {
return (
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.skills.SkillFreshnessUpdateDialog.none',
'No installed Orca skills found.'
)}
</p>
)
}
if (kind === 'current') {
return (
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<CheckCircle2 className="size-4 text-emerald-600 dark:text-emerald-400" />
{translate(
'auto.components.skills.SkillFreshnessUpdateDialog.success',
'All installed Orca skills are up to date.'
)}
</div>
)
}
if (kind === 'attention') {
return (
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<AlertTriangle className="size-4 text-amber-600 dark:text-amber-400" />
{translate(
'auto.components.skills.SkillFreshnessUpdateDialog.attention',
'Some installed Orca skills were left out of the update.'
)}
</div>
<p className="text-xs text-muted-foreground">
{translate(
'auto.components.skills.SkillFreshnessUpdateDialog.attentionDescription',
'Open Update details to see why each one was skipped.'
)}
</p>
</div>
)
}
return (
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
{eligibleCount === 1
? translate(
'auto.components.skills.SkillFreshnessUpdateDialog.updateOne',
'1 skill can be updated safely'
)
: translate(
'auto.components.skills.SkillFreshnessUpdateDialog.updateMany',
'{{value0}} skills can be updated safely',
{ value0: eligibleCount }
)}
</p>
</div>
)
}
export function SkillFreshnessUpdateDialog(): React.JSX.Element {
const state = useSkillFreshness()
const open = useSyncExternalStore(
subscribeSkillFreshnessUpdateDialog,
getSkillFreshnessUpdateDialogRequest,
getSkillFreshnessUpdateDialogRequest
)
const [terminalCommand, setTerminalCommand] = useState<string | null>(null)
const [awaitingExitRefresh, setAwaitingExitRefresh] = useState(false)
const terminalSubmittedRef = useRef(false)
const inventoryAtTerminalExitRef = useRef<SkillFreshnessInventory | null>(null)
const inventory = state.inventory
const eligibleNames = useMemo(() => inventory?.eligibleUpdateNames ?? [], [inventory])
const groups = useMemo(
() =>
inventory ? groupSkillFreshness(inventory.installations, inventory.eligibleUpdateNames) : [],
[inventory]
)
const hasBlockedGroup = groups.some((group) => group.status === 'cannot-update')
const updateCommand = buildTargetedSkillUpdateCommand(eligibleNames)
const summaryKind = summarizeInventory(inventory, hasBlockedGroup)
useEffect(() => {
if (!open) {
return
}
if (state.loading || state.error || !inventory) {
// Why: a scan invalidates the authorization behind an unsubmitted draft.
// A running command keeps its PTY until exit, but stale drafts fail closed.
if (!terminalSubmittedRef.current && terminalCommand !== null) {
setTerminalCommand(null)
}
return
}
if (awaitingExitRefresh) {
if (inventory === inventoryAtTerminalExitRef.current) {
return
}
inventoryAtTerminalExitRef.current = null
setAwaitingExitRefresh(false)
return
}
if (terminalSubmittedRef.current || terminalCommand === updateCommand) {
return
}
// Why: changing the shared onboarding terminal's command pastes it again.
// Replace only an unsubmitted draft; a submitted command owns its PTY until exit.
setTerminalCommand(updateCommand)
}, [
awaitingExitRefresh,
inventory,
open,
state.error,
state.loading,
terminalCommand,
updateCommand
])
const handleOpenChange = (next: boolean): void => {
// Why: closing is the natural point to re-observe bytes so a completed update
// clears the state and the lingering nudge does not fire again.
if (!next) {
consumeSkillFreshnessUpdateDialogRequest()
terminalSubmittedRef.current = false
inventoryAtTerminalExitRef.current = null
setAwaitingExitRefresh(false)
setTerminalCommand(null)
notifyInstalledAgentSkillsChanged()
}
}
const handleTerminalInteraction = (
method: 'keyboard' | 'pointer',
event?: KeyboardEvent<HTMLElement>
): void => {
if (method === 'keyboard' && event?.key === 'Enter') {
terminalSubmittedRef.current = true
}
}
const handleTerminalExit = (): void => {
terminalSubmittedRef.current = false
inventoryAtTerminalExitRef.current = inventory
setAwaitingExitRefresh(true)
setTerminalCommand(null)
notifyInstalledAgentSkillsChanged()
}
const hasVisibleGroups = groups.length > 0
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
aria-describedby={undefined}
className="scrollbar-sleek max-h-[85vh] overflow-y-auto sm:max-w-xl"
>
<DialogHeader>
<DialogTitle>
{translate('auto.components.skills.SkillFreshnessUpdateDialog.title', 'Update skills')}
</DialogTitle>
</DialogHeader>
{state.error ? (
<p className="text-xs text-destructive">{state.error}</p>
) : (
<SummaryHeadline kind={summaryKind} eligibleCount={eligibleNames.length} />
)}
{terminalCommand ? (
<OnboardingInlineCommandTerminal
key={terminalCommand}
command={terminalCommand}
title={translate(
'auto.components.skills.SkillFreshnessUpdateDialog.terminalTitle',
'Update Orca skills'
)}
description={translate(
'auto.components.skills.SkillFreshnessUpdateDialog.terminalDescription',
'Review the pre-filled command, then press Enter to run it.'
)}
ariaLabel={translate(
'auto.components.skills.SkillFreshnessUpdateDialog.terminalAria',
'Orca skill update terminal'
)}
worktreeId="skill-freshness-update-terminal"
terminalHeightPx={200}
terminalTopMarginPx={0}
autoScrollIntoView={false}
onInteracted={handleTerminalInteraction}
onTerminalExit={handleTerminalExit}
/>
) : null}
{/* Why: Radix reads defaultOpen only on mount. Remount when a scan
becomes blocked so the required diagnostic details actually open. */}
{hasVisibleGroups ? (
<Collapsible key={hasBlockedGroup ? 'blocked' : 'default'} defaultOpen={hasBlockedGroup}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="xs"
className="group -ml-2 gap-1.5 text-muted-foreground"
>
<ChevronDown className="size-3.5 transition-transform group-data-[state=open]:rotate-180" />
{translate(
'auto.components.skills.SkillFreshnessUpdateDialog.details',
'Update details'
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-1 divide-y divide-border/40 rounded-md border border-border/60 p-3">
<TooltipProvider>
{groups.map((group) => (
<SkillFreshnessGroup key={group.name} group={group} />
))}
</TooltipProvider>
</CollapsibleContent>
</Collapsible>
) : null}
<DialogFooter className="sm:justify-between">
<Button
type="button"
variant="ghost"
size="sm"
disabled={state.loading}
onClick={() => void state.refresh()}
>
<RefreshCw className={state.loading ? 'animate-spin' : undefined} />
{translate('auto.components.skills.SkillFreshnessUpdateDialog.checkNow', 'Re-check')}
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => handleOpenChange(false)}>
{translate('auto.components.skills.SkillFreshnessUpdateDialog.close', 'Close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,203 @@
import type {
SkillFreshnessGroupModel,
SkillLocationChip,
SkillLocationRow
} from './skill-freshness-grouping'
import { translate } from '@/i18n/i18n'
import { Badge } from '@/components/ui/badge'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
function chipLabel(chip: SkillLocationChip): string {
switch (chip) {
case 'current':
return translate('auto.components.skills.SkillFreshnessRow.chipCurrent', 'Current')
case 'unrecognized':
return translate('auto.components.skills.SkillFreshnessRow.chipUnrecognized', 'Unrecognized')
case 'inaccessible':
return translate('auto.components.skills.SkillFreshnessRow.chipInaccessible', 'Inaccessible')
case 'duplicate':
return translate('auto.components.skills.SkillFreshnessRow.chipDuplicate', 'Duplicate')
case 'external-link':
return translate('auto.components.skills.SkillFreshnessRow.chipExternalLink', 'External link')
case 'broken-link':
return translate('auto.components.skills.SkillFreshnessRow.chipBrokenLink', 'Broken link')
case 'read-only':
return translate('auto.components.skills.SkillFreshnessRow.chipReadOnly', 'Read only')
case 'in-a-repo':
return translate('auto.components.skills.SkillFreshnessRow.chipInRepo', 'In a repo')
case 'plugin-cache':
return translate('auto.components.skills.SkillFreshnessRow.chipPluginCache', 'Plugin cache')
}
}
// Why: chips describe only what a location *is*; the effect on the update
// command lives in the per-skill sentence, so the two never say it twice.
function chipTooltip(chip: SkillLocationChip): string {
switch (chip) {
case 'current':
return translate(
'auto.components.skills.SkillFreshnessRow.tipCurrent',
'This copy matches the current official version.'
)
case 'unrecognized':
return translate(
'auto.components.skills.SkillFreshnessRow.tipUnrecognized',
'This copy doesnt match any official version — it may be modified, or a different skill with the same name.'
)
case 'inaccessible':
return translate(
'auto.components.skills.SkillFreshnessRow.tipInaccessible',
'Orca couldnt read this copy (a permissions or file error).'
)
case 'duplicate':
return translate(
'auto.components.skills.SkillFreshnessRow.tipDuplicate',
'A separate copy of this skill, installed apart from the main one.'
)
case 'external-link':
return translate(
'auto.components.skills.SkillFreshnessRow.tipExternalLink',
'A shortcut pointing outside Orcas skill folders.'
)
case 'broken-link':
return translate(
'auto.components.skills.SkillFreshnessRow.tipBrokenLink',
'A shortcut to something that no longer exists.'
)
case 'read-only':
return translate(
'auto.components.skills.SkillFreshnessRow.tipReadOnly',
'This copy is in a read-only location.'
)
case 'in-a-repo':
return translate(
'auto.components.skills.SkillFreshnessRow.tipInRepo',
'This copy lives inside a project, not your global skills.'
)
case 'plugin-cache':
return translate(
'auto.components.skills.SkillFreshnessRow.tipPluginCache',
'This copy is managed by a plugin.'
)
}
}
// Why: a skill is skipped for one concrete reason; lead with the highest-priority
// blocking placement so the sentence explains the real cause (an edited copy is
// more useful to surface than a downstream symptom).
const SKIPPED_REASON_PRIORITY: SkillLocationChip[] = [
'unrecognized',
'read-only',
'inaccessible',
'in-a-repo',
'plugin-cache',
'external-link',
'broken-link'
]
function skippedReason(locations: readonly SkillLocationRow[]): string {
const present = new Set(locations.map((location) => location.chip))
const chip = SKIPPED_REASON_PRIORITY.find((candidate) => present.has(candidate))
switch (chip) {
case 'unrecognized':
return translate(
'auto.components.skills.SkillFreshnessRow.skippedReasonUnrecognized',
'The copy here doesnt match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it wont overwrite it. Remove it if you want Orca to update this skill.'
)
case 'read-only':
return translate(
'auto.components.skills.SkillFreshnessRow.skippedReasonReadOnly',
'This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.'
)
case 'inaccessible':
return translate(
'auto.components.skills.SkillFreshnessRow.skippedReasonInaccessible',
'Orca couldnt read this copy, so it left the skill out of the update.'
)
case 'in-a-repo':
return translate(
'auto.components.skills.SkillFreshnessRow.skippedReasonInRepo',
'This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.'
)
case 'plugin-cache':
return translate(
'auto.components.skills.SkillFreshnessRow.skippedReasonPluginCache',
'A plugin manages this skill, so Orca left it out of the update — update the plugin instead.'
)
case 'external-link':
return translate(
'auto.components.skills.SkillFreshnessRow.skippedReasonExternalLink',
'This copy is a shortcut pointing outside Orcas skill folders, so Orca left it out of the update.'
)
case 'broken-link':
return translate(
'auto.components.skills.SkillFreshnessRow.skippedReasonBrokenLink',
'This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it.'
)
default:
return translate(
'auto.components.skills.SkillFreshnessRow.cantUpdateReason',
'Orca left this skill out of the update command.'
)
}
}
export function SkillFreshnessGroup({
group
}: {
group: SkillFreshnessGroupModel
}): React.JSX.Element {
const isBlocked = group.status === 'cannot-update'
return (
<div className="space-y-2 py-3 first:pt-0 last:pb-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="text-sm font-medium text-foreground">{group.name}</span>
{isBlocked ? (
<Badge
variant="outline"
className="border-amber-600/50 text-amber-700 dark:border-amber-400/40 dark:text-amber-400"
>
{translate('auto.components.skills.SkillFreshnessRow.statusCantUpdate', 'Skipped')}
</Badge>
) : (
<Badge variant="secondary">
{translate(
'auto.components.skills.SkillFreshnessRow.statusUpdateAvailable',
'Update available'
)}
</Badge>
)}
</div>
{isBlocked ? (
<p className="text-xs leading-5 text-muted-foreground">{skippedReason(group.locations)}</p>
) : null}
<div className="flex flex-col gap-2">
{group.locations.map((location) => (
<div
key={location.id}
className="flex min-w-0 flex-wrap items-center gap-2 border-l-2 border-border/60 pl-3"
>
<span
className="truncate font-mono text-[11px] text-muted-foreground"
title={location.path}
>
{location.path}
</span>
{location.chip ? (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="cursor-help border-dashed">
{chipLabel(location.chip)}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-pretty">
{chipTooltip(location.chip)}
</TooltipContent>
</Tooltip>
) : null}
</div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import type { SkillFreshnessInstallation } from '../../../../shared/skill-freshness'
import { groupSkillFreshness } from './skill-freshness-grouping'
function placement(
name: string,
overrides: Partial<SkillFreshnessInstallation> = {}
): SkillFreshnessInstallation {
return {
id: `${name}-${overrides.rootId ?? 'home-agents'}-${overrides.unresolvedPath ?? 'a'}`,
name,
rootId: 'home-agents',
providers: ['agent-skills'],
sourceKind: 'home',
sourceLabel: 'Agent skills home',
unresolvedPath: `/home/.agents/skills/${name}`,
resolvedPath: `/home/.agents/skills/${name}`,
physicalIdentity: `physical-${name}`,
topology: 'canonical-copy',
status: 'outdated',
installedReleaseRevision: 1,
installedAppVersion: '1.0.0',
currentReleaseRevision: 2,
currentPackageDigest: 'current',
currentAppVersion: '2.0.0',
observedPackageDigest: 'old',
errorCategory: null,
...overrides
}
}
describe('groupSkillFreshness', () => {
it('marks an eligible outdated skill as update-available with one location', () => {
const groups = groupSkillFreshness([placement('orca-cli')], ['orca-cli'])
expect(groups).toHaveLength(1)
expect(groups[0]).toMatchObject({ name: 'orca-cli', status: 'update-available' })
expect(groups[0]?.locations).toEqual([
{ id: expect.any(String), path: '/home/.agents/skills/orca-cli', chip: null }
])
})
it('hides skills with nothing out of date (current, unrecognized-only, unreadable-only)', () => {
const groups = groupSkillFreshness(
[
placement('orca-cli', { status: 'current' }),
placement('dataviz', { status: 'unrecognized', topology: 'independent-copy' }),
placement('linear-tickets', { status: 'inaccessible' })
],
[]
)
expect(groups).toEqual([])
})
it('groups a blocked skill and flags the culprit location, not the main copy', () => {
const groups = groupSkillFreshness(
[
placement('orchestration'),
placement('orchestration', {
rootId: 'home-claude',
unresolvedPath: '/home/.claude/skills/orchestration',
status: 'unrecognized',
topology: 'independent-copy'
})
],
[]
)
expect(groups).toHaveLength(1)
expect(groups[0]?.status).toBe('cannot-update')
// Why: the out-of-date main copy is bare; only the poisoning copy carries a chip.
expect(groups[0]?.locations).toEqual([
{ id: expect.any(String), path: '/home/.agents/skills/orchestration', chip: null },
{ id: expect.any(String), path: '/home/.claude/skills/orchestration', chip: 'unrecognized' }
])
})
it('prefers a location status over its topology and maps every topology to a chip', () => {
const chipFor = (overrides: Partial<SkillFreshnessInstallation>): string | null =>
groupSkillFreshness(
[placement('s', { status: 'outdated' }), placement('s', overrides)],
['s']
)[0]?.locations.find((location) => location.path.includes('culprit'))?.chip ?? null
const at = (path: string, rest: Partial<SkillFreshnessInstallation>) => ({
unresolvedPath: `/culprit/${path}`,
...rest
})
expect(chipFor(at('a', { status: 'unrecognized', topology: 'independent-copy' }))).toBe(
'unrecognized'
)
expect(chipFor(at('b', { status: 'inaccessible', topology: 'read-only' }))).toBe('inaccessible')
expect(chipFor(at('c', { topology: 'independent-copy' }))).toBe('duplicate')
expect(chipFor(at('d', { topology: 'external-link' }))).toBe('external-link')
expect(chipFor(at('e', { topology: 'broken-link' }))).toBe('broken-link')
expect(chipFor(at('f', { topology: 'read-only' }))).toBe('read-only')
expect(chipFor(at('g', { topology: 'repo-scope' }))).toBe('in-a-repo')
expect(chipFor(at('h', { topology: 'plugin-cache' }))).toBe('plugin-cache')
expect(chipFor(at('i', { status: 'current', topology: 'provider-alias' }))).toBe('current')
})
})

View File

@ -0,0 +1,90 @@
import type { SkillFreshnessInstallation } from '../../../../shared/skill-freshness'
export type SkillGroupStatus = 'update-available' | 'cannot-update'
export type SkillLocationChip =
| 'current'
| 'unrecognized'
| 'inaccessible'
| 'duplicate'
| 'external-link'
| 'broken-link'
| 'read-only'
| 'in-a-repo'
| 'plugin-cache'
export type SkillLocationRow = {
id: string
path: string
chip: SkillLocationChip | null
}
export type SkillFreshnessGroupModel = {
name: string
status: SkillGroupStatus
locations: SkillLocationRow[]
}
function locationChip(installation: SkillFreshnessInstallation): SkillLocationChip | null {
// Why: a location's own status wins over its topology — "the contents don't
// match" is more useful to the user than "it's a duplicate".
if (installation.status === 'unrecognized') {
return 'unrecognized'
}
if (installation.status === 'inaccessible') {
return 'inaccessible'
}
switch (installation.topology) {
case 'independent-copy':
return 'duplicate'
case 'external-link':
return 'external-link'
case 'broken-link':
return 'broken-link'
case 'read-only':
return 'read-only'
case 'repo-scope':
return 'in-a-repo'
case 'plugin-cache':
return 'plugin-cache'
case 'canonical-copy':
case 'provider-alias':
// Why: a supported location only needs a chip when it's already up to date,
// to explain why the update won't touch it; the out-of-date main copy is bare.
return installation.status === 'current' ? 'current' : null
}
}
/**
* Groups installations by skill for the update modal and derives each skill's
* update disposition. Only skills with an out-of-date official copy are returned
* up-to-date, unrecognized-only, and unreadable-only skills have nothing to change
* here, so they are omitted entirely.
*/
export function groupSkillFreshness(
installations: readonly SkillFreshnessInstallation[],
eligibleUpdateNames: readonly string[]
): SkillFreshnessGroupModel[] {
const eligible = new Set(eligibleUpdateNames)
const byName = new Map<string, SkillFreshnessInstallation[]>()
for (const installation of installations) {
const entries = byName.get(installation.name) ?? []
entries.push(installation)
byName.set(installation.name, entries)
}
const groups: SkillFreshnessGroupModel[] = []
for (const [name, entries] of byName) {
if (!entries.some((entry) => entry.status === 'outdated')) {
continue
}
const locations = entries
.map((entry) => ({ id: entry.id, path: entry.unresolvedPath, chip: locationChip(entry) }))
.sort((left, right) => left.path.localeCompare(right.path, 'en'))
groups.push({
name,
status: eligible.has(name) ? 'update-available' : 'cannot-update',
locations
})
}
return groups.sort((left, right) => left.name.localeCompare(right.name, 'en'))
}

View File

@ -0,0 +1,31 @@
let pendingOpen = false
const listeners = new Set<() => void>()
// Why: the nudge action can fire before the dialog subscribes. Keeping the
// request as an external snapshot prevents mount ordering from losing it.
export function requestSkillFreshnessUpdateDialog(): void {
pendingOpen = true
for (const listener of listeners) {
listener()
}
}
export function consumeSkillFreshnessUpdateDialogRequest(): boolean {
const requested = pendingOpen
pendingOpen = false
if (requested) {
for (const listener of listeners) {
listener()
}
}
return requested
}
export function getSkillFreshnessUpdateDialogRequest(): boolean {
return pendingOpen
}
export function subscribeSkillFreshnessUpdateDialog(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}

View File

@ -0,0 +1 @@
export const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed'

View File

@ -7,9 +7,9 @@ import type {
} from '../../../shared/skills'
import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands'
import { markOrchestrationSetupComplete } from '@/lib/orchestration-setup-state'
import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event'
import { useMountedRef } from './useMountedRef'
const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed'
export const GLOBAL_AGENT_SKILL_SOURCE_KINDS = [
'home'
] as const satisfies readonly SkillSourceKind[]

View File

@ -0,0 +1,238 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SkillFreshnessInventory } from '../../../shared/skill-freshness'
import {
_skillFreshnessCacheForTests,
type SkillFreshnessState,
useSkillFreshness
} from './useSkillFreshness'
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (cause: unknown) => void
} {
let resolve!: (value: T) => void
let reject!: (cause: unknown) => void
const promise = new Promise<T>((complete, fail) => {
resolve = complete
reject = fail
})
return { promise, resolve, reject }
}
function inventory(scannedAt: number, eligibleUpdateNames: string[] = []): SkillFreshnessInventory {
return { schemaVersion: 1, installations: [], eligibleUpdateNames, scannedAt }
}
let root: Root | null = null
let container: HTMLDivElement | null = null
let state: SkillFreshnessState | null = null
const states = new Map<string, SkillFreshnessState>()
function Probe({ id = 'default' }: { id?: string }): null {
state = useSkillFreshness()
states.set(id, state)
return null
}
describe('useSkillFreshness', () => {
beforeEach(() => {
_skillFreshnessCacheForTests.reset()
state = null
states.clear()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(async () => {
vi.useRealTimers()
if (root) {
await act(async () => root?.unmount())
}
root = null
container?.remove()
container = null
})
it('runs a follow-up scan when invalidated during an in-flight request', async () => {
const first = deferred<SkillFreshnessInventory>()
const second = deferred<SkillFreshnessInventory>()
const freshnessInventory = vi
.fn()
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
window.api = { skills: { freshnessInventory } } as never
await act(async () => root?.render(<Probe />))
expect(freshnessInventory).toHaveBeenCalledTimes(1)
await act(async () => window.dispatchEvent(new Event('focus')))
await act(async () => first.resolve(inventory(1)))
expect(freshnessInventory).toHaveBeenCalledTimes(2)
await act(async () => second.resolve(inventory(2)))
expect(state?.inventory?.scannedAt).toBe(2)
})
it('skips focus rescans inside the cooldown but honors install-change events', async () => {
const first = deferred<SkillFreshnessInventory>()
const second = deferred<SkillFreshnessInventory>()
const freshnessInventory = vi
.fn()
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise)
window.api = { skills: { freshnessInventory } } as never
await act(async () => root?.render(<Probe />))
await act(async () => first.resolve(inventory(1)))
expect(freshnessInventory).toHaveBeenCalledTimes(1)
await act(async () => window.dispatchEvent(new Event('focus')))
expect(freshnessInventory).toHaveBeenCalledTimes(1)
await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed')))
await act(async () => second.resolve(inventory(2)))
expect(freshnessInventory).toHaveBeenCalledTimes(2)
expect(state?.inventory?.scannedAt).toBe(2)
})
it('retracts stale update authority during the cooldown and runs one trailing focus scan', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-15T00:00:00Z'))
const second = deferred<SkillFreshnessInventory>()
const freshnessInventory = vi
.fn()
.mockResolvedValueOnce(inventory(1, ['orca-cli']))
.mockReturnValueOnce(second.promise)
window.api = { skills: { freshnessInventory } } as never
await act(async () => root?.render(<Probe />))
expect(state?.inventory?.eligibleUpdateNames).toEqual(['orca-cli'])
await act(async () => window.dispatchEvent(new Event('focus')))
expect(freshnessInventory).toHaveBeenCalledTimes(1)
expect(state?.inventory).toBeNull()
expect(state?.loading).toBe(true)
await act(async () => vi.advanceTimersByTimeAsync(15_000))
expect(freshnessInventory).toHaveBeenCalledTimes(2)
await act(async () => second.resolve(inventory(2)))
expect(state?.inventory?.scannedAt).toBe(2)
})
it('coalesces multiple consumers into one rescan per invalidation event', async () => {
const first = deferred<SkillFreshnessInventory>()
const second = deferred<SkillFreshnessInventory>()
const freshnessInventory = vi
.fn()
.mockReturnValueOnce(first.promise)
.mockReturnValue(second.promise)
window.api = { skills: { freshnessInventory } } as never
await act(async () =>
root?.render(
<>
<Probe />
<Probe />
</>
)
)
await act(async () => first.resolve(inventory(1)))
expect(freshnessInventory).toHaveBeenCalledTimes(1)
await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed')))
await act(async () => second.resolve(inventory(2)))
expect(freshnessInventory).toHaveBeenCalledTimes(2)
})
it('publishes a manual refresh to every consumer', async () => {
const second = deferred<SkillFreshnessInventory>()
const freshnessInventory = vi
.fn()
.mockResolvedValueOnce(inventory(1))
.mockReturnValueOnce(second.promise)
window.api = { skills: { freshnessInventory } } as never
await act(async () =>
root?.render(
<>
<Probe id="one" />
<Probe id="two" />
</>
)
)
expect(states.get('one')?.inventory?.scannedAt).toBe(1)
expect(states.get('two')?.inventory?.scannedAt).toBe(1)
let refresh: Promise<void> | undefined
await act(async () => {
refresh = states.get('one')?.refresh()
await Promise.resolve()
})
expect(states.get('one')?.inventory).toBeNull()
expect(states.get('two')?.inventory).toBeNull()
await act(async () => second.resolve(inventory(2)))
await refresh
expect(states.get('one')?.inventory?.scannedAt).toBe(2)
expect(states.get('two')?.inventory?.scannedAt).toBe(2)
expect(freshnessInventory).toHaveBeenCalledTimes(2)
})
it('fails closed when an invalidation scan rejects', async () => {
const second = deferred<SkillFreshnessInventory>()
const freshnessInventory = vi
.fn()
.mockResolvedValueOnce(inventory(1))
.mockReturnValueOnce(second.promise)
window.api = { skills: { freshnessInventory } } as never
await act(async () => root?.render(<Probe />))
expect(state?.inventory?.scannedAt).toBe(1)
await act(async () => window.dispatchEvent(new Event('orca:installed-agent-skills-changed')))
expect(state?.inventory).toBeNull()
expect(state?.loading).toBe(true)
await act(async () => second.reject(new Error('scan failed')))
expect(state?.inventory).toBeNull()
expect(state?.loading).toBe(false)
expect(state?.error).toBe('scan failed')
})
it('installs one event-listener pair for multiple consumers and cleans it up', async () => {
const addEventListener = vi.spyOn(window, 'addEventListener')
const removeEventListener = vi.spyOn(window, 'removeEventListener')
window.api = {
skills: { freshnessInventory: vi.fn().mockResolvedValue(inventory(1)) }
} as never
await act(async () =>
root?.render(
<>
<Probe id="one" />
<Probe id="two" />
</>
)
)
expect(addEventListener.mock.calls.filter(([name]) => name === 'focus')).toHaveLength(1)
expect(
addEventListener.mock.calls.filter(([name]) => name === 'orca:installed-agent-skills-changed')
).toHaveLength(1)
await act(async () => root?.unmount())
root = null
expect(removeEventListener.mock.calls.filter(([name]) => name === 'focus')).toHaveLength(1)
expect(
removeEventListener.mock.calls.filter(
([name]) => name === 'orca:installed-agent-skills-changed'
)
).toHaveLength(1)
})
})

View File

@ -0,0 +1,178 @@
import { useEffect, useSyncExternalStore } from 'react'
import type { SkillFreshnessInventory } from '../../../shared/skill-freshness'
import { INSTALLED_AGENT_SKILLS_CHANGED_EVENT } from './installed-agent-skills-change-event'
// Why: window focus fires on every alt-tab, and each scan re-reads and re-hashes
// every installed package; a just-completed scan stays authoritative briefly.
const FOCUS_RESCAN_COOLDOWN_MS = 15_000
let cachedInventory: SkillFreshnessInventory | null = null
let pendingInventory: Promise<SkillFreshnessInventory> | null = null
let invalidationRevision = 0
let completedRevision = -1
let lastCompletedScanAt = 0
let refreshSequence = 0
let scheduledFocusRescan: number | null = null
type SkillFreshnessSnapshot = {
inventory: SkillFreshnessInventory | null
loading: boolean
error: string | null
}
let snapshot: SkillFreshnessSnapshot = {
inventory: null,
loading: false,
error: null
}
const subscribers = new Set<() => void>()
function publishSnapshot(next: SkillFreshnessSnapshot): void {
if (
snapshot.inventory === next.inventory &&
snapshot.loading === next.loading &&
snapshot.error === next.error
) {
return
}
snapshot = next
for (const subscriber of subscribers) {
subscriber()
}
}
async function loadInventory(force: boolean): Promise<SkillFreshnessInventory> {
if (force) {
invalidationRevision += 1
}
const targetRevision = invalidationRevision
for (;;) {
if (cachedInventory && completedRevision >= targetRevision) {
return cachedInventory
}
if (!pendingInventory) {
const requestRevision = invalidationRevision
const request = window.api.skills
.freshnessInventory()
.then((inventory) => {
cachedInventory = inventory
completedRevision = Math.max(completedRevision, requestRevision)
lastCompletedScanAt = Date.now()
return inventory
})
.finally(() => {
if (pendingInventory === request) {
pendingInventory = null
}
})
pendingInventory = request
}
await pendingInventory
}
}
async function refreshSkillFreshness(force = true): Promise<void> {
if (scheduledFocusRescan !== null) {
window.clearTimeout(scheduledFocusRescan)
scheduledFocusRescan = null
}
const sequence = ++refreshSequence
// Why: eligibility is write authority for the draft command. Once invalidated,
// stale bytes must stop authorizing UI even if the replacement scan fails.
publishSnapshot({ inventory: null, loading: true, error: null })
try {
const inventory = await loadInventory(force)
if (sequence === refreshSequence) {
publishSnapshot({ inventory, loading: false, error: null })
}
} catch (cause) {
if (sequence === refreshSequence) {
publishSnapshot({
inventory: null,
loading: false,
error: cause instanceof Error ? cause.message : 'Could not inspect Orca skills.'
})
}
}
}
function onWindowFocus(): void {
const cooldownRemaining = FOCUS_RESCAN_COOLDOWN_MS - (Date.now() - lastCompletedScanAt)
if (cooldownRemaining <= 0) {
void refreshSkillFreshness(true)
return
}
if (!snapshot.inventory?.eligibleUpdateNames.length || scheduledFocusRescan !== null) {
return
}
// Why: a focus event can follow an external edit. Retract stale update
// authority immediately, but keep rapid alt-tabs to one trailing disk scan.
publishSnapshot({ inventory: null, loading: true, error: null })
scheduledFocusRescan = window.setTimeout(
() => {
scheduledFocusRescan = null
void refreshSkillFreshness(true)
},
Math.min(cooldownRemaining, FOCUS_RESCAN_COOLDOWN_MS)
)
}
function onInstalledSkillsChanged(): void {
void refreshSkillFreshness(true)
}
function subscribe(subscriber: () => void): () => void {
subscribers.add(subscriber)
if (subscribers.size === 1) {
// Why: every consumer reads one external snapshot, so focus/install events
// install one listener and trigger one shared IPC scan regardless of UI count.
window.addEventListener('focus', onWindowFocus)
window.addEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged)
}
return () => {
subscribers.delete(subscriber)
if (subscribers.size === 0) {
window.removeEventListener('focus', onWindowFocus)
window.removeEventListener(INSTALLED_AGENT_SKILLS_CHANGED_EVENT, onInstalledSkillsChanged)
}
}
}
function getSnapshot(): SkillFreshnessSnapshot {
return snapshot
}
function ensureInventoryLoaded(): void {
if (!snapshot.inventory && !snapshot.loading) {
void refreshSkillFreshness(false)
}
}
export type SkillFreshnessState = SkillFreshnessSnapshot & {
refresh: () => Promise<void>
}
export function useSkillFreshness(): SkillFreshnessState {
const current = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
useEffect(() => {
ensureInventoryLoaded()
}, [])
return { ...current, refresh: refreshSkillFreshness }
}
export const _skillFreshnessCacheForTests = {
reset(): void {
cachedInventory = null
pendingInventory = null
invalidationRevision = 0
completedRevision = -1
lastCompletedScanAt = 0
refreshSequence = 0
if (scheduledFocusRescan !== null) {
window.clearTimeout(scheduledFocusRescan)
scheduledFocusRescan = null
}
snapshot = { inventory: null, loading: false, error: null }
}
}

View File

@ -3605,6 +3605,64 @@
"cd7893fbc1": "Scanning skills",
"35b9a724a0": "Available",
"0c74e7ff34": "Local"
},
"SkillFreshnessNudge": {
"titleOne": "An installed Orca skill is out of date",
"titleMany": "{{value0}} installed Orca skills are out of date",
"description": "Update {{value0}} so agents follow the current instructions for this version of Orca.",
"updateOne": "Update skill",
"updateMany": "Update skills"
},
"SkillFreshnessRow": {
"statusUpdateAvailable": "Update available",
"statusCantUpdate": "Skipped",
"cantUpdateReason": "Orca left this skill out of the update command.",
"skippedReasonUnrecognized": "The copy here doesnt match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it wont overwrite it. Remove it if you want Orca to update this skill.",
"skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.",
"skippedReasonInaccessible": "Orca couldnt read this copy, so it left the skill out of the update.",
"skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.",
"skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.",
"skippedReasonExternalLink": "This copy is a shortcut pointing outside Orcas skill folders, so Orca left it out of the update.",
"skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it.",
"chipCurrent": "Current",
"chipUnrecognized": "Unrecognized",
"chipInaccessible": "Inaccessible",
"chipDuplicate": "Duplicate",
"chipExternalLink": "External link",
"chipBrokenLink": "Broken link",
"chipReadOnly": "Read only",
"chipInRepo": "In a repo",
"chipPluginCache": "Plugin cache",
"tipCurrent": "This copy matches the current official version.",
"tipUnrecognized": "This copy doesnt match any official version — it may be modified, or a different skill with the same name.",
"tipInaccessible": "Orca couldnt read this copy (a permissions or file error).",
"tipDuplicate": "A separate copy of this skill, installed apart from the main one.",
"tipExternalLink": "A shortcut pointing outside Orcas skill folders.",
"tipBrokenLink": "A shortcut to something that no longer exists.",
"tipReadOnly": "This copy is in a read-only location.",
"tipInRepo": "This copy lives inside a project, not your global skills.",
"tipPluginCache": "This copy is managed by a plugin."
},
"SkillFreshnessUpdateDialog": {
"title": "Update skills",
"checking": "Checking installed Orca skills…",
"none": "No installed Orca skills found.",
"updateOne": "1 skill can be updated safely",
"updateMany": "{{value0}} skills can be updated safely",
"success": "All installed Orca skills are up to date.",
"attention": "Some installed Orca skills were left out of the update.",
"attentionDescription": "Open Update details to see why each one was skipped.",
"details": "Update details",
"terminalTitle": "Update Orca skills",
"terminalDescription": "Review the pre-filled command, then press Enter to run it.",
"terminalAria": "Orca skill update terminal",
"checkNow": "Re-check",
"close": "Close"
},
"SkillFreshnessStatusPill": {
"updateAvailable": "Update available",
"upToDate": "Up to date",
"installed": "Installed"
}
},
"sidebar": {

View File

@ -3605,6 +3605,64 @@
"cd7893fbc1": "Escaneando skills",
"35b9a724a0": "Disponible",
"0c74e7ff34": "Local"
},
"SkillFreshnessNudge": {
"titleOne": "Una skill de Orca instalada está desactualizada",
"titleMany": "{{value0}} skills de Orca instaladas están desactualizadas",
"updateOne": "Actualizar skill",
"updateMany": "Actualizar skills",
"description": "Actualiza {{value0}} para que los Agents sigan las instrucciones actuales de esta versión de Orca."
},
"SkillFreshnessRow": {
"statusUpdateAvailable": "Actualización disponible",
"statusCantUpdate": "No se puede actualizar",
"cantUpdateReason": "Esta skill está instalada en un lugar que Orca no puede actualizar de forma segura, así que el comando npx skills update la deja sin cambios.",
"chipCurrent": "Actual",
"chipUnrecognized": "No reconocida",
"chipInaccessible": "Inaccesible",
"chipDuplicate": "Duplicada",
"chipExternalLink": "Enlace externo",
"chipBrokenLink": "Enlace roto",
"chipReadOnly": "Solo lectura",
"chipInRepo": "En un repo",
"chipPluginCache": "Caché de plugin",
"tipCurrent": "La skill aquí ya está actualizada; la actualización no la cambiará.",
"tipUnrecognized": "El contenido de la skill aquí no coincide con ninguna versión oficial, así que Orca no puede actualizarla de forma segura. Elimina o reemplaza lo que hay aquí para permitir las actualizaciones.",
"tipInaccessible": "Orca no pudo leer la skill aquí (un error de permisos o de archivo), así que no puede comprobarla ni actualizarla.",
"tipDuplicate": "La skill también está instalada aquí, aparte de la principal, así que el comando npx skills update no puede alcanzarla. Elimínala para permitir las actualizaciones.",
"tipExternalLink": "Es un acceso directo que apunta fuera de las carpetas de skills de Orca; la actualización no lo seguirá.",
"tipBrokenLink": "Es un acceso directo a algo que ya no existe; puedes eliminarlo sin problema.",
"tipReadOnly": "La skill aquí está en una ubicación de solo lectura, así que no se puede actualizar hasta que cambies sus permisos.",
"tipInRepo": "La skill aquí vive dentro de un proyecto, no en tus skills globales; Orca solo actualiza las globales.",
"tipPluginCache": "La skill aquí la gestiona un plugin; actualiza el plugin en su lugar.",
"skippedReasonUnrecognized": "The copy here doesnt match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it wont overwrite it. Remove it if you want Orca to update this skill.",
"skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.",
"skippedReasonInaccessible": "Orca couldnt read this copy, so it left the skill out of the update.",
"skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.",
"skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.",
"skippedReasonExternalLink": "This copy is a shortcut pointing outside Orcas skill folders, so Orca left it out of the update.",
"skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it."
},
"SkillFreshnessUpdateDialog": {
"title": "Actualizar skills",
"checking": "Comprobando las skills de Orca instaladas…",
"none": "No se encontraron skills de Orca instaladas.",
"updateOne": "Se puede actualizar 1 skill de forma segura",
"updateMany": "Se pueden actualizar {{value0}} skills de forma segura",
"success": "Todas las skills de Orca instaladas están actualizadas.",
"attention": "Algunas skills de Orca instaladas no se pueden actualizar automáticamente.",
"attentionDescription": "Abre Detalles de la actualización para ver por qué no se puede actualizar cada una.",
"details": "Detalles de la actualización",
"terminalTitle": "Actualizar skills de Orca",
"terminalDescription": "Revisa el comando pre-rellenado y pulsa Intro para ejecutarlo.",
"terminalAria": "Terminal de actualización de skills de Orca",
"checkNow": "Comprobar ahora",
"close": "Cerrar"
},
"SkillFreshnessStatusPill": {
"updateAvailable": "Actualización disponible",
"upToDate": "Actualizado",
"installed": "Instalado"
}
},
"sidebar": {

View File

@ -3605,6 +3605,64 @@
"cd7893fbc1": "スキャンスキル",
"35b9a724a0": "利用可能",
"0c74e7ff34": "ローカル"
},
"SkillFreshnessNudge": {
"titleOne": "インストール済みの Orca スキルが古くなっています",
"titleMany": "インストール済みの Orca スキル {{value0}} 件が古くなっています",
"updateOne": "スキルを更新",
"updateMany": "スキルを更新",
"description": "Agents がこのバージョンの Orca の最新の手順に従うように、{{value0}} を更新してください。"
},
"SkillFreshnessRow": {
"statusUpdateAvailable": "更新あり",
"statusCantUpdate": "更新できません",
"cantUpdateReason": "このスキルは Orca が安全に更新できない場所にインストールされているため、npx skills update コマンドは変更しません。",
"chipCurrent": "最新",
"chipUnrecognized": "未認識",
"chipInaccessible": "アクセス不可",
"chipDuplicate": "重複",
"chipExternalLink": "外部リンク",
"chipBrokenLink": "リンク切れ",
"chipReadOnly": "読み取り専用",
"chipInRepo": "リポジトリ内",
"chipPluginCache": "プラグインキャッシュ",
"tipCurrent": "ここのスキルはすでに最新です。更新しても変更されません。",
"tipUnrecognized": "ここのスキルの内容が公式バージョンのいずれとも一致しないため、Orca は安全に更新できません。更新を許可するには、ここにあるものを削除または置き換えてください。",
"tipInaccessible": "Orca はここのスキルを読み取れなかったため(権限またはファイルのエラー)、確認も更新もできません。",
"tipDuplicate": "このスキルはメインとは別に、ここにもインストールされています。そのため npx skills update コマンドは到達できません。更新を許可するには削除してください。",
"tipExternalLink": "これは Orca のスキルフォルダーの外を指すショートカットです。更新では追跡されません。",
"tipBrokenLink": "これは存在しないものを指すショートカットです。削除しても問題ありません。",
"tipReadOnly": "ここのスキルは読み取り専用の場所にあるため、権限を変更するまで更新できません。",
"tipInRepo": "ここのスキルはグローバルスキルではなくプロジェクト内にあります。Orca はグローバルなものだけを更新します。",
"tipPluginCache": "ここのスキルはプラグインによって管理されています。代わりにプラグインを更新してください。",
"skippedReasonUnrecognized": "The copy here doesnt match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it wont overwrite it. Remove it if you want Orca to update this skill.",
"skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.",
"skippedReasonInaccessible": "Orca couldnt read this copy, so it left the skill out of the update.",
"skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.",
"skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.",
"skippedReasonExternalLink": "This copy is a shortcut pointing outside Orcas skill folders, so Orca left it out of the update.",
"skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it."
},
"SkillFreshnessUpdateDialog": {
"title": "スキルを更新",
"checking": "インストール済みの Orca スキルを確認中…",
"none": "インストール済みの Orca スキルは見つかりませんでした。",
"updateOne": "1 件のスキルを安全に更新できます",
"updateMany": "{{value0}} 件のスキルを安全に更新できます",
"success": "インストール済みの Orca スキルはすべて最新です。",
"attention": "一部のインストール済み Orca スキルは自動的に更新できません。",
"attentionDescription": "更新できない理由は、更新の詳細を開いて確認してください。",
"details": "更新の詳細",
"terminalTitle": "Orca スキルを更新",
"terminalDescription": "入力済みのコマンドを確認し、Enter キーを押して実行してください。",
"terminalAria": "Orca スキル更新ターミナル",
"checkNow": "今すぐ確認",
"close": "閉じる"
},
"SkillFreshnessStatusPill": {
"updateAvailable": "更新があります",
"upToDate": "最新です",
"installed": "インストール済み"
}
},
"sidebar": {

View File

@ -3605,6 +3605,64 @@
"cd7893fbc1": "스킬 스캔 중",
"35b9a724a0": "사용 가능",
"0c74e7ff34": "로컬"
},
"SkillFreshnessNudge": {
"titleOne": "설치된 Orca 스킬이 오래되었습니다",
"titleMany": "설치된 Orca 스킬 {{value0}}개가 오래되었습니다",
"updateOne": "스킬 업데이트",
"updateMany": "스킬 업데이트",
"description": "Agents가 이 버전의 Orca에 대한 최신 지침을 따르도록 {{value0}}을(를) 업데이트하세요."
},
"SkillFreshnessRow": {
"statusUpdateAvailable": "업데이트 있음",
"statusCantUpdate": "업데이트할 수 없음",
"cantUpdateReason": "이 스킬은 Orca가 안전하게 업데이트할 수 없는 위치에 설치되어 있어, npx skills update 명령이 변경하지 않습니다.",
"chipCurrent": "최신",
"chipUnrecognized": "인식 안 됨",
"chipInaccessible": "액세스 불가",
"chipDuplicate": "중복",
"chipExternalLink": "외부 링크",
"chipBrokenLink": "깨진 링크",
"chipReadOnly": "읽기 전용",
"chipInRepo": "저장소 내",
"chipPluginCache": "플러그인 캐시",
"tipCurrent": "여기 있는 스킬은 이미 최신이므로 업데이트해도 변경되지 않습니다.",
"tipUnrecognized": "여기 있는 스킬의 콘텐츠가 어떤 공식 버전과도 일치하지 않아 Orca가 안전하게 업데이트할 수 없습니다. 업데이트를 허용하려면 여기 있는 항목을 제거하거나 교체하세요.",
"tipInaccessible": "Orca가 여기 있는 스킬을 읽지 못해(권한 또는 파일 오류) 확인하거나 업데이트할 수 없습니다.",
"tipDuplicate": "이 스킬은 기본 위치와 별개로 여기에도 설치되어 있어 npx skills update 명령이 접근할 수 없습니다. 업데이트를 허용하려면 제거하세요.",
"tipExternalLink": "Orca의 스킬 폴더 바깥을 가리키는 바로 가기입니다. 업데이트가 이를 따라가지 않습니다.",
"tipBrokenLink": "더 이상 존재하지 않는 대상을 가리키는 바로 가기입니다. 삭제해도 됩니다.",
"tipReadOnly": "여기 있는 스킬은 읽기 전용 위치에 있어 권한을 변경해야 업데이트할 수 있습니다.",
"tipInRepo": "여기 있는 스킬은 전역 스킬이 아니라 프로젝트 안에 있습니다. Orca는 전역 스킬만 업데이트합니다.",
"tipPluginCache": "여기 있는 스킬은 플러그인이 관리합니다. 대신 플러그인을 업데이트하세요.",
"skippedReasonUnrecognized": "The copy here doesnt match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it wont overwrite it. Remove it if you want Orca to update this skill.",
"skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.",
"skippedReasonInaccessible": "Orca couldnt read this copy, so it left the skill out of the update.",
"skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.",
"skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.",
"skippedReasonExternalLink": "This copy is a shortcut pointing outside Orcas skill folders, so Orca left it out of the update.",
"skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it."
},
"SkillFreshnessUpdateDialog": {
"title": "스킬 업데이트",
"checking": "설치된 Orca 스킬을 확인하는 중…",
"none": "설치된 Orca 스킬을 찾을 수 없습니다.",
"updateOne": "스킬 1개를 안전하게 업데이트할 수 있습니다",
"updateMany": "스킬 {{value0}}개를 안전하게 업데이트할 수 있습니다",
"success": "설치된 Orca 스킬이 모두 최신 상태입니다.",
"attention": "일부 설치된 Orca 스킬은 자동으로 업데이트할 수 없습니다.",
"attentionDescription": "각 항목을 업데이트할 수 없는 이유는 업데이트 세부 정보를 열어 확인하세요.",
"details": "업데이트 세부 정보",
"terminalTitle": "Orca 스킬 업데이트",
"terminalDescription": "미리 입력된 명령을 검토한 후 Enter 키를 눌러 실행하세요.",
"terminalAria": "Orca 스킬 업데이트 터미널",
"checkNow": "지금 확인",
"close": "닫기"
},
"SkillFreshnessStatusPill": {
"updateAvailable": "업데이트 가능",
"upToDate": "최신 상태",
"installed": "설치됨"
}
},
"sidebar": {

View File

@ -3605,6 +3605,64 @@
"cd7893fbc1": "正在扫描技能",
"35b9a724a0": "可用的",
"0c74e7ff34": "当地的"
},
"SkillFreshnessNudge": {
"titleOne": "已安装的 Orca 技能已过期",
"titleMany": "{{value0}} 个已安装的 Orca 技能已过期",
"updateOne": "更新技能",
"updateMany": "更新技能",
"description": "更新 {{value0}},让 Agents 遵循此版本 Orca 的最新说明。"
},
"SkillFreshnessRow": {
"statusUpdateAvailable": "有更新",
"statusCantUpdate": "无法更新",
"cantUpdateReason": "此技能安装在 Orca 无法安全更新的位置,因此 npx skills update 命令不会更改它。",
"chipCurrent": "最新",
"chipUnrecognized": "无法识别",
"chipInaccessible": "无法访问",
"chipDuplicate": "重复",
"chipExternalLink": "外部链接",
"chipBrokenLink": "失效链接",
"chipReadOnly": "只读",
"chipInRepo": "在仓库中",
"chipPluginCache": "插件缓存",
"tipCurrent": "这里的技能已是最新,更新不会更改它。",
"tipUnrecognized": "这里的技能内容与任何官方版本都不匹配,因此 Orca 无法安全更新它。请移除或替换此处的内容以允许更新。",
"tipInaccessible": "Orca 无法读取这里的技能(权限或文件错误),因此无法检查或更新它。",
"tipDuplicate": "此技能除主副本外还安装在这里,因此 npx skills update 命令无法访问它。请移除它以允许更新。",
"tipExternalLink": "这是一个指向 Orca 技能文件夹之外的快捷方式;更新不会跟随它。",
"tipBrokenLink": "这是一个指向已不存在内容的快捷方式,可以安全删除。",
"tipReadOnly": "这里的技能位于只读位置,需更改其权限后才能更新。",
"tipInRepo": "这里的技能位于某个项目内而非全局技能Orca 只更新全局技能。",
"tipPluginCache": "这里的技能由插件管理;请改为更新插件。",
"skippedReasonUnrecognized": "The copy here doesnt match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it wont overwrite it. Remove it if you want Orca to update this skill.",
"skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.",
"skippedReasonInaccessible": "Orca couldnt read this copy, so it left the skill out of the update.",
"skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.",
"skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.",
"skippedReasonExternalLink": "This copy is a shortcut pointing outside Orcas skill folders, so Orca left it out of the update.",
"skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it."
},
"SkillFreshnessUpdateDialog": {
"title": "更新技能",
"checking": "正在检查已安装的 Orca 技能…",
"none": "未找到已安装的 Orca 技能。",
"updateOne": "可以安全更新 1 个技能",
"updateMany": "可以安全更新 {{value0}} 个技能",
"success": "所有已安装的 Orca 技能均为最新。",
"attention": "部分已安装的 Orca 技能无法自动更新。",
"attentionDescription": "打开“更新详情”查看为何每一项都无法更新。",
"details": "更新详情",
"terminalTitle": "更新 Orca 技能",
"terminalDescription": "检查预填的命令,然后按 Enter 键运行。",
"terminalAria": "Orca 技能更新终端",
"checkNow": "立即检查",
"close": "关闭"
},
"SkillFreshnessStatusPill": {
"updateAvailable": "有可用更新",
"upToDate": "已是最新",
"installed": "已安装"
}
},
"sidebar": {

View File

@ -3,7 +3,7 @@ import type { LucideProps } from 'lucide-react'
import type { SettingsSearchEntry } from '@/components/settings/settings-search'
export type SettingsNavIcon = ComponentType<LucideProps>
export type SettingsNavInstallStatus = 'install' | 'installed' | 'checking'
export type SettingsNavInstallStatus = 'install' | 'installed' | 'update-available' | 'checking'
export type SettingsNavTarget =
| 'general'

View File

@ -35,6 +35,7 @@ import type {
WorkspaceSessionState
} from '../../../shared/types'
import type { SkillDiscoveryResult } from '../../../shared/skills'
import type { SkillFreshnessInventory } from '../../../shared/skill-freshness'
import type { SshConnectionState, SshTarget } from '../../../shared/ssh-types'
import {
getDefaultOnboardingState,
@ -2743,7 +2744,16 @@ function createSkillsApi(): NonNullable<Partial<PreloadApi>['skills']> {
skills: [],
sources: [],
scannedAt: Date.now()
}))
})),
// Why: browser clients have no local skill homes, and remote-host
// freshness stays disabled until its update rail has equivalent coverage.
freshnessInventory: (): Promise<SkillFreshnessInventory> =>
Promise.resolve({
schemaVersion: 1,
installations: [],
eligibleUpdateNames: [],
scannedAt: Date.now()
})
}
}

View File

@ -0,0 +1,102 @@
import type { SkillProvider, SkillSourceKind } from './skills'
export type SkillBundleFileIdentity = {
path: string
size: number
executable: boolean
classification: 'text' | 'binary'
exactSha256: string
textNormalizedSha256: string | null
identitySha256: string
}
export type SkillKnownSnapshot = {
releaseRevision: number
packageDigest: string
gitTreeSha: string
files: SkillBundleFileIdentity[]
}
export type SkillCurrentBundleEntry = SkillKnownSnapshot & {
name: string
sourcePath: string
appVersion: string
}
export type SkillBundleManifest = {
schemaVersion: 1
appVersion: string
skills: SkillCurrentBundleEntry[]
}
export type SkillSnapshotRegistry = {
schemaVersion: 1
skills: Record<string, SkillKnownSnapshot[]>
}
export type SkillReleaseMapping = {
schemaVersion: 1
releases: { appVersion: string; skills: Record<string, number> }[]
}
export type SkillFreshnessStatus =
| 'current'
| 'outdated'
| 'newer-known'
| 'unrecognized'
| 'inaccessible'
export type SkillInstallationTopology =
| 'canonical-copy'
| 'provider-alias'
| 'independent-copy'
| 'external-link'
| 'broken-link'
| 'read-only'
| 'repo-scope'
| 'plugin-cache'
// Why: eligibility and the explanation copy must agree on which placements the
// validated npx rail can converge; a drifted copy would blame a phantom sibling.
export const SUPPORTED_GLOBAL_SKILL_TOPOLOGIES: ReadonlySet<SkillInstallationTopology> = new Set([
'canonical-copy',
'provider-alias'
])
export type SkillFreshnessInstallation = {
id: string
name: string
rootId: string
providers: SkillProvider[]
sourceKind: SkillSourceKind
sourceLabel: string
unresolvedPath: string
resolvedPath: string | null
physicalIdentity: string | null
topology: SkillInstallationTopology
status: SkillFreshnessStatus
installedReleaseRevision: number | null
installedAppVersion: string | null
currentReleaseRevision: number
currentPackageDigest: string
currentAppVersion: string
observedPackageDigest: string | null
errorCategory: string | null
}
export type SkillFreshnessInventory = {
schemaVersion: 1
installations: SkillFreshnessInstallation[]
eligibleUpdateNames: string[]
scannedAt: number
}
export function buildTargetedSkillUpdateCommand(names: readonly string[]): string | null {
const canonicalNames = [...new Set(names)].sort((left, right) => left.localeCompare(right, 'en'))
// Why: names become editable shell input. Official manifests use this
// restricted package-name grammar so no entry can introduce shell syntax.
if (canonicalNames.some((name) => !/^[a-z0-9][a-z0-9._-]*$/.test(name))) {
return null
}
return canonicalNames.length > 0 ? `npx skills update ${canonicalNames.join(' ')} --global` : null
}

View File

@ -2875,6 +2875,9 @@ export type GlobalSettings = {
/** Why: disabling must persist so startup does not reinstall global agent
* hook entries right after the user removes them from Settings or CLI. */
agentStatusHooksEnabled: boolean
/** Dismissed freshness tuples grant no write authority; they only keep the
* same exact official placement/revision from nudging more than once. */
dismissedSkillFreshnessNudges?: string[]
/** Why: generated tab titles are semantic but subjective, so they stay opt-in
* and manual renames remain the stronger user intent. */
tabAutoGenerateTitle: boolean