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
64 changed files with 7542 additions and 54 deletions
+43 -8
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()
})
})
+8
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() })
})
}
+35
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')
+1
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[] = []
@@ -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'
)
})
})
+164
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
}
}
@@ -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
}
+8 -2
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)}`
@@ -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()
})
})
@@ -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'))
}
@@ -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)
})
})
@@ -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([])
})
})
@@ -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()
}
}
@@ -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()]
}
@@ -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'
}
}
}
@@ -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)
})
})
+229
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
}
@@ -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)
})
})
+142
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] }
}
+2
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>
+4 -1
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: {
+12
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
@@ -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(
@@ -390,6 +390,7 @@ export function CliSection({
}))
}}
onRecheck={refreshCliSkill}
freshnessSkillName={agentRuntime.runtime === 'host' ? ORCA_CLI_SKILL_NAME : undefined}
/>
</div>
) : null}
@@ -79,6 +79,9 @@ export function ComputerUseSkillSetupPanel(): React.JSX.Element {
: ensureOrcaCliAvailableForAgentSkillTerminal())
}}
onRecheck={refreshComputerUseSkill}
freshnessSkillName={
activeSkillRuntime.agentRuntime?.runtime === 'wsl' ? undefined : COMPUTER_USE_SKILL_NAME
}
/>
)
}
@@ -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">
@@ -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
}
/>
)
@@ -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(
@@ -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 (
@@ -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()
})
})
@@ -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
}
@@ -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')
})
})
@@ -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>
)
}
@@ -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)
})
})
@@ -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>
)
}
@@ -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 doesn’t 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 couldn’t 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 Orca’s 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 doesn’t 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 won’t 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 couldn’t 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 Orca’s 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>
)
}
@@ -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')
})
})
@@ -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'))
}
@@ -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)
}
@@ -0,0 +1 @@
export const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed'
@@ -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[]
@@ -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)
})
})
+178
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 }
}
}
+58
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 doesn’t 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 won’t 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 couldn’t 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 Orca’s 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 doesn’t match any official version — it may be modified, or a different skill with the same name.",
"tipInaccessible": "Orca couldn’t 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 Orca’s 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": {
+58
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 doesn’t 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 won’t 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 couldn’t 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 Orca’s 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": {
+58
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 doesn’t 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 won’t 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 couldn’t 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 Orca’s 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": {
+58
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 doesn’t 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 won’t 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 couldn’t 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 Orca’s 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": {
+58
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 doesn’t 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 won’t 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 couldn’t 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 Orca’s 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": {
@@ -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'
+11 -1
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()
})
}
}
+102
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
}
+3
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