From f4a12bcd6bd29b1bbd7e0eca0f1b2a5e1dd0a3fe Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sun, 12 Jul 2026 23:10:16 -0700
Subject: [PATCH] feat(skills): manage bundled skill updates
---
.github/workflows/pr.yml | 7 +
.github/workflows/release-cut.yml | 6 +-
.github/workflows/skill-cli-roundtrip.yml | 38 +
.../workflows/skill-manifest-merge-gate.yml | 21 +
config/electron-builder.config.cjs | 12 +-
config/reliability-gates.jsonc | 156 +++
.../scripts/electron-builder-config.test.mjs | 8 +
.../generate-skill-bundle-manifest.mjs | 463 +++++++
.../generate-skill-bundle-manifest.test.mjs | 98 ++
config/scripts/verify-skill-cli-roundtrip.mjs | 134 ++
package.json | 6 +-
resources/skills/current-manifest.json | 158 +++
resources/skills/release-mapping.json | 475 +++++++
resources/skills/snapshot-registry.json | 1159 +++++++++++++++++
src/main/ipc/skills.test.ts | 247 +++-
src/main/ipc/skills.ts | 267 +++-
src/main/persistence.test.ts | 42 +
src/main/persistence.ts | 49 +
src/main/skills/discovery.test.ts | 19 +
src/main/skills/discovery.ts | 6 +
src/main/skills/skill-adoption-dismissal.ts | 31 +
src/main/skills/skill-adoption.test.ts | 181 +++
src/main/skills/skill-adoption.ts | 130 ++
src/main/skills/skill-bundle-artifacts.ts | 110 ++
src/main/skills/skill-discovery-sources.ts | 3 +-
.../skills/skill-explicit-replacement.test.ts | 84 ++
src/main/skills/skill-explicit-replacement.ts | 161 +++
.../skills/skill-installation-topology.ts | 207 +++
src/main/skills/skill-managed-update.ts | 43 +
...skill-management-inaccessible-candidate.ts | 63 +
.../skills/skill-management-inventory.test.ts | 348 +++++
src/main/skills/skill-management-inventory.ts | 301 +++++
.../skills/skill-management-ledger.test.ts | 66 +
src/main/skills/skill-management-ledger.ts | 127 ++
.../skills/skill-mutation-authority.test.ts | 74 ++
src/main/skills/skill-mutation-authority.ts | 47 +
.../skills/skill-orphan-transaction-sweep.ts | 106 ++
.../skills/skill-owned-temp-publication.ts | 108 ++
src/main/skills/skill-package-identity.ts | 228 ++++
.../skill-package-observation-limits.test.ts | 73 ++
.../skills/skill-package-publication.test.ts | 445 +++++++
src/main/skills/skill-package-publication.ts | 216 +++
src/main/skills/skill-package-publish.test.ts | 176 +++
src/main/skills/skill-package-publish.ts | 171 +++
src/main/skills/skill-package-rollback.ts | 95 ++
.../skill-package-swap-authority.test.ts | 229 ++++
.../skill-package-transaction-context.ts | 202 +++
.../skill-published-destination-authority.ts | 52 +
.../skills/skill-transaction-marker.test.ts | 235 ++++
src/main/skills/skill-transaction-marker.ts | 291 +++++
.../skills/skill-transaction-owned-state.ts | 305 +++++
.../skill-transaction-package-build.test.ts | 187 +++
.../skills/skill-transaction-package-build.ts | 290 +++++
src/main/skills/skill-transaction-paths.ts | 1 +
...kill-transaction-publication-state.test.ts | 220 ++++
.../skill-transaction-publication-state.ts | 224 ++++
.../skills/skill-transaction-recovery.test.ts | 618 +++++++++
src/main/skills/skill-transaction-recovery.ts | 101 ++
src/main/skills/skill-transaction-rename.ts | 35 +
src/main/skills/skill-transaction-restore.ts | 136 ++
.../skill-transaction-snapshot-validation.ts | 62 +
.../skill-transaction-workspace.test.ts | 32 +
.../skills/skill-transaction-workspace.ts | 90 ++
.../skills/skills-cli-lock-provenance.test.ts | 59 +
src/main/skills/skills-cli-lock-provenance.ts | 53 +
src/preload/api-types.ts | 14 +
src/preload/index.ts | 23 +-
src/renderer/src/App.tsx | 2 +
.../MobileEmulatorAgentSetupGuideSteps.tsx | 6 +-
.../feature-tips/CliSkillSetupTerminal.tsx | 27 +-
.../feature-wall/BrowserUseSkillSetupCard.tsx | 3 +
.../FloatingTerminalOrchestrationDialog.tsx | 2 +
.../ManagedSkillAdoptionNudge.test.tsx | 116 ++
.../ManagedSkillAdoptionNudge.tsx | 77 ++
.../onboarding/FeatureSetupInlineTerminal.tsx | 19 +-
.../OnboardingInlineCommandTerminal.tsx | 6 +-
.../onboarding/onboarding-feature-setup.ts | 8 +-
.../file-explorer-operation-owner.ts | 4 +
.../settings/AgentSkillSetupPanel.test.tsx | 12 +
.../settings/AgentSkillSetupPanel.tsx | 43 +-
.../src/components/settings/AgentsPane.tsx | 3 +
.../components/settings/BrowserUsePane.tsx | 1 +
.../settings/BrowserUseSkillStep.tsx | 6 +
.../src/components/settings/CliSection.tsx | 2 +
.../settings/ComputerUseSkillSetupPanel.tsx | 2 +
.../components/settings/EphemeralVmsPane.tsx | 2 +
.../settings/ManagedOrcaSkillsSection.tsx | 330 +++++
.../MobileEmulatorAgentControlRow.tsx | 2 +
.../components/settings/OrchestrationPane.tsx | 2 +
.../settings/OrchestrationSetupCard.tsx | 3 +
.../linear-agent-skill-install-cta.test.tsx | 42 +-
.../linear-agent-skill-install-cta.tsx | 105 +-
.../settings/managed-skill-status-copy.ts | 36 +
.../skill-install-terminal-exit.test.ts | 11 +
.../settings/skill-install-terminal-exit.ts | 8 +
.../use-agent-skill-management-actions.ts | 133 ++
.../sidebar/LinearAgentSkillSetupDialog.tsx | 6 +
.../sidebar/LinearAgentSkillSetupPrompt.tsx | 1 +
.../sidebar/host-header-menu-items.ts | 1 +
.../sidebar/sidebar-host-options.ts | 5 +-
.../components/terminal-pane/TerminalPane.tsx | 2 +-
.../terminal-pane/pty-connection-types.ts | 2 +-
.../terminal-pane/pty-connection.ts | 8 +-
.../terminal-pane/pty-transport-types.ts | 2 +-
.../terminal-pane/pty-transport.test.ts | 6 +-
.../components/terminal-pane/pty-transport.ts | 2 +-
.../remote-runtime-pty-transport.test.ts | 6 +-
.../remote-runtime-pty-transport.ts | 9 +-
.../use-terminal-pane-lifecycle.ts | 2 +-
.../src/hooks/useManagedAgentSkills.ts | 189 +++
src/renderer/src/i18n/locales/en.json | 51 +-
src/renderer/src/i18n/locales/es.json | 51 +-
src/renderer/src/i18n/locales/ja.json | 51 +-
src/renderer/src/i18n/locales/ko.json | 51 +-
src/renderer/src/i18n/locales/zh.json | 51 +-
.../src/lib/ai-vault-resume-target.ts | 4 +-
src/renderer/src/web/web-preload-api.ts | 20 +-
src/shared/constants.ts | 7 +-
src/shared/execution-host.test.ts | 8 +-
src/shared/execution-host.ts | 27 +-
src/shared/skill-management.ts | 164 +++
src/shared/skills.ts | 2 +
src/shared/types.ts | 7 +-
tests/e2e/settings-skill-detection.spec.ts | 111 +-
...ttings-skill-management-production.spec.ts | 47 +
125 files changed, 12210 insertions(+), 120 deletions(-)
create mode 100644 .github/workflows/skill-cli-roundtrip.yml
create mode 100644 .github/workflows/skill-manifest-merge-gate.yml
create mode 100644 config/scripts/generate-skill-bundle-manifest.mjs
create mode 100644 config/scripts/generate-skill-bundle-manifest.test.mjs
create mode 100644 config/scripts/verify-skill-cli-roundtrip.mjs
create mode 100644 resources/skills/current-manifest.json
create mode 100644 resources/skills/release-mapping.json
create mode 100644 resources/skills/snapshot-registry.json
create mode 100644 src/main/skills/skill-adoption-dismissal.ts
create mode 100644 src/main/skills/skill-adoption.test.ts
create mode 100644 src/main/skills/skill-adoption.ts
create mode 100644 src/main/skills/skill-bundle-artifacts.ts
create mode 100644 src/main/skills/skill-explicit-replacement.test.ts
create mode 100644 src/main/skills/skill-explicit-replacement.ts
create mode 100644 src/main/skills/skill-installation-topology.ts
create mode 100644 src/main/skills/skill-managed-update.ts
create mode 100644 src/main/skills/skill-management-inaccessible-candidate.ts
create mode 100644 src/main/skills/skill-management-inventory.test.ts
create mode 100644 src/main/skills/skill-management-inventory.ts
create mode 100644 src/main/skills/skill-management-ledger.test.ts
create mode 100644 src/main/skills/skill-management-ledger.ts
create mode 100644 src/main/skills/skill-mutation-authority.test.ts
create mode 100644 src/main/skills/skill-mutation-authority.ts
create mode 100644 src/main/skills/skill-orphan-transaction-sweep.ts
create mode 100644 src/main/skills/skill-owned-temp-publication.ts
create mode 100644 src/main/skills/skill-package-identity.ts
create mode 100644 src/main/skills/skill-package-observation-limits.test.ts
create mode 100644 src/main/skills/skill-package-publication.test.ts
create mode 100644 src/main/skills/skill-package-publication.ts
create mode 100644 src/main/skills/skill-package-publish.test.ts
create mode 100644 src/main/skills/skill-package-publish.ts
create mode 100644 src/main/skills/skill-package-rollback.ts
create mode 100644 src/main/skills/skill-package-swap-authority.test.ts
create mode 100644 src/main/skills/skill-package-transaction-context.ts
create mode 100644 src/main/skills/skill-published-destination-authority.ts
create mode 100644 src/main/skills/skill-transaction-marker.test.ts
create mode 100644 src/main/skills/skill-transaction-marker.ts
create mode 100644 src/main/skills/skill-transaction-owned-state.ts
create mode 100644 src/main/skills/skill-transaction-package-build.test.ts
create mode 100644 src/main/skills/skill-transaction-package-build.ts
create mode 100644 src/main/skills/skill-transaction-paths.ts
create mode 100644 src/main/skills/skill-transaction-publication-state.test.ts
create mode 100644 src/main/skills/skill-transaction-publication-state.ts
create mode 100644 src/main/skills/skill-transaction-recovery.test.ts
create mode 100644 src/main/skills/skill-transaction-recovery.ts
create mode 100644 src/main/skills/skill-transaction-rename.ts
create mode 100644 src/main/skills/skill-transaction-restore.ts
create mode 100644 src/main/skills/skill-transaction-snapshot-validation.ts
create mode 100644 src/main/skills/skill-transaction-workspace.test.ts
create mode 100644 src/main/skills/skill-transaction-workspace.ts
create mode 100644 src/main/skills/skills-cli-lock-provenance.test.ts
create mode 100644 src/main/skills/skills-cli-lock-provenance.ts
create mode 100644 src/renderer/src/components/managed-skills/ManagedSkillAdoptionNudge.test.tsx
create mode 100644 src/renderer/src/components/managed-skills/ManagedSkillAdoptionNudge.tsx
create mode 100644 src/renderer/src/components/settings/ManagedOrcaSkillsSection.tsx
create mode 100644 src/renderer/src/components/settings/managed-skill-status-copy.ts
create mode 100644 src/renderer/src/components/settings/skill-install-terminal-exit.test.ts
create mode 100644 src/renderer/src/components/settings/skill-install-terminal-exit.ts
create mode 100644 src/renderer/src/components/settings/use-agent-skill-management-actions.ts
create mode 100644 src/renderer/src/hooks/useManagedAgentSkills.ts
create mode 100644 src/shared/skill-management.ts
create mode 100644 tests/e2e/settings-skill-management-production.spec.ts
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 0a4169c6b7d..b93661233dd 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -15,6 +15,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh
@@ -69,6 +71,11 @@ jobs:
- name: Check reliability gate manifest
run: pnpm run check:reliability-gates
+ # Why: skill revisions are append-only; checking merge-queue and main
+ # heads prevents independently green PRs from claiming one revision.
+ - name: Verify bundled skill manifest
+ run: pnpm run verify:skill-bundle-manifest -- --base-ref=origin/${{ github.base_ref }}
+
# Why: oxlint fails any file over max-lines that is NOT suppressed, so this
# ratchet forbids ADDING a new suppression (inline disable or mobile max
# bump). Existing oversized files are grandfathered in
diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml
index 397772bfb7e..372dce928ff 100644
--- a/.github/workflows/release-cut.yml
+++ b/.github/workflows/release-cut.yml
@@ -472,7 +472,10 @@ jobs:
# message and tag name explicitly (avoids npm's `v1.2.3` prefix
# assumptions and any lifecycle scripts that would run on bump).
npm version "$VERSION" --no-git-tag-version --allow-same-version
- git add package.json
+ # Why: the versioned release commit is the mechanical authority that
+ # records exactly which generated skill revisions shipped.
+ node config/scripts/generate-skill-bundle-manifest.mjs --write --record-release
+ git add package.json resources/skills
commit_message="release: v$VERSION"
if [[ "$EVENT_NAME" == "schedule" ]]; then
commit_message="$commit_message [rc-slot:$SLOT]"
@@ -1220,7 +1223,6 @@ jobs:
Write-Warning 'Restored pre-rebuild installer; this release ships with unsigned inner binaries.'
}
# ── End Windows inner-binary signing ───────────────────────────────
-
- name: Upload unsigned Windows installer for SignPath
if: matrix.platform == 'win'
id: upload-unsigned-windows-installer
diff --git a/.github/workflows/skill-cli-roundtrip.yml b/.github/workflows/skill-cli-roundtrip.yml
new file mode 100644
index 00000000000..f4e7bbcf03a
--- /dev/null
+++ b/.github/workflows/skill-cli-roundtrip.yml
@@ -0,0 +1,38 @@
+name: Skill CLI round trip
+
+on:
+ pull_request:
+ paths:
+ - 'skills/**'
+ - 'resources/skills/**'
+ - 'config/scripts/generate-skill-bundle-manifest.mjs'
+ - 'config/scripts/verify-skill-cli-roundtrip.mjs'
+ - '.github/workflows/skill-cli-roundtrip.yml'
+ merge_group:
+ push:
+ branches:
+ - main
+
+jobs:
+ roundtrip:
+ continue-on-error: ${{ matrix.skills-cli == 'latest' }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [macos-latest, ubuntu-latest, windows-latest]
+ skills-cli: ['1.5.16', latest]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ - uses: actions/setup-node@v6
+ with:
+ node-version-file: package.json
+ - name: Verify LF and CRLF installer identities
+ shell: bash
+ run: |
+ node config/scripts/verify-skill-cli-roundtrip.mjs --cli=${{ matrix.skills-cli }} --autocrlf=false --shape=symlink
+ node config/scripts/verify-skill-cli-roundtrip.mjs --cli=${{ matrix.skills-cli }} --autocrlf=false --shape=copy
+ node config/scripts/verify-skill-cli-roundtrip.mjs --cli=${{ matrix.skills-cli }} --autocrlf=true --shape=symlink
+ node config/scripts/verify-skill-cli-roundtrip.mjs --cli=${{ matrix.skills-cli }} --autocrlf=true --shape=copy
diff --git a/.github/workflows/skill-manifest-merge-gate.yml b/.github/workflows/skill-manifest-merge-gate.yml
new file mode 100644
index 00000000000..20e2125e712
--- /dev/null
+++ b/.github/workflows/skill-manifest-merge-gate.yml
@@ -0,0 +1,21 @@
+name: Skill manifest merge gate
+
+on:
+ merge_group:
+ push:
+ branches:
+ - main
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ - uses: actions/setup-node@v6
+ with:
+ node-version-file: package.json
+ # Why: rerunning generation on the integrated head prevents two skill
+ # changes from independently claiming the same immutable revision.
+ - run: node config/scripts/generate-skill-bundle-manifest.mjs --base-ref=HEAD^
diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs
index b15ffc07f5d..1b72e9380f7 100644
--- a/config/electron-builder.config.cjs
+++ b/config/electron-builder.config.cjs
@@ -18,6 +18,12 @@ const featureWallResources = {
from: 'resources/onboarding/feature-wall',
to: 'onboarding/feature-wall'
}
+// Why: the updater must compare against the exact skill bundle belonging to
+// the running app instead of mutable checkout or network content.
+const skillBundleResources = [
+ { from: 'skills', to: 'skills/packages' },
+ { from: 'resources/skills', to: 'skills' }
+]
// Why: SSH relay deploy resolves bundles from process.resourcesPath in packaged
// apps. Keeping relay assets as extraResources makes them real directories
// instead of paths hidden inside app.asar.
@@ -31,7 +37,11 @@ const relayExtraResource = {
// do not fall through to a developer checkout's node_modules.
const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources()
-const commonExtraResources = [relayExtraResource, ...packagedRuntimeNodeModuleResources]
+const commonExtraResources = [
+ relayExtraResource,
+ ...packagedRuntimeNodeModuleResources,
+ ...skillBundleResources
+]
const macSpeechNativeResource = {
from: 'node_modules/sherpa-onnx-darwin-${arch}',
to: 'node_modules/sherpa-onnx-darwin-${arch}'
diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc
index c2cbd6e62d0..670f3435a33 100644
--- a/config/reliability-gates.jsonc
+++ b/config/reliability-gates.jsonc
@@ -3582,6 +3582,162 @@
"No live Electron reload-survival assertion yet — the gate proves the main-side accounting reset, not the full renderer reattach paint."
],
"demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner."
+ },
+ {
+ "id": "terminal-session.authoritative-exit-status",
+ "title": "Install ownership requires an authoritative successful terminal exit",
+ "maturity": "experimental",
+ "protection": "partial",
+ "owner": "terminal-runtime",
+ "layer": "renderer-provider-contract",
+ "surfaces": [
+ "local terminal",
+ "daemon and WSL terminal",
+ "SSH and remote-runtime terminal",
+ "skill install ownership"
+ ],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
+ "coveredPlatforms": ["macos"],
+ "coveredProviders": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
+ "coverageNotes": "Deterministic provider-contract tests prove that the shared local/daemon/WSL IPC path preserves numeric process status, while remote runtime stream retirement reports unknown status and ownership accepts only numeric zero.",
+ "motivatingLinks": ["https://github.com/stablyai/orca"],
+ "invariant": "A transport may report numeric zero only when its terminal provider supplies an authoritative process exit; disconnect, stale-handle retirement, and stream end remain unknown and cannot grant skill ownership.",
+ "oracle": "Drive the IPC PTY transport with zero and nonzero process exits, drive remote runtime close/end/stale events without a process status, and assert the install decision accepts only code zero after an active install window.",
+ "commands": [
+ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/settings/skill-install-terminal-exit.test.ts"
+ ],
+ "testFiles": [
+ "src/renderer/src/components/terminal-pane/pty-transport.test.ts",
+ "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
+ "src/renderer/src/components/settings/skill-install-terminal-exit.test.ts"
+ ],
+ "assertionRefs": [
+ {
+ "file": "src/renderer/src/components/terminal-pane/pty-transport.test.ts",
+ "assertions": [
+ "the shared local IPC transport forwards authoritative zero and nonzero process exit codes"
+ ]
+ },
+ {
+ "file": "src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
+ "assertions": [
+ "remote stream end, disconnect, and stale-handle retirement report a null exit status"
+ ]
+ },
+ {
+ "file": "src/renderer/src/components/settings/skill-install-terminal-exit.test.ts",
+ "assertions": [
+ "skill ownership accepts numeric zero only after the install window begins and rejects null or nonzero"
+ ]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-07-12",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/settings/skill-install-terminal-exit.test.ts",
+ "result": "passed",
+ "durationSeconds": 2.11,
+ "summary": "120 deterministic provider-contract and install-ownership tests passed."
+ }
+ ],
+ "runtimeBudget": {
+ "p95Seconds": 15,
+ "scope": "local renderer unit tests"
+ },
+ "flakeHistory": {
+ "status": "unknown",
+ "evidence": "New deterministic contract gate; soak history has not accumulated."
+ },
+ "redGreenEvidence": {
+ "status": "partial",
+ "evidence": "The remote oracle fails if stream end invents zero, and the ownership oracle fails if null or nonzero is accepted."
+ },
+ "performanceBudget": {
+ "required": false,
+ "evidence": "The change only propagates an event payload and adds no loop, polling, or hot-path work."
+ },
+ "promotionCriteria": [
+ "Accumulate the required soak runs across all required CI platforms.",
+ "Add a live provider-contract run when SSH and WSL terminal harnesses are available in CI."
+ ],
+ "knownGaps": [
+ "Daemon and WSL use the same IPC transport contract as local but do not yet have live provider processes in this unit gate.",
+ "Remote runtimes currently expose stream completion rather than an authoritative subprocess exit status."
+ ],
+ "demotionRule": "Demote or quarantine if the gate flakes once without a product or harness issue filed to the owner."
+ },
+ {
+ "id": "skill-management.inventory-bounded-work",
+ "title": "Mounted skill inventory has deterministic filesystem work limits",
+ "maturity": "experimental",
+ "protection": "partial",
+ "owner": "skill-management",
+ "layer": "main-process-filesystem",
+ "surfaces": ["managed skill adoption nudge", "focus inventory refresh"],
+ "platforms": ["macos", "linux", "windows"],
+ "providers": ["local"],
+ "coveredPlatforms": ["macos"],
+ "coveredProviders": ["local"],
+ "coverageNotes": "Deterministic fixtures prove depth, entry, file-count, per-file-byte, and aggregate-byte limits and prove one inaccessible candidate cannot hide healthy siblings.",
+ "motivatingLinks": ["https://github.com/stablyai/orca"],
+ "invariant": "Always-mounted and focus-triggered skill inventory performs bounded directory and byte work per candidate, and a pathological candidate cannot reject or hide healthy sibling rows.",
+ "oracle": "Drive tiny injected limits across depth, entries, files, single-file bytes, and aggregate bytes; then inventory an oversized and an access-denied candidate beside a healthy official copy and assert stable fail-closed rows plus the healthy sibling.",
+ "commands": [
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-package-observation-limits.test.ts src/main/skills/skill-management-inventory.test.ts"
+ ],
+ "testFiles": [
+ "src/main/skills/skill-package-observation-limits.test.ts",
+ "src/main/skills/skill-management-inventory.test.ts"
+ ],
+ "assertionRefs": [
+ {
+ "file": "src/main/skills/skill-package-observation-limits.test.ts",
+ "assertions": [
+ "depth, entry, file-count, single-file-byte, and aggregate-byte limits fail with stable categories"
+ ]
+ },
+ {
+ "file": "src/main/skills/skill-management-inventory.test.ts",
+ "assertions": [
+ "oversized and access-denied candidates fail closed without hiding healthy siblings"
+ ]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-07-12",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/skills/skill-package-observation-limits.test.ts src/main/skills/skill-management-inventory.test.ts",
+ "result": "passed",
+ "durationSeconds": 0.183,
+ "summary": "15 deterministic bounded-observation and sibling-isolation tests passed."
+ }
+ ],
+ "runtimeBudget": { "p95Seconds": 10, "scope": "local main-process unit tests" },
+ "flakeHistory": {
+ "status": "unknown",
+ "evidence": "New deterministic filesystem fixtures; soak history has not accumulated."
+ },
+ "redGreenEvidence": {
+ "status": "partial",
+ "evidence": "The limit fixtures fail if their corresponding counter or byte guard is removed; saved CI evidence remains pending."
+ },
+ "performanceBudget": {
+ "required": true,
+ "evidence": "Production observation is capped at depth 16, 2,048 entries, 512 files, 4 MiB per file, and 32 MiB aggregate per candidate with no polling or subprocesses."
+ },
+ "promotionCriteria": [
+ "Accumulate the required soak runs across macOS, Linux, and Windows.",
+ "Attach saved red/green evidence for every limit guard."
+ ],
+ "knownGaps": [
+ "The deterministic gate runs locally on macOS; Linux and Windows CI evidence is pending."
+ ],
+ "demotionRule": "Demote or quarantine if the gate flakes once without a product or harness issue filed to the owner."
}
]
}
diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs
index faf29fa244f..ac9016119f9 100644
--- a/config/scripts/electron-builder-config.test.mjs
+++ b/config/scripts/electron-builder-config.test.mjs
@@ -65,6 +65,14 @@ describe('electron-builder config', () => {
})
])
)
+ for (const platform of ['mac', 'linux', 'win']) {
+ expect(electronBuilderConfig[platform].extraResources).toEqual(
+ expect.arrayContaining([
+ { from: 'skills', to: 'skills/packages' },
+ { from: 'resources/skills', to: 'skills' }
+ ])
+ )
+ }
})
// Why: on macOS 26 UNUserNotificationCenter aborts for executables launched
diff --git a/config/scripts/generate-skill-bundle-manifest.mjs b/config/scripts/generate-skill-bundle-manifest.mjs
new file mode 100644
index 00000000000..6d7bd631e49
--- /dev/null
+++ b/config/scripts/generate-skill-bundle-manifest.mjs
@@ -0,0 +1,463 @@
+import { createHash } from 'node:crypto'
+import { execFileSync } from 'node:child_process'
+import { constants } from 'node:fs'
+import { access, lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+import process from 'node:process'
+
+const SCHEMA_VERSION = 1
+const SCRIPT_DIR = import.meta.dirname
+const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..')
+const SKILLS_ROOT = path.join(REPO_ROOT, 'skills')
+const OUTPUT_ROOT = path.join(REPO_ROOT, 'resources', 'skills')
+const CURRENT_MANIFEST_PATH = path.join(OUTPUT_ROOT, 'current-manifest.json')
+const SNAPSHOT_REGISTRY_PATH = path.join(OUTPUT_ROOT, 'snapshot-registry.json')
+const RELEASE_MAPPING_PATH = path.join(OUTPUT_ROOT, 'release-mapping.json')
+
+function sha256(bytes) {
+ return createHash('sha256').update(bytes).digest('hex')
+}
+
+function gitObjectSha(kind, bytes) {
+ return createHash('sha1').update(`${kind} ${bytes.length}\0`).update(bytes).digest()
+}
+
+function normalizeText(bytes) {
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
+ return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8')
+}
+
+function classifyFile(bytes) {
+ if (bytes.includes(0)) {
+ return 'binary'
+ }
+ try {
+ normalizeText(bytes)
+ return 'text'
+ } catch {
+ return 'binary'
+ }
+}
+
+function assertSafeRelativePath(relativePath) {
+ if (
+ path.isAbsolute(relativePath) ||
+ relativePath === '..' ||
+ relativePath.startsWith(`..${path.sep}`)
+ ) {
+ throw new Error(`Unsafe skill package path: ${relativePath}`)
+ }
+}
+
+function describeFile(manifestPath, bytes, executable) {
+ const classification = classifyFile(bytes)
+ const exactSha256 = sha256(bytes)
+ const textNormalizedSha256 = classification === 'text' ? sha256(normalizeText(bytes)) : null
+ return {
+ path: manifestPath,
+ size: bytes.length,
+ executable,
+ classification,
+ exactSha256,
+ textNormalizedSha256,
+ identitySha256: classification === 'text' && !executable ? textNormalizedSha256 : exactSha256,
+ gitBlobSha: gitObjectSha('blob', bytes).toString('hex')
+ }
+}
+
+function gitTreeSha(entries) {
+ const root = { directories: new Map(), files: [] }
+ for (const entry of entries) {
+ const parts = entry.path.split('/')
+ const filename = parts.pop()
+ let directory = root
+ for (const part of parts) {
+ let child = directory.directories.get(part)
+ if (!child) {
+ child = { directories: new Map(), files: [] }
+ directory.directories.set(part, child)
+ }
+ directory = child
+ }
+ directory.files.push({ filename, ...entry })
+ }
+
+ function hashDirectory(directory) {
+ const children = [
+ ...[...directory.directories].map(([name, child]) => ({
+ mode: '40000',
+ name,
+ hash: hashDirectory(child)
+ })),
+ ...directory.files.map((file) => ({
+ mode: file.executable ? '100755' : '100644',
+ name: file.filename,
+ hash: Buffer.from(file.gitBlobSha, 'hex')
+ }))
+ ].sort((left, right) => Buffer.from(left.name).compare(Buffer.from(right.name)))
+ const body = Buffer.concat(
+ children.map(({ mode, name, hash }) =>
+ Buffer.concat([Buffer.from(`${mode} ${name}\0`, 'utf8'), hash])
+ )
+ )
+ return gitObjectSha('tree', body)
+ }
+
+ return hashDirectory(root).toString('hex')
+}
+
+async function collectPackageFiles(packageRoot) {
+ const files = []
+ const caseFoldedPaths = new Map()
+
+ async function visit(directory) {
+ const entries = await readdir(directory, { withFileTypes: true })
+ entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))
+ for (const entry of entries) {
+ const absolutePath = path.join(directory, entry.name)
+ const relativePath = path.relative(packageRoot, absolutePath)
+ assertSafeRelativePath(relativePath)
+ const manifestPath = relativePath.split(path.sep).join('/')
+ const foldedPath = manifestPath.toLocaleLowerCase('en-US')
+ const collision = caseFoldedPaths.get(foldedPath)
+ if (collision && collision !== manifestPath) {
+ throw new Error(`Case-colliding skill paths: ${collision} and ${manifestPath}`)
+ }
+ caseFoldedPaths.set(foldedPath, manifestPath)
+
+ const fileStat = await lstat(absolutePath)
+ if (fileStat.isSymbolicLink()) {
+ throw new Error(`Symlink is not allowed in a shipped skill: ${manifestPath}`)
+ }
+ if (fileStat.isDirectory()) {
+ await visit(absolutePath)
+ continue
+ }
+ if (!fileStat.isFile()) {
+ throw new Error(`Special file is not allowed in a shipped skill: ${manifestPath}`)
+ }
+
+ const bytes = await readFile(absolutePath)
+ const executable = (fileStat.mode & 0o111) !== 0
+ files.push(describeFile(manifestPath, bytes, executable))
+ }
+ }
+
+ await visit(packageRoot)
+ return files
+}
+
+function collectGitPackageFiles(ref, name) {
+ const sourcePrefix = `skills/${name}/`
+ const output = execFileSync('git', ['ls-tree', '-r', '-z', ref, '--', `skills/${name}`])
+ .toString('utf8')
+ .split('\0')
+ .filter(Boolean)
+ const caseFoldedPaths = new Map()
+ return output.map((line) => {
+ const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(line)
+ if (!match) {
+ throw new Error(`Unexpected git tree entry at ${ref}: ${line}`)
+ }
+ const [, mode, type, objectSha, sourcePath] = match
+ if (type !== 'blob' || (mode !== '100644' && mode !== '100755')) {
+ throw new Error(`Unsupported shipped skill entry at ${ref}: ${line}`)
+ }
+ const manifestPath = sourcePath.slice(sourcePrefix.length)
+ assertSafeRelativePath(manifestPath)
+ const foldedPath = manifestPath.toLocaleLowerCase('en-US')
+ const collision = caseFoldedPaths.get(foldedPath)
+ if (collision && collision !== manifestPath) {
+ throw new Error(`Case-colliding skill paths at ${ref}: ${collision} and ${manifestPath}`)
+ }
+ caseFoldedPaths.set(foldedPath, manifestPath)
+ const bytes = execFileSync('git', ['cat-file', 'blob', objectSha])
+ return describeFile(manifestPath, bytes, mode === '100755')
+ })
+}
+
+function releaseTags() {
+ return execFileSync(
+ 'git',
+ ['for-each-ref', '--sort=creatordate', '--format=%(refname:short)', 'refs/tags/v*'],
+ { encoding: 'utf8' }
+ )
+ .split('\n')
+ .filter((tag) => /^v\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?$/.test(tag))
+}
+
+function skillsTreeShaAtRef(ref) {
+ try {
+ const output = execFileSync('git', ['ls-tree', ref, 'skills'], {
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'ignore']
+ }).trim()
+ return output ? output.split(/\s+/)[2] : null
+ } catch {
+ return null
+ }
+}
+
+function skillNamesAtRef(ref) {
+ try {
+ return execFileSync('git', ['ls-tree', '-d', '--name-only', `${ref}:skills`], {
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'ignore']
+ })
+ .split('\n')
+ .filter(Boolean)
+ .sort((left, right) => left.localeCompare(right, 'en'))
+ } catch {
+ return []
+ }
+}
+
+function bootstrapReleaseHistory() {
+ const registry = { schemaVersion: SCHEMA_VERSION, skills: {} }
+ const mapping = { schemaVersion: SCHEMA_VERSION, releases: [] }
+ let previousSkillsTreeSha = null
+
+ for (const tag of releaseTags()) {
+ const skillsTreeSha = skillsTreeShaAtRef(tag)
+ // Why: one mapping per shipped package set proves provenance without
+ // repeating hundreds of app releases that shipped identical skill bytes.
+ if (!skillsTreeSha || skillsTreeSha === previousSkillsTreeSha) {
+ continue
+ }
+ previousSkillsTreeSha = skillsTreeSha
+ const revisions = {}
+ for (const name of skillNamesAtRef(tag)) {
+ const filesWithGitHashes = collectGitPackageFiles(tag, name)
+ if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) {
+ continue
+ }
+ const digest = packageDigest(filesWithGitHashes)
+ const snapshots = registry.skills[name] ?? []
+ const latest = snapshots.at(-1)
+ if (!latest || latest.packageDigest !== digest) {
+ const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file)
+ snapshots.push({
+ releaseRevision: (latest?.releaseRevision ?? 0) + 1,
+ packageDigest: digest,
+ gitTreeSha: gitTreeSha(filesWithGitHashes),
+ files
+ })
+ registry.skills[name] = snapshots
+ }
+ revisions[name] = snapshots.at(-1).releaseRevision
+ }
+ if (Object.keys(revisions).length > 0) {
+ mapping.releases.push({ appVersion: tag.slice(1), skills: revisions })
+ }
+ }
+ return { registry, mapping }
+}
+
+function packageDigest(files) {
+ const identity = files.map((file) => ({
+ path: file.path,
+ executable: file.executable,
+ classification: file.classification,
+ identitySha256: file.identitySha256
+ }))
+ return sha256(Buffer.from(JSON.stringify(identity), 'utf8'))
+}
+
+async function readJsonIfPresent(filePath, fallback) {
+ try {
+ return JSON.parse(await readFile(filePath, 'utf8'))
+ } catch (error) {
+ if (error && error.code === 'ENOENT') {
+ return fallback
+ }
+ throw error
+ }
+}
+
+function readJsonAtRef(ref, relativePath) {
+ try {
+ return JSON.parse(
+ execFileSync('git', ['show', `${ref}:${relativePath}`], {
+ encoding: 'utf8',
+ stdio: ['ignore', 'pipe', 'ignore']
+ })
+ )
+ } catch {
+ return null
+ }
+}
+
+function assertBaseHistoryPreserved(baseRef, artifacts) {
+ if (!baseRef) {
+ return
+ }
+ const baseRegistry = readJsonAtRef(baseRef, 'resources/skills/snapshot-registry.json')
+ const baseMapping = readJsonAtRef(baseRef, 'resources/skills/release-mapping.json')
+ if (baseRegistry) {
+ for (const [name, snapshots] of Object.entries(baseRegistry.skills ?? {})) {
+ const current = artifacts.snapshotRegistry.skills[name] ?? []
+ if (JSON.stringify(current.slice(0, snapshots.length)) !== JSON.stringify(snapshots)) {
+ throw new Error(`Skill snapshot history for ${name} differs from immutable base ${baseRef}`)
+ }
+ }
+ }
+ if (
+ baseMapping &&
+ JSON.stringify(artifacts.releaseMapping.releases.slice(0, baseMapping.releases.length)) !==
+ JSON.stringify(baseMapping.releases)
+ ) {
+ throw new Error(`Skill release mapping differs from immutable base ${baseRef}`)
+ }
+}
+
+async function buildArtifacts({ appVersion, recordRelease, bootstrapHistory = false }) {
+ const history = bootstrapHistory ? bootstrapReleaseHistory() : null
+ const existingRegistry =
+ history?.registry ??
+ (await readJsonIfPresent(SNAPSHOT_REGISTRY_PATH, {
+ schemaVersion: SCHEMA_VERSION,
+ skills: {}
+ }))
+ if (existingRegistry.schemaVersion !== SCHEMA_VERSION) {
+ throw new Error(`Unsupported skill snapshot registry schema: ${existingRegistry.schemaVersion}`)
+ }
+
+ const skillDirectories = (await readdir(SKILLS_ROOT, { withFileTypes: true }))
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => entry.name)
+ .sort((left, right) => left.localeCompare(right, 'en'))
+ const currentSkills = []
+ const nextRegistry = structuredClone(existingRegistry)
+
+ for (const name of skillDirectories) {
+ const filesWithGitHashes = await collectPackageFiles(path.join(SKILLS_ROOT, name))
+ if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) {
+ throw new Error(`Skill package ${name} has no top-level SKILL.md`)
+ }
+ const digest = packageDigest(filesWithGitHashes)
+ const existingSnapshots = nextRegistry.skills[name] ?? []
+ const latest = existingSnapshots.at(-1)
+ let snapshot = latest
+ if (!latest || latest.packageDigest !== digest) {
+ const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file)
+ snapshot = {
+ releaseRevision: (latest?.releaseRevision ?? 0) + 1,
+ packageDigest: digest,
+ gitTreeSha: gitTreeSha(filesWithGitHashes),
+ files
+ }
+ existingSnapshots.push(snapshot)
+ nextRegistry.skills[name] = existingSnapshots
+ }
+ const currentFiles = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file)
+ currentSkills.push({
+ name,
+ sourcePath: `skills/${name}`,
+ appVersion,
+ releaseRevision: snapshot.releaseRevision,
+ packageDigest: digest,
+ gitTreeSha: gitTreeSha(filesWithGitHashes),
+ files: currentFiles
+ })
+ }
+
+ const currentManifest = {
+ schemaVersion: SCHEMA_VERSION,
+ appVersion,
+ skills: currentSkills
+ }
+ const existingMapping =
+ history?.mapping ??
+ (await readJsonIfPresent(RELEASE_MAPPING_PATH, {
+ schemaVersion: SCHEMA_VERSION,
+ releases: []
+ }))
+ if (existingMapping.schemaVersion !== SCHEMA_VERSION) {
+ throw new Error(`Unsupported skill release mapping schema: ${existingMapping.schemaVersion}`)
+ }
+ const releaseMapping = structuredClone(existingMapping)
+ if (recordRelease) {
+ const revisions = Object.fromEntries(
+ currentSkills.map((skill) => [skill.name, skill.releaseRevision])
+ )
+ const prior = releaseMapping.releases.find((release) => release.appVersion === appVersion)
+ if (prior && JSON.stringify(prior.skills) !== JSON.stringify(revisions)) {
+ throw new Error(`Release mapping for ${appVersion} is immutable`)
+ }
+ if (!prior) {
+ releaseMapping.releases.push({ appVersion, skills: revisions })
+ }
+ }
+
+ return { currentManifest, snapshotRegistry: nextRegistry, releaseMapping }
+}
+
+function serialized(value) {
+ return `${JSON.stringify(value, null, 2)}\n`
+}
+
+async function writeArtifacts(artifacts) {
+ await mkdir(OUTPUT_ROOT, { recursive: true })
+ await Promise.all([
+ writeFile(CURRENT_MANIFEST_PATH, serialized(artifacts.currentManifest)),
+ writeFile(SNAPSHOT_REGISTRY_PATH, serialized(artifacts.snapshotRegistry)),
+ writeFile(RELEASE_MAPPING_PATH, serialized(artifacts.releaseMapping))
+ ])
+}
+
+async function verifyArtifacts(artifacts) {
+ const expected = [
+ [CURRENT_MANIFEST_PATH, artifacts.currentManifest],
+ [SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry],
+ [RELEASE_MAPPING_PATH, artifacts.releaseMapping]
+ ]
+ const stale = []
+ for (const [filePath, value] of expected) {
+ try {
+ await access(filePath, constants.R_OK)
+ if ((await readFile(filePath, 'utf8')) !== serialized(value)) {
+ stale.push(filePath)
+ }
+ } catch {
+ stale.push(filePath)
+ }
+ }
+ if (stale.length > 0) {
+ throw new Error(
+ `Generated skill artifacts are stale:\n${stale.map((filePath) => path.relative(REPO_ROOT, filePath)).join('\n')}\nRun pnpm generate:skill-bundle-manifest.`
+ )
+ }
+}
+
+async function main() {
+ const packageJson = JSON.parse(await readFile(path.join(REPO_ROOT, 'package.json'), 'utf8'))
+ const write = process.argv.includes('--write')
+ const recordRelease = process.argv.includes('--record-release')
+ const bootstrapHistory = process.argv.includes('--bootstrap-release-history')
+ const baseRef = process.argv
+ .find((arg) => arg.startsWith('--base-ref='))
+ ?.slice('--base-ref='.length)
+ const artifacts = await buildArtifacts({
+ appVersion: packageJson.version,
+ recordRelease,
+ bootstrapHistory
+ })
+ assertBaseHistoryPreserved(baseRef, artifacts)
+ await (write ? writeArtifacts : verifyArtifacts)(artifacts)
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
+ main().catch((error) => {
+ console.error(error instanceof Error ? error.message : error)
+ process.exitCode = 1
+ })
+}
+
+export {
+ buildArtifacts,
+ classifyFile,
+ collectPackageFiles,
+ gitTreeSha,
+ normalizeText,
+ packageDigest
+}
diff --git a/config/scripts/generate-skill-bundle-manifest.test.mjs b/config/scripts/generate-skill-bundle-manifest.test.mjs
new file mode 100644
index 00000000000..1c694310084
--- /dev/null
+++ b/config/scripts/generate-skill-bundle-manifest.test.mjs
@@ -0,0 +1,98 @@
+import { execFileSync } from 'node:child_process'
+import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ classifyFile,
+ collectPackageFiles,
+ gitTreeSha,
+ normalizeText,
+ packageDigest
+} from './generate-skill-bundle-manifest.mjs'
+
+const temporaryDirectories = []
+
+async function createPackage() {
+ const directory = await mkdtemp(path.join(tmpdir(), 'orca-skill-manifest-'))
+ temporaryDirectories.push(directory)
+ return directory
+}
+
+afterEach(async () => {
+ await Promise.all(
+ temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
+ )
+})
+
+describe('skill bundle manifest generator', () => {
+ it('folds platform line endings for text identity', () => {
+ const lf = Buffer.from('first\nsecond\n')
+ const crlf = Buffer.from('first\r\nsecond\r\n')
+
+ expect(classifyFile(lf)).toBe('text')
+ expect(normalizeText(crlf)).toEqual(lf)
+ })
+
+ it('classifies null-containing and invalid UTF-8 content as binary', () => {
+ expect(classifyFile(Buffer.from([0, 1, 2]))).toBe('binary')
+ expect(classifyFile(Buffer.from([0xc3, 0x28]))).toBe('binary')
+ })
+
+ it('uses normalized text identity but exact executable identity', async () => {
+ const packageRoot = await createPackage()
+ await writeFile(path.join(packageRoot, 'SKILL.md'), 'line one\r\nline two\r\n')
+ await writeFile(path.join(packageRoot, 'run.sh'), '#!/bin/sh\necho ok\n')
+ await chmod(path.join(packageRoot, 'run.sh'), 0o755)
+
+ const files = await collectPackageFiles(packageRoot)
+ const skillFile = files.find((file) => file.path === 'SKILL.md')
+ const executable = files.find((file) => file.path === 'run.sh')
+
+ expect(skillFile.identitySha256).toBe(skillFile.textNormalizedSha256)
+ expect(skillFile.identitySha256).not.toBe(skillFile.exactSha256)
+ expect(executable.identitySha256).toBe(executable.exactSha256)
+ expect(packageDigest(files)).toMatch(/^[a-f0-9]{64}$/)
+ })
+
+ it.runIf(process.platform === 'linux')('rejects case-colliding paths', async () => {
+ const packageRoot = await createPackage()
+ await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill')
+ await writeFile(path.join(packageRoot, 'Readme.md'), 'one')
+ await writeFile(path.join(packageRoot, 'README.md'), 'two')
+
+ await expect(collectPackageFiles(packageRoot)).rejects.toThrow('Case-colliding skill paths')
+ })
+
+ it.runIf(process.platform !== 'win32')('rejects symlinks inside shipped packages', async () => {
+ const packageRoot = await createPackage()
+ await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill')
+ await symlink('SKILL.md', path.join(packageRoot, 'linked.md'))
+
+ await expect(collectPackageFiles(packageRoot)).rejects.toThrow(
+ 'Symlink is not allowed in a shipped skill'
+ )
+ })
+
+ it('computes the same Git tree identity as Git', async () => {
+ const packageRoot = path.resolve('skills', 'orca-cli')
+ const files = await collectPackageFiles(packageRoot)
+ const expected = execFileSync('git', ['ls-tree', 'HEAD:skills', 'orca-cli'], {
+ encoding: 'utf8'
+ })
+ .trim()
+ .split(/\s+/)[2]
+
+ expect(gitTreeSha(files)).toBe(expected)
+ })
+
+ it('includes nested directory names in Git tree identity', async () => {
+ const packageRoot = await createPackage()
+ await mkdir(path.join(packageRoot, 'assets'))
+ await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill\n')
+ await writeFile(path.join(packageRoot, 'assets', 'prompt.txt'), 'prompt\n')
+
+ const files = await collectPackageFiles(packageRoot)
+ expect(gitTreeSha(files)).toMatch(/^[a-f0-9]{40}$/)
+ })
+})
diff --git a/config/scripts/verify-skill-cli-roundtrip.mjs b/config/scripts/verify-skill-cli-roundtrip.mjs
new file mode 100644
index 00000000000..7a28572d600
--- /dev/null
+++ b/config/scripts/verify-skill-cli-roundtrip.mjs
@@ -0,0 +1,134 @@
+import { execFileSync } from 'node:child_process'
+import { chmod, lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import process from 'node:process'
+import { collectPackageFiles, packageDigest } from './generate-skill-bundle-manifest.mjs'
+
+const cliVersion = process.argv.find((value) => value.startsWith('--cli='))?.slice('--cli='.length)
+const autocrlf = process.argv
+ .find((value) => value.startsWith('--autocrlf='))
+ ?.slice('--autocrlf='.length)
+const shape = process.argv.find((value) => value.startsWith('--shape='))?.slice('--shape='.length)
+if (
+ !cliVersion ||
+ (autocrlf !== 'true' && autocrlf !== 'false') ||
+ (shape !== 'symlink' && shape !== 'copy')
+) {
+ throw new Error(
+ 'Usage: verify-skill-cli-roundtrip.mjs --cli= --autocrlf=true|false --shape=symlink|copy'
+ )
+}
+
+const sandbox = await mkdtemp(join(tmpdir(), 'orca-skill-cli-roundtrip-'))
+const home = join(sandbox, 'home')
+const source = join(sandbox, 'source')
+const manifest = JSON.parse(
+ await readFile(resolve('resources/skills/current-manifest.json'), 'utf8')
+)
+const manifestByName = new Map(manifest.skills.map((skill) => [skill.name, skill]))
+const fixtureName = 'orca-roundtrip-fixture'
+const skillNames = [...manifest.skills.map((skill) => skill.name), fixtureName]
+
+async function createRepresentativeFixture() {
+ const fixtureRoot = join(source, 'skills', fixtureName)
+ const executablePath = join(fixtureRoot, 'scripts', 'check.sh')
+ await mkdir(join(fixtureRoot, 'assets'), { recursive: true })
+ await mkdir(join(fixtureRoot, 'scripts'), { recursive: true })
+ await writeFile(
+ join(fixtureRoot, 'SKILL.md'),
+ `---\nname: ${fixtureName}\ndescription: Exercises real multi-file CLI installation.\n---\n\n# Round-trip fixture\n`
+ )
+ await writeFile(join(fixtureRoot, 'assets', 'guide.txt'), 'nested text asset\nsecond line\n')
+ await writeFile(join(fixtureRoot, 'assets', 'payload.bin'), Buffer.from([0, 1, 2, 13, 10, 255]))
+ await writeFile(executablePath, '#!/bin/sh\nprintf fixture\\n\n')
+ if (process.platform !== 'win32') {
+ await chmod(executablePath, 0o755)
+ }
+ const files = await collectPackageFiles(fixtureRoot)
+ manifestByName.set(fixtureName, { name: fixtureName, packageDigest: packageDigest(files) })
+}
+
+async function verifyPackage(name, packageRoot) {
+ const expected = manifestByName.get(name)
+ if (!expected) {
+ throw new Error(`Missing current manifest entry for ${name}`)
+ }
+ const files = await collectPackageFiles(packageRoot)
+ const digest = packageDigest(files)
+ if (digest !== expected.packageDigest) {
+ throw new Error(
+ `${name} identity mismatch with core.autocrlf=${autocrlf}: ${digest} != ${expected.packageDigest}`
+ )
+ }
+}
+
+try {
+ execFileSync(
+ 'git',
+ [
+ '-c',
+ `core.autocrlf=${autocrlf}`,
+ 'clone',
+ '--quiet',
+ '--no-hardlinks',
+ process.cwd(),
+ source
+ ],
+ { stdio: 'inherit' }
+ )
+ // Why: shipped packages are currently single-file, so this temporary source
+ // package keeps nested, binary, and POSIX executable semantics in the real CLI oracle.
+ await createRepresentativeFixture()
+ execFileSync(
+ process.platform === 'win32' ? 'npx.cmd' : 'npx',
+ [
+ '--yes',
+ `skills@${cliVersion}`,
+ 'add',
+ source,
+ '--skill',
+ ...skillNames,
+ '--agent',
+ 'codex',
+ 'claude-code',
+ '--global',
+ '--yes',
+ ...(shape === 'copy' ? ['--copy'] : [])
+ ],
+ {
+ cwd: source,
+ env: {
+ ...process.env,
+ HOME: home,
+ USERPROFILE: home,
+ XDG_STATE_HOME: join(home, '.state'),
+ GIT_CONFIG_COUNT: '1',
+ GIT_CONFIG_KEY_0: 'core.autocrlf',
+ GIT_CONFIG_VALUE_0: autocrlf
+ },
+ stdio: 'inherit'
+ }
+ )
+
+ for (const name of skillNames) {
+ const canonical = join(home, '.agents', 'skills', name)
+ const canonicalStat = await lstat(canonical)
+ if (!canonicalStat.isDirectory() || canonicalStat.isSymbolicLink()) {
+ throw new Error(`${name} canonical CLI placement is not a real directory`)
+ }
+ await verifyPackage(name, canonical)
+
+ const provider = join(home, '.claude', 'skills', name)
+ const providerStat = await lstat(provider)
+ if (shape === 'symlink' && !providerStat.isSymbolicLink()) {
+ throw new Error(`${name} provider CLI placement is not a symlink or junction`)
+ }
+ if (shape === 'copy' && (!providerStat.isDirectory() || providerStat.isSymbolicLink())) {
+ throw new Error(`${name} provider CLI placement is not a copy`)
+ }
+ await verifyPackage(name, await realpath(provider))
+ }
+} finally {
+ await rm(sandbox, { recursive: true, force: true })
+}
diff --git a/package.json b/package.json
index 6ed66c79734..c811e3f9c30 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,7 @@
"main": "./out/main/index.js",
"scripts": {
"format": "oxfmt --write .",
- "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage",
+ "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage",
"lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json",
"lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs",
"lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet",
@@ -21,6 +21,10 @@
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
"check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs",
+ "generate:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs --write",
+ "bootstrap:skill-release-history": "node config/scripts/generate-skill-bundle-manifest.mjs --write --record-release --bootstrap-release-history",
+ "record-release:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs --write --record-release",
+ "verify:skill-bundle-manifest": "node config/scripts/generate-skill-bundle-manifest.mjs",
"verify:macos-entitlements": "node config/scripts/verify-macos-entitlements.mjs",
"vendor:feature-wall-assets": "node config/scripts/vendor-feature-wall-assets.mjs",
"tc:node": "pnpm run typecheck:node",
diff --git a/resources/skills/current-manifest.json b/resources/skills/current-manifest.json
new file mode 100644
index 00000000000..a580f3592a9
--- /dev/null
+++ b/resources/skills/current-manifest.json
@@ -0,0 +1,158 @@
+{
+ "schemaVersion": 1,
+ "appVersion": "1.4.138-rc.2",
+ "skills": [
+ {
+ "name": "computer-use",
+ "sourcePath": "skills/computer-use",
+ "appVersion": "1.4.138-rc.2",
+ "releaseRevision": 4,
+ "packageDigest": "35b6d91d1d3d38f3828c60e5b856a91324bdd09273db33664f508392c5c48dfc",
+ "gitTreeSha": "b4e2d7e7af8f8f29c6d0df6e92f23c1b2c439e3b",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 10625,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b",
+ "textNormalizedSha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b",
+ "identitySha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b"
+ }
+ ]
+ },
+ {
+ "name": "linear-tickets",
+ "sourcePath": "skills/linear-tickets",
+ "appVersion": "1.4.138-rc.2",
+ "releaseRevision": 4,
+ "packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b",
+ "gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 10596,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5",
+ "textNormalizedSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5",
+ "identitySha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5"
+ }
+ ]
+ },
+ {
+ "name": "orca-cli",
+ "sourcePath": "skills/orca-cli",
+ "appVersion": "1.4.138-rc.2",
+ "releaseRevision": 31,
+ "packageDigest": "c52a999a35f8e96ec72c97a537ce5906ef098879495382d85f50c70a888ba901",
+ "gitTreeSha": "77be03ea57ddbc4c4067bca22ab58c3af5ae3d2c",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 20862,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14",
+ "textNormalizedSha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14",
+ "identitySha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14"
+ }
+ ]
+ },
+ {
+ "name": "orca-emulator",
+ "sourcePath": "skills/orca-emulator",
+ "appVersion": "1.4.138-rc.2",
+ "releaseRevision": 3,
+ "packageDigest": "1807d287d2c08b1c535ba4e14ca2f29afe39274c4bde5737995a1792e44d5412",
+ "gitTreeSha": "dbb8e0daba768527885715ee5efebeaa1ee38f0f",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 10853,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834",
+ "textNormalizedSha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834",
+ "identitySha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834"
+ }
+ ]
+ },
+ {
+ "name": "orca-emulator-android",
+ "sourcePath": "skills/orca-emulator-android",
+ "appVersion": "1.4.138-rc.2",
+ "releaseRevision": 1,
+ "packageDigest": "cf86dd085b981febd8e4e11b7796c90d69a60bd9c1dfaab5a16b99f77841aaa4",
+ "gitTreeSha": "6299122f69f0218ec01214e2a14c479b1811cdf6",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 8189,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844",
+ "textNormalizedSha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844",
+ "identitySha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844"
+ }
+ ]
+ },
+ {
+ "name": "orca-linear",
+ "sourcePath": "skills/orca-linear",
+ "appVersion": "1.4.138-rc.2",
+ "releaseRevision": 2,
+ "packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f",
+ "gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 10320,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5",
+ "textNormalizedSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5",
+ "identitySha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5"
+ }
+ ]
+ },
+ {
+ "name": "orca-per-workspace-env",
+ "sourcePath": "skills/orca-per-workspace-env",
+ "appVersion": "1.4.138-rc.2",
+ "releaseRevision": 2,
+ "packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d",
+ "gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 43769,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7",
+ "textNormalizedSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7",
+ "identitySha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7"
+ }
+ ]
+ },
+ {
+ "name": "orchestration",
+ "sourcePath": "skills/orchestration",
+ "appVersion": "1.4.138-rc.2",
+ "releaseRevision": 23,
+ "packageDigest": "dab5cc1bf87b12838a53dda90f1f4f35d1f77da66bcc63840574531eff600d2b",
+ "gitTreeSha": "f7a017f3e1f14dafed0c59717bec37c022075be2",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 22850,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9",
+ "textNormalizedSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9",
+ "identitySha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9"
+ }
+ ]
+ }
+ ]
+}
diff --git a/resources/skills/release-mapping.json b/resources/skills/release-mapping.json
new file mode 100644
index 00000000000..076d6aed0f3
--- /dev/null
+++ b/resources/skills/release-mapping.json
@@ -0,0 +1,475 @@
+{
+ "schemaVersion": 1,
+ "releases": [
+ {
+ "appVersion": "1.0.81",
+ "skills": {
+ "orca-cli": 1
+ }
+ },
+ {
+ "appVersion": "1.0.86",
+ "skills": {
+ "orca-cli": 2
+ }
+ },
+ {
+ "appVersion": "1.0.94-rc.0",
+ "skills": {
+ "orca-cli": 3
+ }
+ },
+ {
+ "appVersion": "1.3.8-rc.1",
+ "skills": {
+ "orca-cli": 4
+ }
+ },
+ {
+ "appVersion": "1.3.12-rc.4",
+ "skills": {
+ "orca-cli": 5
+ }
+ },
+ {
+ "appVersion": "1.3.24-rc.2",
+ "skills": {
+ "orca-cli": 6,
+ "orchestration": 1
+ }
+ },
+ {
+ "appVersion": "1.3.33-rc.3",
+ "skills": {
+ "mobile-fit-debug": 1,
+ "orca-cli": 6,
+ "orchestration": 1
+ }
+ },
+ {
+ "appVersion": "0.0.1",
+ "skills": {
+ "orca-cli": 6,
+ "orchestration": 1
+ }
+ },
+ {
+ "appVersion": "1.3.35-rc.0",
+ "skills": {
+ "mobile-fit-debug": 1,
+ "orca-cli": 6,
+ "orchestration": 1
+ }
+ },
+ {
+ "appVersion": "1.3.38-rc.3",
+ "skills": {
+ "orca-cli": 6,
+ "orchestration": 1
+ }
+ },
+ {
+ "appVersion": "1.3.38",
+ "skills": {
+ "mobile-fit-debug": 1,
+ "orca-cli": 6,
+ "orchestration": 1
+ }
+ },
+ {
+ "appVersion": "1.3.39",
+ "skills": {
+ "orca-cli": 6,
+ "orchestration": 1
+ }
+ },
+ {
+ "appVersion": "1.3.46-rc.3",
+ "skills": {
+ "orca-cli": 7,
+ "orchestration": 2
+ }
+ },
+ {
+ "appVersion": "1.3.47-rc.0",
+ "skills": {
+ "orca-cli": 8,
+ "orchestration": 3
+ }
+ },
+ {
+ "appVersion": "1.3.49-rc.0",
+ "skills": {
+ "computer-use": 1,
+ "orca-cli": 8,
+ "orchestration": 3
+ }
+ },
+ {
+ "appVersion": "1.4.2-rc.10",
+ "skills": {
+ "computer-use": 1,
+ "orca-cli": 9,
+ "orchestration": 4
+ }
+ },
+ {
+ "appVersion": "1.4.2",
+ "skills": {
+ "computer-use": 1,
+ "orca-cli": 10,
+ "orchestration": 5
+ }
+ },
+ {
+ "appVersion": "1.4.3-rc.0",
+ "skills": {
+ "computer-use": 1,
+ "orca-cli": 11,
+ "orchestration": 6
+ }
+ },
+ {
+ "appVersion": "1.4.9-rc.1",
+ "skills": {
+ "computer-use": 1,
+ "orca-cli": 12,
+ "orchestration": 6
+ }
+ },
+ {
+ "appVersion": "1.4.10-rc.3",
+ "skills": {
+ "computer-use": 1,
+ "orca-cli": 13,
+ "orchestration": 6
+ }
+ },
+ {
+ "appVersion": "1.4.11-rc.0",
+ "skills": {
+ "computer-use": 1,
+ "orca-cli": 14,
+ "orchestration": 6
+ }
+ },
+ {
+ "appVersion": "1.4.18-rc.2",
+ "skills": {
+ "computer-use": 1,
+ "orca-cli": 15,
+ "orchestration": 6
+ }
+ },
+ {
+ "appVersion": "1.4.18-rc.3",
+ "skills": {
+ "computer-use": 1,
+ "orca-cli": 16,
+ "orchestration": 6
+ }
+ },
+ {
+ "appVersion": "1.4.22",
+ "skills": {
+ "computer-use": 1,
+ "orca-cli": 16,
+ "orchestration": 7
+ }
+ },
+ {
+ "appVersion": "1.4.36-rc.2",
+ "skills": {
+ "computer-use": 1,
+ "orca-cli": 17,
+ "orchestration": 8
+ }
+ },
+ {
+ "appVersion": "1.4.36-rc.6",
+ "skills": {
+ "computer-use": 1,
+ "orca-cli": 18,
+ "orchestration": 9
+ }
+ },
+ {
+ "appVersion": "1.4.42-rc.8",
+ "skills": {
+ "computer-use": 2,
+ "orca-cli": 19,
+ "orchestration": 10
+ }
+ },
+ {
+ "appVersion": "1.4.46-rc.0",
+ "skills": {
+ "computer-use": 2,
+ "orca-cli": 19,
+ "orchestration": 11
+ }
+ },
+ {
+ "appVersion": "1.4.51-rc.8",
+ "skills": {
+ "computer-use": 3,
+ "orca-cli": 19,
+ "orchestration": 11
+ }
+ },
+ {
+ "appVersion": "1.4.51-rc.9",
+ "skills": {
+ "computer-use": 3,
+ "orca-cli": 20,
+ "orca-emulator": 1,
+ "orchestration": 12
+ }
+ },
+ {
+ "appVersion": "1.4.65-rc.3",
+ "skills": {
+ "computer-use": 3,
+ "linear-tickets": 1,
+ "orca-cli": 20,
+ "orca-emulator": 1,
+ "orchestration": 12
+ }
+ },
+ {
+ "appVersion": "1.4.65",
+ "skills": {
+ "computer-use": 3,
+ "orca-cli": 20,
+ "orca-emulator": 1,
+ "orchestration": 12
+ }
+ },
+ {
+ "appVersion": "1.4.66-rc.0",
+ "skills": {
+ "computer-use": 3,
+ "linear-tickets": 1,
+ "orca-cli": 20,
+ "orca-emulator": 1,
+ "orchestration": 12
+ }
+ },
+ {
+ "appVersion": "1.4.67-rc.0",
+ "skills": {
+ "computer-use": 3,
+ "linear-tickets": 2,
+ "orca-cli": 20,
+ "orca-emulator": 1,
+ "orchestration": 12
+ }
+ },
+ {
+ "appVersion": "1.4.68",
+ "skills": {
+ "computer-use": 3,
+ "linear-tickets": 2,
+ "orca-cli": 20,
+ "orca-emulator": 1,
+ "orchestration": 13
+ }
+ },
+ {
+ "appVersion": "1.4.74-rc.0",
+ "skills": {
+ "computer-use": 3,
+ "linear-tickets": 2,
+ "orca-cli": 21,
+ "orca-emulator": 1,
+ "orchestration": 14
+ }
+ },
+ {
+ "appVersion": "1.4.78-rc.1",
+ "skills": {
+ "computer-use": 3,
+ "linear-tickets": 2,
+ "orca-cli": 22,
+ "orca-emulator": 1,
+ "orchestration": 15
+ }
+ },
+ {
+ "appVersion": "1.4.78-rc.2",
+ "skills": {
+ "computer-use": 3,
+ "linear-tickets": 2,
+ "orca-cli": 23,
+ "orca-emulator": 1,
+ "orchestration": 16
+ }
+ },
+ {
+ "appVersion": "1.4.81-rc.2",
+ "skills": {
+ "computer-use": 3,
+ "linear-tickets": 2,
+ "orca-cli": 24,
+ "orca-emulator": 1,
+ "orchestration": 16
+ }
+ },
+ {
+ "appVersion": "1.4.81-rc.3",
+ "skills": {
+ "computer-use": 3,
+ "linear-tickets": 2,
+ "orca-cli": 25,
+ "orca-emulator": 1,
+ "orchestration": 16
+ }
+ },
+ {
+ "appVersion": "1.4.88",
+ "skills": {
+ "computer-use": 3,
+ "linear-tickets": 2,
+ "orca-cli": 26,
+ "orca-emulator": 1,
+ "orchestration": 17
+ }
+ },
+ {
+ "appVersion": "1.4.90",
+ "skills": {
+ "computer-use": 3,
+ "linear-tickets": 3,
+ "orca-cli": 26,
+ "orca-emulator": 1,
+ "orca-linear": 1,
+ "orchestration": 17
+ }
+ },
+ {
+ "appVersion": "1.4.92-rc.1",
+ "skills": {
+ "computer-use": 3,
+ "linear-tickets": 3,
+ "orca-cli": 27,
+ "orca-emulator": 1,
+ "orca-linear": 1,
+ "orchestration": 17
+ }
+ },
+ {
+ "appVersion": "1.4.96-rc.1",
+ "skills": {
+ "computer-use": 3,
+ "linear-tickets": 3,
+ "orca-cli": 28,
+ "orca-emulator": 1,
+ "orca-linear": 1,
+ "orchestration": 17
+ }
+ },
+ {
+ "appVersion": "1.4.105-rc.2",
+ "skills": {
+ "computer-use": 3,
+ "linear-tickets": 3,
+ "orca-cli": 29,
+ "orca-emulator": 2,
+ "orca-emulator-android": 1,
+ "orca-linear": 1,
+ "orchestration": 18
+ }
+ },
+ {
+ "appVersion": "1.4.111-rc.0",
+ "skills": {
+ "computer-use": 3,
+ "linear-tickets": 3,
+ "orca-cli": 29,
+ "orca-emulator": 2,
+ "orca-emulator-android": 1,
+ "orca-linear": 1,
+ "orca-per-workspace-env": 1,
+ "orchestration": 18
+ }
+ },
+ {
+ "appVersion": "1.4.124-rc.6",
+ "skills": {
+ "computer-use": 3,
+ "linear-tickets": 3,
+ "orca-cli": 29,
+ "orca-emulator": 2,
+ "orca-emulator-android": 1,
+ "orca-linear": 1,
+ "orca-per-workspace-env": 2,
+ "orchestration": 18
+ }
+ },
+ {
+ "appVersion": "1.4.124-rc.9",
+ "skills": {
+ "computer-use": 3,
+ "linear-tickets": 3,
+ "orca-cli": 29,
+ "orca-emulator": 2,
+ "orca-emulator-android": 1,
+ "orca-linear": 1,
+ "orca-per-workspace-env": 2,
+ "orchestration": 19
+ }
+ },
+ {
+ "appVersion": "1.4.124",
+ "skills": {
+ "computer-use": 3,
+ "linear-tickets": 4,
+ "orca-cli": 29,
+ "orca-emulator": 2,
+ "orca-emulator-android": 1,
+ "orca-linear": 2,
+ "orca-per-workspace-env": 2,
+ "orchestration": 20
+ }
+ },
+ {
+ "appVersion": "1.4.137-rc.1",
+ "skills": {
+ "computer-use": 3,
+ "linear-tickets": 4,
+ "orca-cli": 30,
+ "orca-emulator": 2,
+ "orca-emulator-android": 1,
+ "orca-linear": 2,
+ "orca-per-workspace-env": 2,
+ "orchestration": 21
+ }
+ },
+ {
+ "appVersion": "1.4.138-rc.0",
+ "skills": {
+ "computer-use": 4,
+ "linear-tickets": 4,
+ "orca-cli": 31,
+ "orca-emulator": 3,
+ "orca-emulator-android": 1,
+ "orca-linear": 2,
+ "orca-per-workspace-env": 2,
+ "orchestration": 22
+ }
+ },
+ {
+ "appVersion": "1.4.138-rc.2",
+ "skills": {
+ "computer-use": 4,
+ "linear-tickets": 4,
+ "orca-cli": 31,
+ "orca-emulator": 3,
+ "orca-emulator-android": 1,
+ "orca-linear": 2,
+ "orca-per-workspace-env": 2,
+ "orchestration": 23
+ }
+ }
+ ]
+}
diff --git a/resources/skills/snapshot-registry.json b/resources/skills/snapshot-registry.json
new file mode 100644
index 00000000000..87601a19487
--- /dev/null
+++ b/resources/skills/snapshot-registry.json
@@ -0,0 +1,1159 @@
+{
+ "schemaVersion": 1,
+ "skills": {
+ "orca-cli": [
+ {
+ "releaseRevision": 1,
+ "packageDigest": "a3831a8a5561b24f085cb3c185c23ccc368970898119d90723aa3406778aecb6",
+ "gitTreeSha": "aed90e508a1acac62d3f22ceba3afa5489cffb84",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 5547,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "5a3111c5a5b0edcf53217a3b93a9dafd9ee6dbc77eaa78ab850e3706d27fb3b1",
+ "textNormalizedSha256": "5a3111c5a5b0edcf53217a3b93a9dafd9ee6dbc77eaa78ab850e3706d27fb3b1",
+ "identitySha256": "5a3111c5a5b0edcf53217a3b93a9dafd9ee6dbc77eaa78ab850e3706d27fb3b1"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 2,
+ "packageDigest": "e0ed74622f5f77a6339f6f2632dc6edb0b724c7f69a4d2ffecaa45bd0995865a",
+ "gitTreeSha": "d9d879b3040843ae434462db46939b1eac410304",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 5850,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "6f85d7a0d4dde6bf8310d1db6373865143053aa97fbe69f7a3f96fc2be697aeb",
+ "textNormalizedSha256": "6f85d7a0d4dde6bf8310d1db6373865143053aa97fbe69f7a3f96fc2be697aeb",
+ "identitySha256": "6f85d7a0d4dde6bf8310d1db6373865143053aa97fbe69f7a3f96fc2be697aeb"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 3,
+ "packageDigest": "0032941edd38a56de97a56f50c7688e38bcb566735310a9321bb8a7959a06878",
+ "gitTreeSha": "57ab93e99a12dc947d4329ef7d1227d24bf3dd26",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 8871,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "3637f50e20b0305d1b5b5962d117f121bd2323651023e74c6dfd1a2aa170f681",
+ "textNormalizedSha256": "3637f50e20b0305d1b5b5962d117f121bd2323651023e74c6dfd1a2aa170f681",
+ "identitySha256": "3637f50e20b0305d1b5b5962d117f121bd2323651023e74c6dfd1a2aa170f681"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 4,
+ "packageDigest": "d30e6b53ff35008ffe467e2557326326575ac5d37fab88019b1d8c2d6fb8eeba",
+ "gitTreeSha": "f43280abdf0e484966ce840600a6cae4963f9d64",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 24622,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56",
+ "textNormalizedSha256": "baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56",
+ "identitySha256": "baa87d5188d2cab8f0ffcfa694fdcbd6ca745c4b0690851241bfd81c2b038e56"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 5,
+ "packageDigest": "1f2ae2e3922a93e020feebfa799b993557fead2e8c83ce03b5646767ea354f19",
+ "gitTreeSha": "f8a840864ad01f1c3892a1bf05fb699c5f4543e5",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 26576,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "5f7a6cdad62ebee25adad2a9e76d9623bee690846eaf8c7e1739b27c4a9e0efc",
+ "textNormalizedSha256": "5f7a6cdad62ebee25adad2a9e76d9623bee690846eaf8c7e1739b27c4a9e0efc",
+ "identitySha256": "5f7a6cdad62ebee25adad2a9e76d9623bee690846eaf8c7e1739b27c4a9e0efc"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 6,
+ "packageDigest": "6e77196af8e6b9123861d7f09c0727e3f982722940eaff2ca53521d76134362f",
+ "gitTreeSha": "35f50f11a68d2f8f532df28d677ba1b0bc71b740",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 26970,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "640374995d830fd3607e3b162d7bf03237b8ee9afb846b8a753e45c91c8e322a",
+ "textNormalizedSha256": "640374995d830fd3607e3b162d7bf03237b8ee9afb846b8a753e45c91c8e322a",
+ "identitySha256": "640374995d830fd3607e3b162d7bf03237b8ee9afb846b8a753e45c91c8e322a"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 7,
+ "packageDigest": "d15b79aee7d15f1cc089467284f2454a8ad4f351c08e1e3758e8c58fdcf07b02",
+ "gitTreeSha": "d7788dc3cd1f1ed705981299c7b8ff9b26cdd5e6",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 27011,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "28105318e676de18e27389002fdbe1b33c3c27f8c001ba82d769cef4adde1ef1",
+ "textNormalizedSha256": "28105318e676de18e27389002fdbe1b33c3c27f8c001ba82d769cef4adde1ef1",
+ "identitySha256": "28105318e676de18e27389002fdbe1b33c3c27f8c001ba82d769cef4adde1ef1"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 8,
+ "packageDigest": "6821844c4336d2673c85c968efd8cab14b4e34b8c798e4f11d6afb67b65f12b8",
+ "gitTreeSha": "a0f98aea5ecdafae6dff25d31d0925a4affae774",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 27038,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347",
+ "textNormalizedSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347",
+ "identitySha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 9,
+ "packageDigest": "7486314542c03f682e7640ad61825cc709004e8c076c2971f707bdf839018233",
+ "gitTreeSha": "c556268a21e785b01be2dfb7cd0988100c7d7cde",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 27197,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e",
+ "textNormalizedSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e",
+ "identitySha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 10,
+ "packageDigest": "6821844c4336d2673c85c968efd8cab14b4e34b8c798e4f11d6afb67b65f12b8",
+ "gitTreeSha": "a0f98aea5ecdafae6dff25d31d0925a4affae774",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 27038,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347",
+ "textNormalizedSha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347",
+ "identitySha256": "92f645b9db8bef6eb1278e87622cb18d5e146254039fc927a1f37313f32bf347"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 11,
+ "packageDigest": "7486314542c03f682e7640ad61825cc709004e8c076c2971f707bdf839018233",
+ "gitTreeSha": "c556268a21e785b01be2dfb7cd0988100c7d7cde",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 27197,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e",
+ "textNormalizedSha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e",
+ "identitySha256": "80f8b98301c0d4c0cce6d522d56ae248312d6a386a17b275078c618cc7ddaa5e"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 12,
+ "packageDigest": "962904e301eef6917af25eccbfe74ab8a119503749030e44c67fd34415d85002",
+ "gitTreeSha": "4fde9a02a5a77e7ff1a47c04819b764378557a28",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 28908,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137",
+ "textNormalizedSha256": "f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137",
+ "identitySha256": "f3e3112e666d3735acac3e41b768fcb7be3e2daf692e8c03b481a50798783137"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 13,
+ "packageDigest": "d73f45feb6ccf88947aba3095e9ae36c2c02e9fb4dfeaba67f3f8e3af1cb6f74",
+ "gitTreeSha": "b5c98b55e0b10e4525d63f5a8df3bc95b62158e6",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 30358,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "5d5a5c630b581fe2e3ae047baddd795434dbb3ecb4ff6e1384fcd60ead665d07",
+ "textNormalizedSha256": "5d5a5c630b581fe2e3ae047baddd795434dbb3ecb4ff6e1384fcd60ead665d07",
+ "identitySha256": "5d5a5c630b581fe2e3ae047baddd795434dbb3ecb4ff6e1384fcd60ead665d07"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 14,
+ "packageDigest": "d1adae602b254aa2de6d65d09066ca1dcd9e89685bc1ba7c72e9964beb3f6539",
+ "gitTreeSha": "b545ca22a27b8cfff80fd2d33c77db8b03efd7ed",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 30787,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "41451300db3ebaea51931b325aea20195923d631125588614fd1be5ebcfdcdc1",
+ "textNormalizedSha256": "41451300db3ebaea51931b325aea20195923d631125588614fd1be5ebcfdcdc1",
+ "identitySha256": "41451300db3ebaea51931b325aea20195923d631125588614fd1be5ebcfdcdc1"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 15,
+ "packageDigest": "23a6bb7a6069a8c4d6506aaa688a44c8421699e3575bb8c73185561579c1f623",
+ "gitTreeSha": "8b7caa9655971745a8ea782f6d05d76ab0f3812c",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 31985,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "2ada72dac2e503723f3046433995756549fd1e44d445ec4d13c8f01764e6304d",
+ "textNormalizedSha256": "2ada72dac2e503723f3046433995756549fd1e44d445ec4d13c8f01764e6304d",
+ "identitySha256": "2ada72dac2e503723f3046433995756549fd1e44d445ec4d13c8f01764e6304d"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 16,
+ "packageDigest": "c882bcd1293ef6f601e7f48199f6e755692f668419f8c2098e00757d9349d557",
+ "gitTreeSha": "22de521546df142d7fd540dd3a329462c548e86b",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 32276,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "7ef384a2687f655acd0cc40be40ea5a7df46a0e6d6bdb1ea3d43d9d7ae25778c",
+ "textNormalizedSha256": "7ef384a2687f655acd0cc40be40ea5a7df46a0e6d6bdb1ea3d43d9d7ae25778c",
+ "identitySha256": "7ef384a2687f655acd0cc40be40ea5a7df46a0e6d6bdb1ea3d43d9d7ae25778c"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 17,
+ "packageDigest": "a1bfdab91a68b3441b548de158e2c6a56452a499600625c6a9a3523d325837ec",
+ "gitTreeSha": "c694b183230bface19ef8ec9218726f82e391f69",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 32853,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "ad2bbe6f75cc98ce09781ed1345f09d1fc3f2042c4f9a5c5f75032ca149a2672",
+ "textNormalizedSha256": "ad2bbe6f75cc98ce09781ed1345f09d1fc3f2042c4f9a5c5f75032ca149a2672",
+ "identitySha256": "ad2bbe6f75cc98ce09781ed1345f09d1fc3f2042c4f9a5c5f75032ca149a2672"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 18,
+ "packageDigest": "afb5026bf156e8d411898ecc84c48348010129558499a18240194b0d5533028c",
+ "gitTreeSha": "3b9164e0fba50923c9ce3a4a298a9c8b81677291",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 33310,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "320539431c03434355a2186095f782444a4dbec5adbcb8560cebcea3ed49fe1f",
+ "textNormalizedSha256": "320539431c03434355a2186095f782444a4dbec5adbcb8560cebcea3ed49fe1f",
+ "identitySha256": "320539431c03434355a2186095f782444a4dbec5adbcb8560cebcea3ed49fe1f"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 19,
+ "packageDigest": "2c07a8155f0a0f3fdc6a038c25e0758486dc2ce19d8e0f5bb19d4722f6acbae7",
+ "gitTreeSha": "dc8ef30fef07f81167e948a9bfc90d14670ea10c",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 11055,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "9d10496ccc998f58472e902b4131910d7bde48f305f70a400c211deecd670c0b",
+ "textNormalizedSha256": "9d10496ccc998f58472e902b4131910d7bde48f305f70a400c211deecd670c0b",
+ "identitySha256": "9d10496ccc998f58472e902b4131910d7bde48f305f70a400c211deecd670c0b"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 20,
+ "packageDigest": "6f57bb7833fd4e6acf743921ca25fd57bb51df76caa8ea2aea98c149e20f22e1",
+ "gitTreeSha": "535bf7b62e1e374adb380e16f837a3c575e302d4",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 13423,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea",
+ "textNormalizedSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea",
+ "identitySha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 21,
+ "packageDigest": "ce5459d22254faf23a3af527a22fa8981f1c3f88afe48487a9fd79c44d0729b9",
+ "gitTreeSha": "de4999bb21e7009bf1e453d593e7726d917d3fc4",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 13764,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c",
+ "textNormalizedSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c",
+ "identitySha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 22,
+ "packageDigest": "6f57bb7833fd4e6acf743921ca25fd57bb51df76caa8ea2aea98c149e20f22e1",
+ "gitTreeSha": "535bf7b62e1e374adb380e16f837a3c575e302d4",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 13423,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea",
+ "textNormalizedSha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea",
+ "identitySha256": "aa9307c3db1e8d96870955fa2f6260b220e840401ce7ea9c034021658601a3ea"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 23,
+ "packageDigest": "ce5459d22254faf23a3af527a22fa8981f1c3f88afe48487a9fd79c44d0729b9",
+ "gitTreeSha": "de4999bb21e7009bf1e453d593e7726d917d3fc4",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 13764,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c",
+ "textNormalizedSha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c",
+ "identitySha256": "879f328595d4148a5b0dba9dd3cc31c4d30740cb574d754f38ee82f9d1cc782c"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 24,
+ "packageDigest": "30b067d09f9f9ee9c048f0b3d809c25e269fd46cc948486cdb66690205ae8c4b",
+ "gitTreeSha": "833e5ce46df5bf55d29fbbd3bd710aa2dfe6b5c4",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 14298,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "a1a98ff994d134217b32ccd13130a1d0af44db9df0c90a8ea7c1e46b5092603f",
+ "textNormalizedSha256": "a1a98ff994d134217b32ccd13130a1d0af44db9df0c90a8ea7c1e46b5092603f",
+ "identitySha256": "a1a98ff994d134217b32ccd13130a1d0af44db9df0c90a8ea7c1e46b5092603f"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 25,
+ "packageDigest": "2ae59c0bea7f2a12ef0c78e3cba65895ff67b311b40b97b46223460b4d1a7acd",
+ "gitTreeSha": "ba4e183d53b70fd01b763a9cc19f757550a18e2e",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 14320,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4",
+ "textNormalizedSha256": "ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4",
+ "identitySha256": "ed3994def086f5a8aeecfee545116d1f8fa4ef46b48656dfa797a507a7a078a4"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 26,
+ "packageDigest": "5d7bdc664990cd84771eceea9393b0a566aee4ad9cdbf0883910cd8d4d46d5ac",
+ "gitTreeSha": "6b459e105bb77cbfa67ba0086f3afcbe32e12093",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 14636,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "86f75826ec0ed735a3363fe6b17c7b29431f1ddcf16736398bd38ee6a7e9c7ce",
+ "textNormalizedSha256": "86f75826ec0ed735a3363fe6b17c7b29431f1ddcf16736398bd38ee6a7e9c7ce",
+ "identitySha256": "86f75826ec0ed735a3363fe6b17c7b29431f1ddcf16736398bd38ee6a7e9c7ce"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 27,
+ "packageDigest": "94f535ede189381ae1cf1a7cf6effee03335b2e98e9bdf57fed72dcba39036e3",
+ "gitTreeSha": "1b02d6a7c06465e3ba6661bef201c6db6552c9e6",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 14829,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "7b1b746d4f080e0ac9a4b30f1fcb32f24433efccf70e03b66342a07cac54c835",
+ "textNormalizedSha256": "7b1b746d4f080e0ac9a4b30f1fcb32f24433efccf70e03b66342a07cac54c835",
+ "identitySha256": "7b1b746d4f080e0ac9a4b30f1fcb32f24433efccf70e03b66342a07cac54c835"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 28,
+ "packageDigest": "d70d2e70a9ac4ae8cf8ec5cb958524521842d558e40ea24dd882b90a846a2cdc",
+ "gitTreeSha": "3d734f7f385d66ab408c56e8814b9c115d53556c",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 15055,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "399d3e63eb8db02632870428e9465866787540599a39e168516a30621fa84995",
+ "textNormalizedSha256": "399d3e63eb8db02632870428e9465866787540599a39e168516a30621fa84995",
+ "identitySha256": "399d3e63eb8db02632870428e9465866787540599a39e168516a30621fa84995"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 29,
+ "packageDigest": "ca9d1d5e2ad2dc6814be80af642c5fb485644713e9bd7ef19acd36d4228b94c5",
+ "gitTreeSha": "d1175a5a5807a6783a3d896f720645b39f736dbb",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 17257,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "65dddf473219f4324d6b81ece2af29d512948dd9ebd67f15b424a1b205ea814e",
+ "textNormalizedSha256": "65dddf473219f4324d6b81ece2af29d512948dd9ebd67f15b424a1b205ea814e",
+ "identitySha256": "65dddf473219f4324d6b81ece2af29d512948dd9ebd67f15b424a1b205ea814e"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 30,
+ "packageDigest": "4994ac99e6563e53f85c404d52470ab05945679ecb3e0f6a1ac4dbba83d70b35",
+ "gitTreeSha": "91c0234c3e64dfcf45ac1515ecc77c973a7af185",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 19660,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "236008f441d16f1d4c92681776798b787dc67357f9110dac11d8b0191b00a368",
+ "textNormalizedSha256": "236008f441d16f1d4c92681776798b787dc67357f9110dac11d8b0191b00a368",
+ "identitySha256": "236008f441d16f1d4c92681776798b787dc67357f9110dac11d8b0191b00a368"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 31,
+ "packageDigest": "c52a999a35f8e96ec72c97a537ce5906ef098879495382d85f50c70a888ba901",
+ "gitTreeSha": "77be03ea57ddbc4c4067bca22ab58c3af5ae3d2c",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 20862,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14",
+ "textNormalizedSha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14",
+ "identitySha256": "4304a86de2844899f730650c48a4ac7db812bc9f3bfb0c447d5e6415139abe14"
+ }
+ ]
+ }
+ ],
+ "orchestration": [
+ {
+ "releaseRevision": 1,
+ "packageDigest": "427a9ae737ae258ccf2c1f7659549da1b6b00a65c1fad8ac9c6baf4f42af8c4e",
+ "gitTreeSha": "13840d1ad238a462bdc34f7fca11cb4d224b514c",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 12589,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "e4aa2bde868fdb45f905189361ab09e613ea9d83875ba7e951b6f3addfb58c75",
+ "textNormalizedSha256": "e4aa2bde868fdb45f905189361ab09e613ea9d83875ba7e951b6f3addfb58c75",
+ "identitySha256": "e4aa2bde868fdb45f905189361ab09e613ea9d83875ba7e951b6f3addfb58c75"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 2,
+ "packageDigest": "122aa952ff591cddbda47767a7de738b05dbeb0c5d26065010bcb6b788ef329d",
+ "gitTreeSha": "2c0e80be2d5ff6760f461711179d9fd94a7454b2",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 12972,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "68761e4b247b7a5bc22087eb4d801287e120b9a3746054b50c96d9f5e7e0bcca",
+ "textNormalizedSha256": "68761e4b247b7a5bc22087eb4d801287e120b9a3746054b50c96d9f5e7e0bcca",
+ "identitySha256": "68761e4b247b7a5bc22087eb4d801287e120b9a3746054b50c96d9f5e7e0bcca"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 3,
+ "packageDigest": "8e8ce7fc8dc644841bb9a62426eb4bf3e0ca15dd49c33f23d442ec0b2b72aaab",
+ "gitTreeSha": "2775e9a44fec9135321fdf92051c34a4bc9214ff",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 13003,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee",
+ "textNormalizedSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee",
+ "identitySha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 4,
+ "packageDigest": "10d51bc7f30b924ac32f05a16352c9b2172e21082db81562316c7d7d897de049",
+ "gitTreeSha": "a75a8a7124054ebfa2898c13a399e8fb6578bbb0",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 13167,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059",
+ "textNormalizedSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059",
+ "identitySha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 5,
+ "packageDigest": "8e8ce7fc8dc644841bb9a62426eb4bf3e0ca15dd49c33f23d442ec0b2b72aaab",
+ "gitTreeSha": "2775e9a44fec9135321fdf92051c34a4bc9214ff",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 13003,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee",
+ "textNormalizedSha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee",
+ "identitySha256": "f3bae76495657f27458969fe2d6510098f39b429580870f5e79fac3493f891ee"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 6,
+ "packageDigest": "10d51bc7f30b924ac32f05a16352c9b2172e21082db81562316c7d7d897de049",
+ "gitTreeSha": "a75a8a7124054ebfa2898c13a399e8fb6578bbb0",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 13167,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059",
+ "textNormalizedSha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059",
+ "identitySha256": "abb639ec034ab3f50fddf3dc02c4a041876c34278f529ec2c044c0cda7b74059"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 7,
+ "packageDigest": "0c9848cc3d1e135f6d0105c49ec6f16a126e675125408744d9e6c940645fd427",
+ "gitTreeSha": "d12449bc4ba94699a9c9ec636a15ad3b6f095bfd",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 12749,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "6c38221cc0c280320264dbcba35af1371b3bf525d430a9d6b7a21c33bcda2223",
+ "textNormalizedSha256": "6c38221cc0c280320264dbcba35af1371b3bf525d430a9d6b7a21c33bcda2223",
+ "identitySha256": "6c38221cc0c280320264dbcba35af1371b3bf525d430a9d6b7a21c33bcda2223"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 8,
+ "packageDigest": "fbb016bd4ec67677fea3ffa16aacc2e95d13968b33520409385bef5d1a025c16",
+ "gitTreeSha": "413ae24e6011f86372aa44c8611d8fe76d936f8a",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 12795,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "9248461f4224081452ef8aed56748a456b99b79fb3f78a525857f8d12e44f7f1",
+ "textNormalizedSha256": "9248461f4224081452ef8aed56748a456b99b79fb3f78a525857f8d12e44f7f1",
+ "identitySha256": "9248461f4224081452ef8aed56748a456b99b79fb3f78a525857f8d12e44f7f1"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 9,
+ "packageDigest": "cb991258e829f2a1256bcaf4f90018046d2bc91b5fba301bd12b046e9e2e6e02",
+ "gitTreeSha": "820faebf9aa70e7c1792909ceb9fbf35db93795a",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 15044,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "ee559224b6047d01db02d9aabdfbcc94475394e6f11f4076ce785c42faee63c6",
+ "textNormalizedSha256": "ee559224b6047d01db02d9aabdfbcc94475394e6f11f4076ce785c42faee63c6",
+ "identitySha256": "ee559224b6047d01db02d9aabdfbcc94475394e6f11f4076ce785c42faee63c6"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 10,
+ "packageDigest": "0d8470040c3c15570fe2745fc159b5ff57b5a229ec4e27ea600a8ced594c0455",
+ "gitTreeSha": "5113d720ff1a96ed158befcb8752f3a26aadd161",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 10338,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "d37fa68601e6b383146976baf4ac28b1ecc8acef808394e9cede434a08be82ce",
+ "textNormalizedSha256": "d37fa68601e6b383146976baf4ac28b1ecc8acef808394e9cede434a08be82ce",
+ "identitySha256": "d37fa68601e6b383146976baf4ac28b1ecc8acef808394e9cede434a08be82ce"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 11,
+ "packageDigest": "9008c30bdf8a20857642968753013bba4961a6297b02d31afe65105606b0bcfd",
+ "gitTreeSha": "993c9e1861ad97047e34ab82e5dbd045e888a4a5",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 10348,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "3899a8a6da1d7a962e1029d002f7187058543e47a230b6459756a41bc08af4a7",
+ "textNormalizedSha256": "3899a8a6da1d7a962e1029d002f7187058543e47a230b6459756a41bc08af4a7",
+ "identitySha256": "3899a8a6da1d7a962e1029d002f7187058543e47a230b6459756a41bc08af4a7"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 12,
+ "packageDigest": "c2a45891a381db4376a61f2e0f170cd512883e0f817a5c28266b744e04d00006",
+ "gitTreeSha": "2f7404841129a80c8b9add53c761f0ff11174dae",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 10973,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "4bb49dd8fb3062af4424f912909d52914f377e6dd3fcd12ed5296e7781bdabe4",
+ "textNormalizedSha256": "4bb49dd8fb3062af4424f912909d52914f377e6dd3fcd12ed5296e7781bdabe4",
+ "identitySha256": "4bb49dd8fb3062af4424f912909d52914f377e6dd3fcd12ed5296e7781bdabe4"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 13,
+ "packageDigest": "b1a4930b9a368892f54a7364c76fab3a63cea1638ebfefd0a556d96fd0af2ef8",
+ "gitTreeSha": "bf470e96a3411ef20ce67ee9a900bc773010775c",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 11536,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab",
+ "textNormalizedSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab",
+ "identitySha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 14,
+ "packageDigest": "a8c4f3fbc51a6a4d310ab5cddb015224884ac834b40530b6dbb7fcc2f91b52d1",
+ "gitTreeSha": "141e1de10ebe68fe5e72da23eb900b72746d217f",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 12177,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a",
+ "textNormalizedSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a",
+ "identitySha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 15,
+ "packageDigest": "b1a4930b9a368892f54a7364c76fab3a63cea1638ebfefd0a556d96fd0af2ef8",
+ "gitTreeSha": "bf470e96a3411ef20ce67ee9a900bc773010775c",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 11536,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab",
+ "textNormalizedSha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab",
+ "identitySha256": "1150a30d854ad1d6c4f107af4c115bac6afd00122e4b011ee5e8f9c49a9d9bab"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 16,
+ "packageDigest": "a8c4f3fbc51a6a4d310ab5cddb015224884ac834b40530b6dbb7fcc2f91b52d1",
+ "gitTreeSha": "141e1de10ebe68fe5e72da23eb900b72746d217f",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 12177,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a",
+ "textNormalizedSha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a",
+ "identitySha256": "d6490454f5a2814e98bb3848f05574beb81b7c5111295efd8c1d26c8b16bee4a"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 17,
+ "packageDigest": "f51066a029f17a1d87f7ac587df9850440cde08f5bdda80cb810a100ea45a169",
+ "gitTreeSha": "df7b970dda7e03e87bd9fd1dfe62c9aaf8769adc",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 13415,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "277dd8372f4b3b1773f8debe6e02eae575f01d568de328ae06d78ba71fc045cb",
+ "textNormalizedSha256": "277dd8372f4b3b1773f8debe6e02eae575f01d568de328ae06d78ba71fc045cb",
+ "identitySha256": "277dd8372f4b3b1773f8debe6e02eae575f01d568de328ae06d78ba71fc045cb"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 18,
+ "packageDigest": "77663355eef8ed82ff6918d9745bfecc4a50550ea86edc0f2a151f989c205a23",
+ "gitTreeSha": "7ff543c1701d55ffb5dffb2cd8dc5dd3fc47c700",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 17357,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "e72ecaf625b4a839189e8f13717b0c5b7a309fc82c9ed0dbfb8e861e31a2124a",
+ "textNormalizedSha256": "e72ecaf625b4a839189e8f13717b0c5b7a309fc82c9ed0dbfb8e861e31a2124a",
+ "identitySha256": "e72ecaf625b4a839189e8f13717b0c5b7a309fc82c9ed0dbfb8e861e31a2124a"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 19,
+ "packageDigest": "3318ddeba58457774a0436b6d2d1e722b8d81e2ab3c67cd8409bfbd8efe62a5f",
+ "gitTreeSha": "90a57c3d0135992369caa36493ca04278e02e55a",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 17600,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "2c1ac3a94ef4ee085147ee48d813bdcce532f5f6c7040d7836ea7b3d2a882628",
+ "textNormalizedSha256": "2c1ac3a94ef4ee085147ee48d813bdcce532f5f6c7040d7836ea7b3d2a882628",
+ "identitySha256": "2c1ac3a94ef4ee085147ee48d813bdcce532f5f6c7040d7836ea7b3d2a882628"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 20,
+ "packageDigest": "15ec10fdb480836bedc166c54ea15c10fb1e66e7c480f19300ad1227abe2b34d",
+ "gitTreeSha": "189912ad39f48b0a71f1cf0ee87abfcd55f90716",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 19701,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "e3b81f4d713f97a65a35ca5db0606939cebfdaade3daf1d709a76df1c869fd8a",
+ "textNormalizedSha256": "e3b81f4d713f97a65a35ca5db0606939cebfdaade3daf1d709a76df1c869fd8a",
+ "identitySha256": "e3b81f4d713f97a65a35ca5db0606939cebfdaade3daf1d709a76df1c869fd8a"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 21,
+ "packageDigest": "211f80c301de8afa2623dac3fde79f9ada8ec99a817f54a56dbb3cb43e4325d7",
+ "gitTreeSha": "a96dcd29ab8618eccc54c5c433f0b2378e50f698",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 21546,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "9550fb923937729fbaa7d6edec8a22f5a88da556fa7834c6a78ba3c65e8cda67",
+ "textNormalizedSha256": "9550fb923937729fbaa7d6edec8a22f5a88da556fa7834c6a78ba3c65e8cda67",
+ "identitySha256": "9550fb923937729fbaa7d6edec8a22f5a88da556fa7834c6a78ba3c65e8cda67"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 22,
+ "packageDigest": "c8eddb2814dda4c4b000048e628f147ef730088f8c9628afb04529a4863a410b",
+ "gitTreeSha": "ad1024ebab1f464a743d1c91a7005211beed0917",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 21701,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "6053ed840323522c3bac235007b2241df897857a6974dfd784f7e539b2b98a10",
+ "textNormalizedSha256": "6053ed840323522c3bac235007b2241df897857a6974dfd784f7e539b2b98a10",
+ "identitySha256": "6053ed840323522c3bac235007b2241df897857a6974dfd784f7e539b2b98a10"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 23,
+ "packageDigest": "dab5cc1bf87b12838a53dda90f1f4f35d1f77da66bcc63840574531eff600d2b",
+ "gitTreeSha": "f7a017f3e1f14dafed0c59717bec37c022075be2",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 22850,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9",
+ "textNormalizedSha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9",
+ "identitySha256": "b73c7c665aa233a782ba81982ebc954e220fdf1c1c6ca2b6bf38133c1cd76fc9"
+ }
+ ]
+ }
+ ],
+ "mobile-fit-debug": [
+ {
+ "releaseRevision": 1,
+ "packageDigest": "97ff3f85018bdea5e4518325423b159708eabe16cde3fbf7297525fa084d6a46",
+ "gitTreeSha": "9ff10aaeea448fa956cd4708b7f3b8242d178c0a",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 9414,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "84aa7cc18ed03df90f40c3c70650a0ad3df941a5e1f80870bd00cf6b9b7f7eab",
+ "textNormalizedSha256": "84aa7cc18ed03df90f40c3c70650a0ad3df941a5e1f80870bd00cf6b9b7f7eab",
+ "identitySha256": "84aa7cc18ed03df90f40c3c70650a0ad3df941a5e1f80870bd00cf6b9b7f7eab"
+ }
+ ]
+ }
+ ],
+ "computer-use": [
+ {
+ "releaseRevision": 1,
+ "packageDigest": "7c844c42b8e8c1a413cb8755b11a3b89fb3dc6c5a349d7aa63f269df975c1ccb",
+ "gitTreeSha": "ac1f6e3de0cbd9b488c5cfb1d4c5c076e17afea0",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 8514,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "bdd257ccd34bf4db179a549d2f133ac5e5fa9e4ea5c7e811d38a98e8a939b3cc",
+ "textNormalizedSha256": "bdd257ccd34bf4db179a549d2f133ac5e5fa9e4ea5c7e811d38a98e8a939b3cc",
+ "identitySha256": "bdd257ccd34bf4db179a549d2f133ac5e5fa9e4ea5c7e811d38a98e8a939b3cc"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 2,
+ "packageDigest": "6c882a35e50a00c49d2f87647c54d401fe2af60a95dc3d17a14bf3cf879e5547",
+ "gitTreeSha": "d83929742fd30aa3d4ab82ed1be4000acbb9cce0",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 6368,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "1b6e39080305dea665ce2c05c4c53ad5b49697db874920dab758d7a5cdc8292c",
+ "textNormalizedSha256": "1b6e39080305dea665ce2c05c4c53ad5b49697db874920dab758d7a5cdc8292c",
+ "identitySha256": "1b6e39080305dea665ce2c05c4c53ad5b49697db874920dab758d7a5cdc8292c"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 3,
+ "packageDigest": "747b9bbe7d08d0c6fc69a47c02064de73fa09ab755244b40a6253aa810d015f7",
+ "gitTreeSha": "b3fb19187d9775774df2a93d59e9e0003093e4a2",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 10454,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "7f5a7bbe8b2fb51a9637e8df71cd1dca1a84b213ffe5bf56dcdd3417d7fb1601",
+ "textNormalizedSha256": "7f5a7bbe8b2fb51a9637e8df71cd1dca1a84b213ffe5bf56dcdd3417d7fb1601",
+ "identitySha256": "7f5a7bbe8b2fb51a9637e8df71cd1dca1a84b213ffe5bf56dcdd3417d7fb1601"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 4,
+ "packageDigest": "35b6d91d1d3d38f3828c60e5b856a91324bdd09273db33664f508392c5c48dfc",
+ "gitTreeSha": "b4e2d7e7af8f8f29c6d0df6e92f23c1b2c439e3b",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 10625,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b",
+ "textNormalizedSha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b",
+ "identitySha256": "4ee2af92cf1bcb1a31b0ea87e61c5f99dbce325a6f211e320c05fba3b599a07b"
+ }
+ ]
+ }
+ ],
+ "orca-emulator": [
+ {
+ "releaseRevision": 1,
+ "packageDigest": "fdecfa3957c5502ec5f4933504f7112ab98c2c3b7b8902aad34db6cb1b7dc28a",
+ "gitTreeSha": "6b6b92ea67eec1661e45a8b37bf8014542f05417",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 10715,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "273123d797b6b6072a13a4e2732feca2b6d391154987836b31d5b105a7096045",
+ "textNormalizedSha256": "273123d797b6b6072a13a4e2732feca2b6d391154987836b31d5b105a7096045",
+ "identitySha256": "273123d797b6b6072a13a4e2732feca2b6d391154987836b31d5b105a7096045"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 2,
+ "packageDigest": "5a25152a06a88be0361f47cc37325e06ab840e81df0092cfea82f5e20e50978b",
+ "gitTreeSha": "5575f765c3dc31f8bc57032ca3716df38826eabd",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 10717,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "56b880444b2fe1f33061017ae41eb0eac1b11155ac74087f6e511a879891a712",
+ "textNormalizedSha256": "56b880444b2fe1f33061017ae41eb0eac1b11155ac74087f6e511a879891a712",
+ "identitySha256": "56b880444b2fe1f33061017ae41eb0eac1b11155ac74087f6e511a879891a712"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 3,
+ "packageDigest": "1807d287d2c08b1c535ba4e14ca2f29afe39274c4bde5737995a1792e44d5412",
+ "gitTreeSha": "dbb8e0daba768527885715ee5efebeaa1ee38f0f",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 10853,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834",
+ "textNormalizedSha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834",
+ "identitySha256": "837909c40e2472c24bcfd88684c00b0c19208e35e4446f81772ee6d7854fd834"
+ }
+ ]
+ }
+ ],
+ "linear-tickets": [
+ {
+ "releaseRevision": 1,
+ "packageDigest": "d70fb0e7640182b1b193969c96d2ff0d70821f59f71945915709ae95ddc4e350",
+ "gitTreeSha": "bb8c28593807f05b5007f89ccc86f9c054728882",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 6271,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "050220f735ce9b0f65520d3ed7fba6f5c1f856a6e8e84093ef564901cfc97ee1",
+ "textNormalizedSha256": "050220f735ce9b0f65520d3ed7fba6f5c1f856a6e8e84093ef564901cfc97ee1",
+ "identitySha256": "050220f735ce9b0f65520d3ed7fba6f5c1f856a6e8e84093ef564901cfc97ee1"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 2,
+ "packageDigest": "cc5901e9ce67829d909ab97910eb22825ca58547d215c42b665b8ff7401d5a05",
+ "gitTreeSha": "954d226df97ba0034002887bc9d23221667d45f0",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 8985,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "f57dfda0dddd20992b83d8b7a3e85f9651244ae4cb1b80bed80a87bb2c9b8bf2",
+ "textNormalizedSha256": "f57dfda0dddd20992b83d8b7a3e85f9651244ae4cb1b80bed80a87bb2c9b8bf2",
+ "identitySha256": "f57dfda0dddd20992b83d8b7a3e85f9651244ae4cb1b80bed80a87bb2c9b8bf2"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 3,
+ "packageDigest": "948919e8f2b37157c40009f865fa43fc925e56d0c30b544d0508cec823c26b15",
+ "gitTreeSha": "e00b39a2c9b37916e40b539e08341274bd8db2f7",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 9670,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "0d4fa74e40af8b530ff7071eb6f96b136e1c4cb0c02239305bb6d83b67a8214b",
+ "textNormalizedSha256": "0d4fa74e40af8b530ff7071eb6f96b136e1c4cb0c02239305bb6d83b67a8214b",
+ "identitySha256": "0d4fa74e40af8b530ff7071eb6f96b136e1c4cb0c02239305bb6d83b67a8214b"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 4,
+ "packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b",
+ "gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 10596,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5",
+ "textNormalizedSha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5",
+ "identitySha256": "0c3077c93328b9965430cd6951f1a35889b8c0775037870ea3a8bcf303d9d2c5"
+ }
+ ]
+ }
+ ],
+ "orca-linear": [
+ {
+ "releaseRevision": 1,
+ "packageDigest": "9015e708218fe86620eee39041d3e4f9ddd4b67ff2c6d049beca9ffe3783e03d",
+ "gitTreeSha": "bc547faebffa3f5645b7d2aad32560d25d836b7f",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 9394,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "bd158d47ba6f7090d73e8d86b9c4cd02610c14a96bf6a03402fe1f725ca84c3f",
+ "textNormalizedSha256": "bd158d47ba6f7090d73e8d86b9c4cd02610c14a96bf6a03402fe1f725ca84c3f",
+ "identitySha256": "bd158d47ba6f7090d73e8d86b9c4cd02610c14a96bf6a03402fe1f725ca84c3f"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 2,
+ "packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f",
+ "gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 10320,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5",
+ "textNormalizedSha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5",
+ "identitySha256": "48ded55ec3842ce65105e6db7adf9bc9ed263ece08555cec056e68e90321c3d5"
+ }
+ ]
+ }
+ ],
+ "orca-emulator-android": [
+ {
+ "releaseRevision": 1,
+ "packageDigest": "cf86dd085b981febd8e4e11b7796c90d69a60bd9c1dfaab5a16b99f77841aaa4",
+ "gitTreeSha": "6299122f69f0218ec01214e2a14c479b1811cdf6",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 8189,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844",
+ "textNormalizedSha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844",
+ "identitySha256": "ebc1ce862f3e5489e9bd94388386a28116241748f5957116509de7f759250844"
+ }
+ ]
+ }
+ ],
+ "orca-per-workspace-env": [
+ {
+ "releaseRevision": 1,
+ "packageDigest": "dc7f471327f55b27a40cd9575ca476fea7a79c2aba74881af00ce4e468cbf714",
+ "gitTreeSha": "8efb9872787bf874c558393b9a651df91604650c",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 37692,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "a18c3e67b3558d95c07ac80ab7e94c9ddfa0c61946c6b977923ffd65d3df63cc",
+ "textNormalizedSha256": "a18c3e67b3558d95c07ac80ab7e94c9ddfa0c61946c6b977923ffd65d3df63cc",
+ "identitySha256": "a18c3e67b3558d95c07ac80ab7e94c9ddfa0c61946c6b977923ffd65d3df63cc"
+ }
+ ]
+ },
+ {
+ "releaseRevision": 2,
+ "packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d",
+ "gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650",
+ "files": [
+ {
+ "path": "SKILL.md",
+ "size": 43769,
+ "executable": false,
+ "classification": "text",
+ "exactSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7",
+ "textNormalizedSha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7",
+ "identitySha256": "58e479bd18c4c553df0dfcb408eece2fbe550a0f9688bc289414420f72ed7ea7"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/src/main/ipc/skills.test.ts b/src/main/ipc/skills.test.ts
index 680163f8b5d..aaa56d4426a 100644
--- a/src/main/ipc/skills.test.ts
+++ b/src/main/ipc/skills.test.ts
@@ -1,13 +1,25 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type * as FsPromises from 'node:fs/promises'
-const { handleMock, discoverSkillsMock, getDefaultWslDistroMock, getWslHomeMock } = vi.hoisted(
- () => ({
- handleMock: vi.fn(),
- discoverSkillsMock: vi.fn(),
- getDefaultWslDistroMock: vi.fn(),
- getWslHomeMock: vi.fn()
- })
-)
+const {
+ handleMock,
+ discoverSkillsMock,
+ getDefaultWslDistroMock,
+ getWslHomeMock,
+ inventoryManagedSkillsMock,
+ adoptExactSkillSnapshotMock,
+ updateManagedSkillMock,
+ statMock
+} = vi.hoisted(() => ({
+ handleMock: vi.fn(),
+ discoverSkillsMock: vi.fn(),
+ getDefaultWslDistroMock: vi.fn(),
+ getWslHomeMock: vi.fn(),
+ inventoryManagedSkillsMock: vi.fn(),
+ adoptExactSkillSnapshotMock: vi.fn(),
+ updateManagedSkillMock: vi.fn(),
+ statMock: vi.fn()
+}))
vi.mock('electron', () => ({
ipcMain: {
@@ -24,13 +36,39 @@ vi.mock('../wsl', () => ({
getWslHome: getWslHomeMock
}))
+vi.mock('node:fs/promises', async () => ({
+ ...(await vi.importActual('node:fs/promises')),
+ stat: statMock
+}))
+
+vi.mock('../skills/skill-management-inventory', () => ({
+ inventoryManagedSkills: inventoryManagedSkillsMock
+}))
+
+vi.mock('../skills/skill-adoption', () => ({
+ adoptExactSkillSnapshot: adoptExactSkillSnapshotMock,
+ skillAdoptionDismissal: vi.fn()
+}))
+
+vi.mock('../skills/skill-managed-update', () => ({
+ updateManagedSkill: updateManagedSkillMock
+}))
+
+vi.mock('../skills/skill-explicit-replacement', () => ({
+ previewExplicitSkillReplacement: vi.fn(),
+ replaceSkillWithCurrentBundle: vi.fn()
+}))
+
import { registerSkillsHandlers } from './skills'
describe('registerSkillsHandlers', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
const repos = [{ id: 'repo-1', path: 'C:\\Users\\alice\\repo' }]
const store = {
- getRepos: vi.fn(() => repos)
+ getRepos: vi.fn(() => repos),
+ getSkillManagementLedger: vi.fn(),
+ setManagedSkillDestination: vi.fn(),
+ dismissSkillAdoptionCandidate: vi.fn()
}
beforeEach(() => {
@@ -38,6 +76,18 @@ describe('registerSkillsHandlers', () => {
discoverSkillsMock.mockReset()
getDefaultWslDistroMock.mockReset()
getWslHomeMock.mockReset()
+ inventoryManagedSkillsMock.mockReset()
+ adoptExactSkillSnapshotMock.mockReset()
+ updateManagedSkillMock.mockReset()
+ statMock.mockReset()
+ store.getSkillManagementLedger.mockReset()
+ store.setManagedSkillDestination.mockReset()
+ store.dismissSkillAdoptionCandidate.mockReset()
+ store.getSkillManagementLedger.mockReturnValue({
+ schemaVersion: 1,
+ destinations: {},
+ dismissedAdoptionCandidates: []
+ })
discoverSkillsMock.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 })
getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\alice')
Object.defineProperty(process, 'platform', {
@@ -53,10 +103,22 @@ describe('registerSkillsHandlers', () => {
})
function getDiscoverHandler() {
- registerSkillsHandlers(store as never)
- const call = handleMock.mock.calls.find((entry: unknown[]) => entry[0] === 'skills:discover')
+ return getHandler('skills:discover')
+ }
+
+ function getHandler(
+ channel: string,
+ runtimeFacts: NonNullable[1]> = {
+ isPackaged: true,
+ buildIdentity: 'stable',
+ userDataDir: '/isolated/user-data',
+ temporaryRoot: '/isolated'
+ }
+ ) {
+ registerSkillsHandlers(store as never, runtimeFacts)
+ const call = handleMock.mock.calls.find((entry: unknown[]) => entry[0] === channel)
if (!call) {
- throw new Error('skills:discover handler was not registered')
+ throw new Error(`${channel} handler was not registered`)
}
return call[1] as (_event: unknown, target?: unknown) => Promise
}
@@ -136,4 +198,165 @@ describe('registerSkillsHandlers', () => {
).rejects.toThrow('Project runtime requires repair before skill discovery')
expect(discoverSkillsMock).not.toHaveBeenCalled()
})
+
+ it('rejects WSL skill mutations before touching a distro path', async () => {
+ const handler = getHandler('skills:adopt')
+
+ await expect(
+ handler(null, {
+ installationId: 'candidate',
+ target: { runtime: 'wsl', wslDistro: 'Ubuntu' }
+ })
+ ).rejects.toThrow('Skill management actions are not available on this host yet')
+
+ expect(adoptExactSkillSnapshotMock).not.toHaveBeenCalled()
+ })
+
+ it('rejects remote execution-host discovery instead of scanning local paths', async () => {
+ const handler = getDiscoverHandler()
+
+ await expect(
+ handler(null, { executionHostId: 'ssh:production', cwd: '/srv/repo' })
+ ).rejects.toThrow('Skill management is not available for remote execution hosts in Phase 1')
+
+ expect(discoverSkillsMock).not.toHaveBeenCalled()
+ })
+
+ it('rejects remote execution-host mutations before inventory or filesystem work', async () => {
+ const handler = getHandler('skills:adopt')
+
+ await expect(
+ handler(null, {
+ installationId: 'candidate',
+ target: { executionHostId: 'runtime:environment-1' }
+ })
+ ).rejects.toThrow('Skill management is not available for remote execution hosts in Phase 1')
+
+ expect(inventoryManagedSkillsMock).not.toHaveBeenCalled()
+ expect(adoptExactSkillSnapshotMock).not.toHaveBeenCalled()
+ })
+
+ it('rejects unpackaged mutations against the real home', async () => {
+ const handler = getHandler('skills:adopt', {
+ isPackaged: false,
+ buildIdentity: null,
+ userDataDir: '/real/user-data',
+ temporaryRoot: '/tmp'
+ })
+
+ await expect(handler(null, { installationId: 'candidate' })).rejects.toThrow(
+ 'official stable build or isolated dev roots'
+ )
+ expect(inventoryManagedSkillsMock).not.toHaveBeenCalled()
+ expect(adoptExactSkillSnapshotMock).not.toHaveBeenCalled()
+ })
+
+ it('persists the exact adoption tuple supplied by the displayed candidate', async () => {
+ inventoryManagedSkillsMock.mockResolvedValue({
+ schemaVersion: 1,
+ hostId: 'local',
+ installations: [],
+ adoptionCandidateCount: 0,
+ scannedAt: 1
+ })
+ const handler = getHandler('skills:dismissAdoption')
+ const candidate = {
+ hostId: 'local',
+ physicalIdentity: '1:2',
+ skillName: 'orca-cli',
+ snapshotDigest: 'a'.repeat(64)
+ }
+
+ await handler(null, { candidate })
+
+ expect(store.dismissSkillAdoptionCandidate).toHaveBeenCalledWith({
+ ...candidate,
+ dismissedAt: expect.any(Number)
+ })
+ })
+
+ it('does not claim a canonical install that predates the terminal attempt', async () => {
+ const startedAt = Date.now() - 1_000
+ const inventory = {
+ schemaVersion: 1,
+ hostId: 'local',
+ installations: [
+ {
+ id: 'candidate',
+ name: 'orca-cli',
+ rootId: 'home-agents',
+ unresolvedPath: '/home/alice/.agents/skills/orca-cli',
+ eligible: true,
+ managed: false
+ }
+ ],
+ adoptionCandidateCount: 1,
+ scannedAt: 1
+ }
+ inventoryManagedSkillsMock.mockResolvedValue(inventory)
+ statMock.mockResolvedValue({ mtimeMs: startedAt - 10_000 })
+ const handler = getHandler('skills:recordOrcaInstall')
+
+ await handler(null, { skillNames: ['orca-cli'], startedAt })
+
+ expect(adoptExactSkillSnapshotMock).not.toHaveBeenCalled()
+ expect(store.setManagedSkillDestination).not.toHaveBeenCalled()
+ })
+
+ it('rejects install ownership without a valid terminal start time', async () => {
+ const handler = getHandler('skills:recordOrcaInstall')
+
+ await expect(handler(null, { skillNames: ['orca-cli'], startedAt: 0 })).rejects.toThrow(
+ 'Invalid skill installation start time'
+ )
+
+ expect(inventoryManagedSkillsMock).not.toHaveBeenCalled()
+ expect(adoptExactSkillSnapshotMock).not.toHaveBeenCalled()
+ })
+
+ it('does not claim an install whose marker mtime is implausibly in the future', async () => {
+ inventoryManagedSkillsMock.mockResolvedValue({
+ schemaVersion: 1,
+ hostId: 'local',
+ installations: [
+ {
+ id: 'candidate',
+ name: 'orca-cli',
+ rootId: 'home-agents',
+ unresolvedPath: '/home/alice/.agents/skills/orca-cli',
+ eligible: true,
+ managed: false
+ }
+ ],
+ adoptionCandidateCount: 1,
+ scannedAt: 1
+ })
+ statMock.mockResolvedValue({ mtimeMs: Date.now() + 60_000 })
+ const handler = getHandler('skills:recordOrcaInstall')
+
+ await handler(null, { skillNames: ['orca-cli'], startedAt: Date.now() - 1_000 })
+
+ expect(adoptExactSkillSnapshotMock).not.toHaveBeenCalled()
+ })
+
+ it('persists a failed managed update for retry diagnostics', async () => {
+ const prior = { id: 'managed', hostId: 'local', lastOutcome: 'adopted' }
+ store.getSkillManagementLedger.mockReturnValue({
+ schemaVersion: 1,
+ destinations: { managed: prior },
+ dismissedAdoptionCandidates: []
+ })
+ updateManagedSkillMock.mockRejectedValue(Object.assign(new Error('busy'), { code: 'EBUSY' }))
+ const handler = getHandler('skills:updateManaged')
+
+ await expect(handler(null, { installationId: 'managed' })).rejects.toThrow('busy')
+
+ expect(store.setManagedSkillDestination).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'managed',
+ lastOutcome: 'failed',
+ lastErrorCategory: 'filesystem-ebusy'
+ })
+ )
+ })
})
diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts
index b81fd695790..c3b5e75e583 100644
--- a/src/main/ipc/skills.ts
+++ b/src/main/ipc/skills.ts
@@ -1,16 +1,60 @@
-import { ipcMain } from 'electron'
+import { app, ipcMain } from 'electron'
+import { homedir, tmpdir } from 'node:os'
+import { stat } from 'node:fs/promises'
+import { join } from 'node:path'
import type { Store } from '../persistence'
import { discoverSkills } from '../skills/discovery'
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills'
+import type {
+ OrcaSkillInstallResultArgs,
+ SkillAdoptionDismissalArgs,
+ SkillManagementActionArgs,
+ SkillManagementInventory,
+ SkillReplacementPreview
+} from '../../shared/skill-management'
import { getDefaultWslDistro, getWslHome } from '../wsl'
+import { LOCAL_EXECUTION_HOST_ID, toWslExecutionHostId } from '../../shared/execution-host'
+import { inventoryManagedSkills } from '../skills/skill-management-inventory'
+import { adoptExactSkillSnapshot } from '../skills/skill-adoption'
+import {
+ previewExplicitSkillReplacement,
+ replaceSkillWithCurrentBundle
+} from '../skills/skill-explicit-replacement'
+import { updateManagedSkill } from '../skills/skill-managed-update'
+import { createSkillAdoptionDismissal } from '../skills/skill-adoption-dismissal'
+import { buildSkillDiscoverySources } from '../skills/skill-discovery-sources'
+import { sweepOrphanedSkillTransactions } from '../skills/skill-orphan-transaction-sweep'
+import { resolveDiagnosticBuildIdentity } from '../observability/diagnostic-upload-endpoint'
+import {
+ canMutateManagedSkills,
+ type SkillMutationRuntimeFacts
+} from '../skills/skill-mutation-authority'
type SkillDiscoveryRuntimeTarget =
| { runtime: 'host' }
| { runtime: 'wsl'; wslDistro: string | null | undefined }
+const MAX_ORCA_SKILL_INSTALL_ATTEMPT_MS = 15 * 60 * 1_000
+
+type SkillHandlerRuntimeFacts = Omit
+
+function productionSkillHandlerRuntimeFacts(): SkillHandlerRuntimeFacts {
+ return {
+ isPackaged: app.isPackaged,
+ buildIdentity: resolveDiagnosticBuildIdentity(),
+ userDataDir: app.getPath('userData'),
+ isolatedDevHomeDir: process.env.ORCA_E2E_SKILL_HOME_DIR,
+ isolatedDevUserDataDir: process.env.ORCA_E2E_USER_DATA_DIR,
+ temporaryRoot: tmpdir()
+ }
+}
+
function getSkillDiscoveryRuntimeTarget(
target: SkillDiscoveryTarget | undefined
): SkillDiscoveryRuntimeTarget {
+ if (target?.executionHostId && target.executionHostId !== LOCAL_EXECUTION_HOST_ID) {
+ throw new Error('Skill management is not available for remote execution hosts in Phase 1.')
+ }
const projectRuntime = target?.projectRuntime
if (!projectRuntime) {
return target?.runtime === 'wsl'
@@ -31,7 +75,75 @@ function getSkillDiscoveryRuntimeTarget(
return { runtime: 'host' }
}
-export function registerSkillsHandlers(store: Store): void {
+function getSkillManagementHostContext(target: SkillDiscoveryTarget | undefined): {
+ hostId: ReturnType | typeof LOCAL_EXECUTION_HOST_ID
+ homeDir: string
+} {
+ const runtimeTarget = getSkillDiscoveryRuntimeTarget(target)
+ if (runtimeTarget.runtime === 'host') {
+ return { hostId: LOCAL_EXECUTION_HOST_ID, homeDir: homedir() }
+ }
+ if (process.platform !== 'win32') {
+ throw new Error('WSL skill management is only available on Windows.')
+ }
+ const distro = runtimeTarget.wslDistro?.trim() || getDefaultWslDistro()
+ if (!distro) {
+ throw new Error('No WSL distribution is available for skill management.')
+ }
+ const homeDir = getWslHome(distro)
+ if (!homeDir) {
+ throw new Error(`Could not resolve the WSL home directory for ${distro}.`)
+ }
+ return { hostId: toWslExecutionHostId(distro), homeDir }
+}
+
+function requireSkillMutation(
+ context: { hostId: string; homeDir: string },
+ runtimeFacts: SkillHandlerRuntimeFacts
+): void {
+ if (context.hostId !== LOCAL_EXECUTION_HOST_ID) {
+ // Why: WSL publication belongs inside the distro runtime; UNC mutation
+ // would apply Windows lock and mode semantics to Linux-owned files.
+ throw new Error('Skill management actions are not available on this host yet.')
+ }
+ if (!canMutateManagedSkills({ ...runtimeFacts, homeDir: context.homeDir })) {
+ throw new Error(
+ 'Skill management actions require an official stable build or isolated dev roots.'
+ )
+ }
+}
+
+async function recoverSkillTransactionsForExplicitAction(
+ context: { hostId: string; homeDir: string },
+ store: Store,
+ runtimeFacts: SkillHandlerRuntimeFacts
+): Promise {
+ requireSkillMutation(context, runtimeFacts)
+ const roots = buildSkillDiscoverySources({
+ homeDir: context.homeDir,
+ cwd: context.homeDir
+ }).filter((root) => root.sourceKind === 'home')
+ // Why: orphan recovery can write, so it runs only inside an explicit
+ // foreground management action, never inventory or the App-mounted nudge.
+ await Promise.all(
+ roots.map((root) => sweepOrphanedSkillTransactions(root.path, store.getSkillManagementLedger()))
+ )
+}
+
+function skillFailureCategory(error: unknown): string {
+ if (error instanceof Error && /^skill-[a-z-]+$/.test(error.message)) {
+ return error.message
+ }
+ if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') {
+ return `filesystem-${error.code.toLowerCase()}`
+ }
+ return 'unknown-error'
+}
+
+export function registerSkillsHandlers(
+ store: Store,
+ runtimeFacts: SkillHandlerRuntimeFacts = productionSkillHandlerRuntimeFacts()
+): void {
ipcMain.handle(
'skills:discover',
async (_event, target?: SkillDiscoveryTarget): Promise => {
@@ -55,4 +167,155 @@ export function registerSkillsHandlers(store: Store): void {
return cwd ? discoverSkills({ repos: [], cwd }) : discoverSkills({ repos: store.getRepos() })
}
)
+
+ ipcMain.handle(
+ 'skills:managementInventory',
+ async (_event, target?: SkillDiscoveryTarget): Promise => {
+ const context = getSkillManagementHostContext(target)
+ return inventoryManagedSkills({
+ ...context,
+ ledger: store.getSkillManagementLedger(),
+ actionsSupported: canMutateManagedSkills({ ...runtimeFacts, homeDir: context.homeDir })
+ })
+ }
+ )
+
+ ipcMain.handle(
+ 'skills:adopt',
+ async (_event, args: SkillManagementActionArgs): Promise => {
+ const context = getSkillManagementHostContext(args.target)
+ await recoverSkillTransactionsForExplicitAction(context, store, runtimeFacts)
+ await adoptExactSkillSnapshot({
+ ...context,
+ ledger: store.getSkillManagementLedger(),
+ installationId: args.installationId,
+ commit: (next) => store.setManagedSkillDestination(next)
+ })
+ return inventoryManagedSkills({ ...context, ledger: store.getSkillManagementLedger() })
+ }
+ )
+
+ ipcMain.handle(
+ 'skills:dismissAdoption',
+ async (_event, args: SkillAdoptionDismissalArgs): Promise => {
+ const context = getSkillManagementHostContext(args.target)
+ requireSkillMutation(context, runtimeFacts)
+ store.dismissSkillAdoptionCandidate(
+ createSkillAdoptionDismissal(args?.candidate, context.hostId)
+ )
+ return inventoryManagedSkills({ ...context, ledger: store.getSkillManagementLedger() })
+ }
+ )
+
+ ipcMain.handle(
+ 'skills:previewReplacement',
+ async (_event, args: SkillManagementActionArgs): Promise => {
+ const context = getSkillManagementHostContext(args.target)
+ return previewExplicitSkillReplacement({
+ ...context,
+ ledger: store.getSkillManagementLedger(),
+ installationId: args.installationId
+ })
+ }
+ )
+
+ ipcMain.handle(
+ 'skills:replace',
+ async (_event, args: SkillManagementActionArgs): Promise => {
+ const context = getSkillManagementHostContext(args.target)
+ await recoverSkillTransactionsForExplicitAction(context, store, runtimeFacts)
+ await replaceSkillWithCurrentBundle({
+ ...context,
+ ledger: store.getSkillManagementLedger(),
+ installationId: args.installationId,
+ commit: (next) => store.setManagedSkillDestination(next)
+ })
+ return inventoryManagedSkills({ ...context, ledger: store.getSkillManagementLedger() })
+ }
+ )
+
+ ipcMain.handle(
+ 'skills:updateManaged',
+ async (_event, args: SkillManagementActionArgs): Promise => {
+ const context = getSkillManagementHostContext(args.target)
+ await recoverSkillTransactionsForExplicitAction(context, store, runtimeFacts)
+ const ledger = store.getSkillManagementLedger()
+ try {
+ await updateManagedSkill({
+ ...context,
+ ledger,
+ installationId: args.installationId,
+ commit: (next) => store.setManagedSkillDestination(next)
+ })
+ } catch (error) {
+ const prior = ledger.destinations[args.installationId]
+ if (prior) {
+ store.setManagedSkillDestination({
+ ...prior,
+ lastOutcome: 'failed',
+ lastErrorCategory: skillFailureCategory(error),
+ updatedAt: Date.now()
+ })
+ }
+ throw error
+ }
+ return inventoryManagedSkills({ ...context, ledger: store.getSkillManagementLedger() })
+ }
+ )
+
+ ipcMain.handle(
+ 'skills:recordOrcaInstall',
+ async (_event, args: OrcaSkillInstallResultArgs): Promise => {
+ const context = getSkillManagementHostContext(args.target)
+ const completedAt = Date.now()
+ if (
+ !Number.isFinite(args.startedAt) ||
+ args.startedAt <= 0 ||
+ args.startedAt > completedAt ||
+ completedAt - args.startedAt > MAX_ORCA_SKILL_INSTALL_ATTEMPT_MS
+ ) {
+ throw new Error('Invalid skill installation start time.')
+ }
+ await recoverSkillTransactionsForExplicitAction(context, store, runtimeFacts)
+ let inventory = await inventoryManagedSkills({
+ ...context,
+ ledger: store.getSkillManagementLedger()
+ })
+ for (const skillName of new Set(args.skillNames)) {
+ // Why: the global skills CLI owns the canonical .agents placement;
+ // provider-specific copies discovered beside it were not necessarily
+ // created by this terminal run and must retain separate consent.
+ const installation = inventory.installations.find(
+ (entry) =>
+ entry.name === skillName &&
+ entry.rootId === 'home-agents' &&
+ entry.eligible &&
+ !entry.managed
+ )
+ if (!installation) {
+ continue
+ }
+ const modifiedAt = await stat(join(installation.unresolvedPath, 'SKILL.md'))
+ .then((file) => file.mtimeMs)
+ .catch(() => 0)
+ // Why: exact content proves provenance, but only an mtime from this
+ // terminal window attributes ownership to Orca's install attempt.
+ if (modifiedAt + 2_000 < args.startedAt || modifiedAt > completedAt + 2_000) {
+ continue
+ }
+ await adoptExactSkillSnapshot({
+ ...context,
+ ledger: store.getSkillManagementLedger(),
+ installationId: installation.id,
+ commit: (next) =>
+ store.setManagedSkillDestination({ ...next, adoptedFrom: 'orca-install' })
+ })
+ inventory = await inventoryManagedSkills({
+ ...context,
+ ledger: store.getSkillManagementLedger()
+ })
+ }
+ return inventory
+ }
+ )
}
diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts
index 4ae746a1f28..528e804b0e8 100644
--- a/src/main/persistence.test.ts
+++ b/src/main/persistence.test.ts
@@ -6711,6 +6711,48 @@ describe('Store', () => {
expect(existsSync(join(testState.dir, 'terminal-scrollback', `${ref}.bin`))).toBe(false)
})
+ it('durably persists managed skill ownership before returning', async () => {
+ const store = await createStore()
+ const destination = {
+ id: 'local-skill',
+ hostId: 'local' as const,
+ skillName: 'orca-cli',
+ unresolvedPath: '/home/alice/.agents/skills/orca-cli',
+ installedPackageDigest: 'a'.repeat(64)
+ }
+
+ store.setManagedSkillDestination(destination as never)
+
+ expect(
+ (
+ readDataFile() as {
+ skillManagementLedger: { destinations: Record }
+ }
+ ).skillManagementLedger.destinations['local-skill']
+ ).toMatchObject(destination)
+ })
+
+ it('durably persists an exact skill adoption dismissal before returning', async () => {
+ const store = await createStore()
+ const candidate = {
+ hostId: 'local' as const,
+ physicalIdentity: '1:2',
+ skillName: 'orca-cli',
+ snapshotDigest: 'a'.repeat(64),
+ dismissedAt: 1
+ }
+
+ store.dismissSkillAdoptionCandidate(candidate)
+
+ expect(
+ (
+ readDataFile() as {
+ skillManagementLedger: { dismissedAdoptionCandidates: unknown[] }
+ }
+ ).skillManagementLedger.dismissedAdoptionCandidates
+ ).toContainEqual(candidate)
+ })
+
it('reads legacy terminal scrollback snapshots for explicit profile data files', async () => {
const profileDataDirectory = join(testState.dir, 'profiles', 'local-default')
const profileDataFile = join(profileDataDirectory, 'orca-data.json')
diff --git a/src/main/persistence.ts b/src/main/persistence.ts
index 44d0827bc5b..1b3b9f891b8 100644
--- a/src/main/persistence.ts
+++ b/src/main/persistence.ts
@@ -73,6 +73,11 @@ import {
} from '../shared/project-execution-runtime'
import { projectHostSetupProjectionFromRepos } from '../shared/project-host-setup-projection'
import type { GitRemoteIdentity } from '../shared/git-remote-identity'
+import type {
+ DismissedSkillAdoptionCandidate,
+ ManagedSkillDestination,
+ SkillManagementLedger
+} from '../shared/skill-management'
import {
buildTaskSourceContextFromRepo,
buildWorkspaceRunContext
@@ -236,6 +241,10 @@ import {
import { track } from './telemetry/client'
import { getCohortAtEmit } from './telemetry/cohort-classifier'
import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup/startup-diagnostics'
+import {
+ normalizeSkillManagementLedger,
+ skillAdoptionDismissalKey
+} from './skills/skill-management-ledger'
function encrypt(plaintext: string): string {
if (!plaintext || !safeStorage.isEncryptionAvailable()) {
@@ -3106,6 +3115,9 @@ export class Store {
featureInteractionTelemetryBuckets: normalizeFeatureInteractionTelemetryBuckets(
parsed.featureInteractionTelemetryBuckets
),
+ // Why: missing or malformed ownership state must never grant write
+ // authority; exact-snapshot adoption can reconstruct it explicitly.
+ skillManagementLedger: normalizeSkillManagementLedger(parsed.skillManagementLedger),
projectGroups: normalizedProjectGroups,
folderWorkspaces: normalizeFolderWorkspaces(
parsed.folderWorkspaces,
@@ -3804,6 +3816,43 @@ export class Store {
return this.state.repos.map((repo) => this.hydrateRepo(repo))
}
+ getSkillManagementLedger(): SkillManagementLedger {
+ return structuredClone(this.state.skillManagementLedger)
+ }
+
+ setManagedSkillDestination(destination: ManagedSkillDestination): void {
+ const previous = this.state.skillManagementLedger.destinations[destination.id]
+ this.state.skillManagementLedger.destinations[destination.id] = structuredClone(destination)
+ try {
+ // Why: transaction cleanup may delete the only rollback copy immediately
+ // after this callback, so ownership must reach disk before it returns.
+ this.flushOrThrow()
+ } catch (error) {
+ if (previous) {
+ this.state.skillManagementLedger.destinations[destination.id] = previous
+ } else {
+ delete this.state.skillManagementLedger.destinations[destination.id]
+ }
+ throw error
+ }
+ }
+
+ dismissSkillAdoptionCandidate(candidate: DismissedSkillAdoptionCandidate): void {
+ const previous = this.state.skillManagementLedger.dismissedAdoptionCandidates
+ const key = skillAdoptionDismissalKey(candidate)
+ const retained = previous.filter((entry) => skillAdoptionDismissalKey(entry) !== key)
+ retained.push(structuredClone(candidate))
+ this.state.skillManagementLedger.dismissedAdoptionCandidates = retained
+ try {
+ // Why: toast closure is the user's one suppression action, so the tuple
+ // must be durable before the renderer treats that lifecycle as complete.
+ this.flushOrThrow()
+ } catch (error) {
+ this.state.skillManagementLedger.dismissedAdoptionCandidates = previous
+ throw error
+ }
+ }
+
getProjects(): Project[] {
return [...this.state.projects]
}
diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts
index dcee058f8e2..ce1ce029385 100644
--- a/src/main/skills/discovery.test.ts
+++ b/src/main/skills/discovery.test.ts
@@ -57,6 +57,25 @@ describe('skill discovery', () => {
expect(rootPaths).toContain('/workspace/current/.claude/skills')
})
+ it('hard-excludes reserved updater transactions from skill discovery', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
+ const home = join(root, 'home')
+ const stagedSkill = join(
+ home,
+ '.agents',
+ 'skills',
+ '.orca-skill-transactions',
+ 'transaction-1',
+ 'stage'
+ )
+ await mkdir(stagedSkill, { recursive: true })
+ await writeFile(join(stagedSkill, 'SKILL.md'), '# Staged package')
+
+ const result = await discoverSkills({ homeDir: home, cwd: home, repos: [] })
+
+ expect(result.skills).toEqual([])
+ })
+
it('discovers skill packages through symlinked skill directories', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
const home = join(root, 'home')
diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts
index d84f82af426..e67c500d78f 100644
--- a/src/main/skills/discovery.ts
+++ b/src/main/skills/discovery.ts
@@ -14,6 +14,7 @@ import {
stablePathId,
type SkillScanRoot
} from './skill-discovery-sources'
+import { RESERVED_SKILL_TRANSACTION_DIRECTORY } from './skill-transaction-paths'
export { buildSkillDiscoverySources } from './skill-discovery-sources'
@@ -92,6 +93,11 @@ async function findSkillFiles(rootPath: string, maxDepth: number): Promise
+ if (
+ candidate.hostId !== hostId ||
+ typeof candidate.physicalIdentity !== 'string' ||
+ candidate.physicalIdentity.length === 0 ||
+ typeof candidate.skillName !== 'string' ||
+ candidate.skillName.length === 0 ||
+ typeof candidate.snapshotDigest !== 'string' ||
+ !/^[a-f0-9]{64}$/.test(candidate.snapshotDigest)
+ ) {
+ throw new Error('skill-adoption-candidate-invalid')
+ }
+ return {
+ hostId,
+ physicalIdentity: candidate.physicalIdentity,
+ skillName: candidate.skillName,
+ snapshotDigest: candidate.snapshotDigest,
+ dismissedAt
+ }
+}
diff --git a/src/main/skills/skill-adoption.test.ts b/src/main/skills/skill-adoption.test.ts
new file mode 100644
index 00000000000..2c742123ec9
--- /dev/null
+++ b/src/main/skills/skill-adoption.test.ts
@@ -0,0 +1,181 @@
+import { execFileSync } from 'node:child_process'
+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 type { SkillManagementInventory } from '../../shared/skill-management'
+import { adoptExactSkillSnapshot } from './skill-adoption'
+import { emptySkillManagementLedger } from './skill-management-ledger'
+import { inventoryManagedSkills } from './skill-management-inventory'
+
+const resourceRoot = resolve('resources')
+const temporaryHomes: string[] = []
+
+async function installHistoricalOrcaCli(crlf = false): Promise<{
+ home: string
+ inventory: SkillManagementInventory
+}> {
+ const home = await mkdtemp(join(tmpdir(), 'orca-skill-adoption-'))
+ temporaryHomes.push(home)
+ const destination = join(home, '.agents', 'skills', 'orca-cli')
+ await mkdir(destination, { recursive: true })
+ const releaseMapping = JSON.parse(
+ await readFile(resolve('resources/skills/release-mapping.json'), 'utf8')
+ )
+ const firstRelease = releaseMapping.releases.find(
+ (release: { skills: Record }) => release.skills['orca-cli'] === 1
+ )
+ const source = execFileSync(
+ 'git',
+ ['show', `v${firstRelease.appVersion}:skills/orca-cli/SKILL.md`],
+ { encoding: 'utf8' }
+ )
+ await writeFile(join(destination, 'SKILL.md'), crlf ? source.replace(/\n/g, '\r\n') : source)
+ const inventory = await inventoryManagedSkills({
+ ledger: emptySkillManagementLedger(),
+ hostId: 'local',
+ homeDir: home,
+ resourceRoot
+ })
+ return { home, inventory }
+}
+
+afterEach(async () => {
+ await Promise.all(temporaryHomes.splice(0).map((home) => rm(home, { recursive: true })))
+})
+
+describe('exact skill snapshot adoption', () => {
+ it('adopts and foreground-updates a released stale package', async () => {
+ const { home, inventory } = await installHistoricalOrcaCli()
+ const installation = inventory.installations.find((entry) => entry.name === 'orca-cli')
+ expect(installation?.status).toBe('known-update-available')
+ expect(installation?.installedAppVersion).toBe('1.0.81')
+
+ const record = await adoptExactSkillSnapshot({
+ ledger: emptySkillManagementLedger(),
+ hostId: 'local',
+ homeDir: home,
+ installationId: installation!.id,
+ resourceRoot,
+ commit: () => undefined,
+ now: 42
+ })
+
+ expect(record).toMatchObject({
+ installedReleaseRevision: installation!.currentReleaseRevision,
+ installedPackageDigest: installation!.currentPackageDigest,
+ lastOutcome: 'updated',
+ adoptedAt: 42,
+ updatedAt: 42
+ })
+ expect(await readFile(join(home, '.agents', 'skills', 'orca-cli', 'SKILL.md'), 'utf8')).toBe(
+ await readFile(resolve('skills/orca-cli/SKILL.md'), 'utf8')
+ )
+ const managedInventory = await inventoryManagedSkills({
+ ledger: {
+ schemaVersion: 1,
+ destinations: { [record.id]: record },
+ dismissedAdoptionCandidates: []
+ },
+ hostId: 'local',
+ homeDir: home,
+ resourceRoot
+ })
+ expect(managedInventory.installations.find((entry) => entry.name === 'orca-cli')).toMatchObject(
+ { id: record.id, status: 'managed-current', managed: true }
+ )
+ })
+
+ it('preserves a Windows-shaped CRLF text installation while updating', async () => {
+ const { home, inventory } = await installHistoricalOrcaCli(true)
+ const installation = inventory.installations.find((entry) => entry.name === 'orca-cli')
+
+ await adoptExactSkillSnapshot({
+ ledger: emptySkillManagementLedger(),
+ hostId: 'local',
+ homeDir: home,
+ installationId: installation!.id,
+ resourceRoot,
+ commit: () => undefined
+ })
+
+ const installed = await readFile(join(home, '.agents', 'skills', 'orca-cli', 'SKILL.md'))
+ const current = await readFile(resolve('skills/orca-cli/SKILL.md'), 'utf8')
+ expect(installed.includes(13)).toBe(true)
+ expect(installed.toString('utf8').replace(/\r\n/g, '\n')).toBe(current)
+ })
+
+ it('restores the prior package when durable ledger persistence fails', async () => {
+ const { home, inventory } = await installHistoricalOrcaCli()
+ const installation = inventory.installations.find((entry) => entry.name === 'orca-cli')!
+ const skillMarkdownPath = join(home, '.agents', 'skills', 'orca-cli', 'SKILL.md')
+ const before = await readFile(skillMarkdownPath)
+
+ await expect(
+ adoptExactSkillSnapshot({
+ ledger: emptySkillManagementLedger(),
+ hostId: 'local',
+ homeDir: home,
+ installationId: installation.id,
+ resourceRoot,
+ commit: () => {
+ throw new Error('ledger-write-failed')
+ }
+ })
+ ).rejects.toThrow('ledger-write-failed')
+
+ expect(await readFile(skillMarkdownPath)).toEqual(before)
+ })
+
+ it('refuses adoption after unknown content replaces the official snapshot', async () => {
+ const { home, inventory } = await installHistoricalOrcaCli()
+ const installation = inventory.installations.find((entry) => entry.name === 'orca-cli')
+ await writeFile(join(home, '.agents', 'skills', 'orca-cli', 'SKILL.md'), 'custom')
+
+ await expect(
+ adoptExactSkillSnapshot({
+ ledger: emptySkillManagementLedger(),
+ hostId: 'local',
+ homeDir: home,
+ installationId: installation!.id,
+ resourceRoot,
+ commit: () => undefined
+ })
+ ).rejects.toThrow('skill-installation-not-adoptable')
+ })
+
+ it('revalidates exact content immediately before committing current adoption', async () => {
+ const home = await mkdtemp(join(tmpdir(), 'orca-skill-adoption-race-'))
+ temporaryHomes.push(home)
+ const destination = join(home, '.agents', 'skills', 'orca-cli')
+ await mkdir(destination, { recursive: true })
+ await writeFile(
+ join(destination, 'SKILL.md'),
+ await readFile(resolve('skills/orca-cli/SKILL.md'))
+ )
+ const inventory = await inventoryManagedSkills({
+ ledger: emptySkillManagementLedger(),
+ hostId: 'local',
+ homeDir: home,
+ resourceRoot
+ })
+ const installation = inventory.installations.find((entry) => entry.name === 'orca-cli')!
+ let committed = false
+
+ await expect(
+ adoptExactSkillSnapshot({
+ ledger: emptySkillManagementLedger(),
+ hostId: 'local',
+ homeDir: home,
+ installationId: installation.id,
+ resourceRoot,
+ beforeFinalValidation: () =>
+ writeFile(join(destination, 'SKILL.md'), 'post-observation independent edit'),
+ commit: () => {
+ committed = true
+ }
+ })
+ ).rejects.toThrow('skill-content-changed')
+ expect(committed).toBe(false)
+ })
+})
diff --git a/src/main/skills/skill-adoption.ts b/src/main/skills/skill-adoption.ts
new file mode 100644
index 00000000000..3bc41fefe62
--- /dev/null
+++ b/src/main/skills/skill-adoption.ts
@@ -0,0 +1,130 @@
+import { lstat, realpath } from 'node:fs/promises'
+import { resolve } from 'node:path'
+import type { ExecutionHostId } from '../../shared/execution-host'
+import type {
+ DismissedSkillAdoptionCandidate,
+ ManagedSkillDestination,
+ SkillManagementLedger
+} from '../../shared/skill-management'
+import { loadSkillBundleArtifacts } from './skill-bundle-artifacts'
+import {
+ matchingReleasedSnapshot,
+ observeSkillPackage,
+ persistedObservedFiles
+} from './skill-package-identity'
+import { inventoryManagedSkills } from './skill-management-inventory'
+import { normalizedSkillIdentityPath } from './skill-installation-topology'
+import { publishBundledSkill } from './skill-package-publish'
+
+export async function adoptExactSkillSnapshot(args: {
+ ledger: SkillManagementLedger
+ hostId: ExecutionHostId
+ homeDir: string
+ installationId: string
+ resourceRoot?: string
+ now?: number
+ // Why: deterministic fault injection must land after advisory observation
+ // but before the same final validation used by production commits.
+ beforeFinalValidation?: () => void | Promise
+ commit: (destination: ManagedSkillDestination) => void | Promise
+}): Promise {
+ const inventory = await inventoryManagedSkills(args)
+ const installation = inventory.installations.find((entry) => entry.id === args.installationId)
+ if (
+ !installation?.eligible ||
+ installation.managed ||
+ !installation.resolvedPath ||
+ !installation.physicalIdentity ||
+ installation.installedReleaseRevision === null ||
+ !installation.installedPackageDigest
+ ) {
+ throw new Error('skill-installation-not-adoptable')
+ }
+
+ const artifacts = await loadSkillBundleArtifacts(args.resourceRoot)
+ const current = artifacts.manifest.skills.find((entry) => entry.name === installation.name)
+ const observed = await observeSkillPackage(installation.resolvedPath)
+ const snapshot = matchingReleasedSnapshot(
+ observed,
+ artifacts.releasedSnapshots[installation.name] ?? []
+ )
+ // Why: inventory is only advisory; consent records ownership only after a
+ // fresh exact-content check closes the user/CLI mutation race.
+ if (
+ !current ||
+ !snapshot ||
+ snapshot.packageDigest !== installation.installedPackageDigest ||
+ normalizedSkillIdentityPath(await realpath(installation.resolvedPath)) !==
+ normalizedSkillIdentityPath(installation.resolvedPath)
+ ) {
+ throw new Error('skill-installation-changed-before-adoption')
+ }
+
+ const logicalStat = await lstat(installation.unresolvedPath)
+ const now = args.now ?? Date.now()
+ const record: ManagedSkillDestination = {
+ id: installation.id,
+ hostId: args.hostId,
+ homeIdentity: normalizedSkillIdentityPath(
+ await realpath(args.homeDir).catch(() => resolve(args.homeDir))
+ ),
+ rootId: installation.rootId,
+ unresolvedPath: installation.unresolvedPath,
+ resolvedPath: installation.resolvedPath,
+ physicalIdentity: installation.physicalIdentity,
+ entryType: logicalStat.isSymbolicLink()
+ ? process.platform === 'win32'
+ ? 'junction'
+ : 'symlink'
+ : 'directory',
+ skillName: installation.name,
+ source: 'stablyai/orca',
+ sourcePath: current.sourcePath,
+ sourceRef: snapshot.gitTreeSha,
+ installedReleaseRevision: snapshot.releaseRevision,
+ installedPackageDigest: snapshot.packageDigest,
+ installedFiles: persistedObservedFiles(observed),
+ lastWrittenPackageDigest: null,
+ lastAttemptedBundleFingerprint: current.packageDigest,
+ lastOutcome: 'adopted',
+ lastErrorCategory: null,
+ adoptedFrom: 'exact-snapshot',
+ adoptedAt: now,
+ updatedAt: now
+ }
+ await args.beforeFinalValidation?.()
+ return publishBundledSkill({
+ record,
+ current,
+ releasedSnapshots: artifacts.releasedSnapshots[installation.name] ?? [],
+ packagesRoot: artifacts.packagesRoot,
+ commit: args.commit,
+ now
+ })
+}
+
+export async function skillAdoptionDismissal(args: {
+ ledger: SkillManagementLedger
+ hostId: ExecutionHostId
+ homeDir: string
+ installationId: string
+ resourceRoot?: string
+ now?: number
+}): Promise {
+ const inventory = await inventoryManagedSkills(args)
+ const installation = inventory.installations.find((entry) => entry.id === args.installationId)
+ if (
+ !installation?.eligible ||
+ !installation.physicalIdentity ||
+ !installation.installedPackageDigest
+ ) {
+ throw new Error('skill-adoption-candidate-not-found')
+ }
+ return {
+ hostId: args.hostId,
+ physicalIdentity: installation.physicalIdentity,
+ skillName: installation.name,
+ snapshotDigest: installation.installedPackageDigest,
+ dismissedAt: args.now ?? Date.now()
+ }
+}
diff --git a/src/main/skills/skill-bundle-artifacts.ts b/src/main/skills/skill-bundle-artifacts.ts
new file mode 100644
index 00000000000..d04880cd700
--- /dev/null
+++ b/src/main/skills/skill-bundle-artifacts.ts
@@ -0,0 +1,110 @@
+import { app } from 'electron'
+import { readFile } from 'node:fs/promises'
+import { join, resolve } from 'node:path'
+import type {
+ SkillBundleManifest,
+ SkillReleaseMapping,
+ SkillReleasedSnapshot,
+ SkillSnapshotRegistry
+} from '../../shared/skill-management'
+
+export type SkillBundleArtifacts = {
+ manifest: SkillBundleManifest
+ registry: SkillSnapshotRegistry
+ releaseMapping: SkillReleaseMapping
+ releasedSnapshots: Record
+ releasedAppVersions: Record>
+ packagesRoot: string
+}
+
+function assertSupportedSchema(
+ value: unknown,
+ label: string
+): asserts value is { schemaVersion: 1 } {
+ if (
+ typeof value !== 'object' ||
+ value === null ||
+ !('schemaVersion' in value) ||
+ value.schemaVersion !== 1
+ ) {
+ throw new Error(`Unsupported ${label} schema`)
+ }
+}
+
+export async function loadSkillBundleArtifacts(
+ resourceRoot = app.isPackaged ? process.resourcesPath : resolve(process.cwd(), 'resources')
+): Promise {
+ const bundleRoot = join(resourceRoot, 'skills')
+ const [manifest, registry, releaseMapping] = 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)
+ ])
+ assertSupportedSchema(manifest, 'skill bundle manifest')
+ assertSupportedSchema(registry, 'skill snapshot registry')
+ assertSupportedSchema(releaseMapping, 'skill release mapping')
+ if (!('skills' in manifest) || !Array.isArray(manifest.skills)) {
+ throw new Error('Invalid skill bundle manifest')
+ }
+ if (
+ !('appVersion' in manifest) ||
+ typeof manifest.appVersion !== 'string' ||
+ !('skills' in registry) ||
+ typeof registry.skills !== 'object' ||
+ registry.skills === null
+ ) {
+ throw new Error('Invalid skill snapshot registry')
+ }
+ if (!('releases' in releaseMapping) || !Array.isArray(releaseMapping.releases)) {
+ throw new Error('Invalid skill release mapping')
+ }
+ const mappedRevisions = new Map>()
+ const releasedAppVersions: Record> = {}
+ for (const release of releaseMapping.releases) {
+ if (
+ typeof release !== 'object' ||
+ release === null ||
+ !('appVersion' in release) ||
+ typeof release.appVersion !== 'string' ||
+ !('skills' in release) ||
+ typeof release.skills !== 'object' ||
+ release.skills === null
+ ) {
+ throw new Error('Invalid skill release mapping')
+ }
+ for (const [name, revision] of Object.entries(release.skills)) {
+ if (!Number.isSafeInteger(revision) || (revision as number) <= 0) {
+ throw new Error('Invalid skill release mapping')
+ }
+ const revisions = mappedRevisions.get(name) ?? new Set()
+ revisions.add(revision as number)
+ mappedRevisions.set(name, revisions)
+ releasedAppVersions[name] ??= {}
+ releasedAppVersions[name][revision as number] ??= release.appVersion
+ }
+ }
+ const releasedSnapshots = Object.fromEntries(
+ Object.entries((registry as SkillSnapshotRegistry).skills).map(([name, snapshots]) => [
+ name,
+ snapshots.filter((snapshot) => mappedRevisions.get(name)?.has(snapshot.releaseRevision))
+ ])
+ )
+ for (const [name, revisions] of mappedRevisions) {
+ const known = new Set(
+ ((registry as SkillSnapshotRegistry).skills[name] ?? []).map(
+ (snapshot) => snapshot.releaseRevision
+ )
+ )
+ if ([...revisions].some((revision) => !known.has(revision))) {
+ throw new Error('Skill release mapping references an unknown snapshot')
+ }
+ }
+ return {
+ manifest: manifest as SkillBundleManifest,
+ registry: registry as SkillSnapshotRegistry,
+ releaseMapping: releaseMapping as SkillReleaseMapping,
+ releasedSnapshots,
+ releasedAppVersions,
+ packagesRoot: app?.isPackaged ? join(bundleRoot, 'packages') : resolve(process.cwd(), 'skills')
+ }
+}
diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts
index c1bb1072b2e..d909c688582 100644
--- a/src/main/skills/skill-discovery-sources.ts
+++ b/src/main/skills/skill-discovery-sources.ts
@@ -32,7 +32,8 @@ export function buildSkillDiscoverySources(
const roots: SkillScanRoot[] = [
source('home-codex', 'Codex home', join(home, '.codex', 'skills'), 'home', ['codex']),
source('home-agents', 'Agent skills home', join(home, '.agents', 'skills'), 'home', [
- 'agent-skills'
+ 'agent-skills',
+ 'codex'
]),
source('home-claude', 'Claude home', join(home, '.claude', 'skills'), 'home', ['claude']),
source(
diff --git a/src/main/skills/skill-explicit-replacement.test.ts b/src/main/skills/skill-explicit-replacement.test.ts
new file mode 100644
index 00000000000..4cd322e5424
--- /dev/null
+++ b/src/main/skills/skill-explicit-replacement.test.ts
@@ -0,0 +1,84 @@
+import { cp, 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 { emptySkillManagementLedger } from './skill-management-ledger'
+import { inventoryManagedSkills } from './skill-management-inventory'
+import {
+ previewExplicitSkillReplacement,
+ replaceSkillWithCurrentBundle
+} from './skill-explicit-replacement'
+
+const temporaryHomes: string[] = []
+const resourceRoot = resolve('resources')
+
+afterEach(async () => {
+ await Promise.all(temporaryHomes.splice(0).map((home) => rm(home, { recursive: true })))
+})
+
+async function customInstallation() {
+ const home = await mkdtemp(join(tmpdir(), 'orca-skill-replacement-'))
+ temporaryHomes.push(home)
+ const destination = join(home, '.agents', 'skills', 'orca-cli')
+ await mkdir(destination, { recursive: true })
+ await cp(resolve('skills/orca-cli'), destination, { recursive: true })
+ await writeFile(join(destination, 'SKILL.md'), 'custom instructions\n')
+ await writeFile(join(destination, 'notes.txt'), 'keep me until confirmed\n')
+ const inventory = await inventoryManagedSkills({
+ ledger: emptySkillManagementLedger(),
+ hostId: 'local',
+ homeDir: home,
+ resourceRoot
+ })
+ return {
+ home,
+ destination,
+ installation: inventory.installations.find((entry) => entry.name === 'orca-cli')!
+ }
+}
+
+describe('explicit skill replacement', () => {
+ it('previews every local file that replacement changes or removes', async () => {
+ const { home, installation } = await customInstallation()
+ const preview = await previewExplicitSkillReplacement({
+ ledger: emptySkillManagementLedger(),
+ hostId: 'local',
+ homeDir: home,
+ installationId: installation.id,
+ resourceRoot
+ })
+
+ expect(preview.files).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ path: 'SKILL.md', change: 'modified' }),
+ expect.objectContaining({ path: 'notes.txt', change: 'removed' })
+ ])
+ )
+ })
+
+ it('replaces only after explicit invocation and records the verified current bundle', async () => {
+ const { home, destination, installation } = await customInstallation()
+ expect(await readFile(join(destination, 'notes.txt'), 'utf8')).toContain('keep me')
+
+ const record = await replaceSkillWithCurrentBundle({
+ ledger: emptySkillManagementLedger(),
+ hostId: 'local',
+ homeDir: home,
+ installationId: installation.id,
+ resourceRoot,
+ commit: () => undefined,
+ now: 99
+ })
+
+ expect(record).toMatchObject({
+ adoptedFrom: 'explicit-replacement',
+ lastOutcome: 'replaced',
+ adoptedAt: 99,
+ updatedAt: 99
+ })
+ expect(await readFile(join(destination, 'SKILL.md'), 'utf8')).toBe(
+ await readFile(resolve('skills/orca-cli/SKILL.md'), 'utf8')
+ )
+ await expect(readFile(join(destination, 'notes.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
+ })
+})
diff --git a/src/main/skills/skill-explicit-replacement.ts b/src/main/skills/skill-explicit-replacement.ts
new file mode 100644
index 00000000000..3ac8cf511b8
--- /dev/null
+++ b/src/main/skills/skill-explicit-replacement.ts
@@ -0,0 +1,161 @@
+import { readFile, realpath } from 'node:fs/promises'
+import { join, resolve } from 'node:path'
+import type { ExecutionHostId } from '../../shared/execution-host'
+import type {
+ ManagedSkillDestination,
+ SkillManagementLedger,
+ SkillReplacementFilePreview,
+ SkillReplacementPreview,
+ SkillReleasedSnapshot
+} from '../../shared/skill-management'
+import { loadSkillBundleArtifacts } from './skill-bundle-artifacts'
+import { normalizedSkillIdentityPath } from './skill-installation-topology'
+import { inventoryManagedSkills } from './skill-management-inventory'
+import {
+ observeSkillPackage,
+ persistedObservedFiles,
+ type ObservedSkillPackage
+} from './skill-package-identity'
+import { publishBundledSkill } from './skill-package-publish'
+
+function decodedText(bytes: Buffer): string | null {
+ try {
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
+ } catch {
+ return null
+ }
+}
+
+async function replacementFiles(
+ observed: ObservedSkillPackage,
+ packageRoot: string,
+ currentPaths: readonly string[]
+): Promise {
+ const observedByPath = new Map(observed.files.map((file) => [file.path, file]))
+ const paths = [...new Set([...observedByPath.keys(), ...currentPaths])].sort()
+ const previews = await Promise.all(
+ paths.map(async (path): Promise => {
+ const before = observedByPath.get(path)
+ const afterBytes = currentPaths.includes(path)
+ ? await readFile(join(packageRoot, ...path.split('/')))
+ : null
+ if (before && afterBytes && before.bytes.equals(afterBytes)) {
+ return null
+ }
+ return {
+ path,
+ change: before ? (afterBytes ? 'modified' : 'removed') : 'added',
+ beforeText: before ? decodedText(before.bytes) : null,
+ afterText: afterBytes ? decodedText(afterBytes) : null
+ }
+ })
+ )
+ return previews.filter((preview): preview is SkillReplacementFilePreview => preview !== null)
+}
+
+async function replacementContext(args: {
+ ledger: SkillManagementLedger
+ hostId: ExecutionHostId
+ homeDir: string
+ installationId: string
+ resourceRoot?: string
+}) {
+ const inventory = await inventoryManagedSkills(args)
+ const installation = inventory.installations.find((entry) => entry.id === args.installationId)
+ if (
+ !installation ||
+ (installation.status !== 'modified' && installation.status !== 'unknown') ||
+ !installation.resolvedPath ||
+ !installation.physicalIdentity ||
+ (installation.topology !== 'canonical-copy' && installation.topology !== 'independent-copy')
+ ) {
+ throw new Error('skill-installation-not-replaceable')
+ }
+ const artifacts = await loadSkillBundleArtifacts(args.resourceRoot)
+ const current = artifacts.manifest.skills.find((entry) => entry.name === installation.name)
+ if (!current) {
+ throw new Error('skill-bundle-entry-missing')
+ }
+ const observed = await observeSkillPackage(installation.resolvedPath)
+ return { installation, artifacts, current, observed }
+}
+
+export async function previewExplicitSkillReplacement(args: {
+ ledger: SkillManagementLedger
+ hostId: ExecutionHostId
+ homeDir: string
+ installationId: string
+ resourceRoot?: string
+}): Promise {
+ const { installation, artifacts, current, observed } = await replacementContext(args)
+ return {
+ installationId: installation.id,
+ skillName: installation.name,
+ files: await replacementFiles(
+ observed,
+ join(artifacts.packagesRoot, installation.name),
+ current.files.map((file) => file.path)
+ )
+ }
+}
+
+export async function replaceSkillWithCurrentBundle(args: {
+ ledger: SkillManagementLedger
+ hostId: ExecutionHostId
+ homeDir: string
+ installationId: string
+ resourceRoot?: string
+ now?: number
+ commit: (destination: ManagedSkillDestination) => void | Promise
+}): Promise {
+ const { installation, artifacts, current, observed } = await replacementContext(args)
+ const now = args.now ?? Date.now()
+ const installedFiles = persistedObservedFiles(observed)
+ const observedSnapshot: SkillReleasedSnapshot = {
+ releaseRevision: 0,
+ packageDigest: observed.observedDigest,
+ gitTreeSha: '',
+ files: installedFiles
+ }
+ const homeIdentity = normalizedSkillIdentityPath(
+ await realpath(args.homeDir).catch(() => resolve(args.homeDir))
+ )
+ const record: ManagedSkillDestination = {
+ id: installation.id,
+ hostId: args.hostId,
+ homeIdentity,
+ rootId: installation.rootId,
+ unresolvedPath: installation.unresolvedPath,
+ resolvedPath: installation.resolvedPath!,
+ physicalIdentity: installation.physicalIdentity!,
+ entryType: 'directory',
+ skillName: installation.name,
+ source: 'stablyai/orca',
+ sourcePath: current.sourcePath,
+ sourceRef: null,
+ installedReleaseRevision: 0,
+ installedPackageDigest: observed.observedDigest,
+ installedFiles,
+ lastWrittenPackageDigest: null,
+ lastAttemptedBundleFingerprint: current.packageDigest,
+ lastOutcome: 'replaced',
+ lastErrorCategory: null,
+ adoptedFrom: 'explicit-replacement',
+ adoptedAt: now,
+ updatedAt: now
+ }
+ const published = await publishBundledSkill({
+ record,
+ current,
+ releasedSnapshots: [observedSnapshot],
+ packagesRoot: artifacts.packagesRoot,
+ commit: (destination) =>
+ args.commit({
+ ...destination,
+ lastOutcome: 'replaced',
+ adoptedFrom: 'explicit-replacement'
+ }),
+ now
+ })
+ return { ...published, lastOutcome: 'replaced', adoptedFrom: 'explicit-replacement' }
+}
diff --git a/src/main/skills/skill-installation-topology.ts b/src/main/skills/skill-installation-topology.ts
new file mode 100644
index 00000000000..aa9a10f6b52
--- /dev/null
+++ b/src/main/skills/skill-installation-topology.ts
@@ -0,0 +1,207 @@
+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 { ExecutionHostId } from '../../shared/execution-host'
+import type { SkillInstallationTopology } from '../../shared/skill-management'
+import type { ManagedSkillDestination } from '../../shared/skill-management'
+import type { SkillScanRoot } from './skill-discovery-sources'
+
+export type ClassifiedSkillTopology = {
+ topology: SkillInstallationTopology
+ resolvedPath: string | null
+ identity: string | null
+ entryType: 'directory' | 'symlink' | 'junction'
+ errorCategory: string | null
+}
+
+export function skillDestinationId(
+ hostId: ExecutionHostId,
+ unresolvedPath: string,
+ name: string
+): string {
+ return createHash('sha256')
+ .update(hostId)
+ .update('\0')
+ .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>
+): 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':
+ return 0
+ }
+}
+
+async function writableDestination(path: string): Promise {
+ 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 {
+ 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 assertManagedSkillTopology(record: ManagedSkillDestination): Promise {
+ const logical = await lstat(record.unresolvedPath)
+ const actualEntryType = logical.isSymbolicLink()
+ ? process.platform === 'win32'
+ ? 'junction'
+ : 'symlink'
+ : logical.isDirectory()
+ ? 'directory'
+ : null
+ const homeBoundary = dirname(dirname(dirname(record.unresolvedPath)))
+ if (
+ actualEntryType !== record.entryType ||
+ (await hasSymlinkedAncestor(dirname(record.unresolvedPath), homeBoundary))
+ ) {
+ throw new Error('skill-topology-changed')
+ }
+ const [resolved, resolvedParent] = await Promise.all([
+ realpath(record.unresolvedPath),
+ realpath(dirname(record.unresolvedPath))
+ ])
+ const resolvedStat = await stat(resolved)
+ if (
+ !resolvedStat.isDirectory() ||
+ normalizedSkillIdentityPath(resolved) !== normalizedSkillIdentityPath(record.resolvedPath) ||
+ normalizedSkillIdentityPath(resolvedParent) !==
+ normalizedSkillIdentityPath(dirname(record.resolvedPath)) ||
+ skillPhysicalIdentity(resolved, resolvedStat) !== record.physicalIdentity
+ ) {
+ throw new Error('skill-topology-changed')
+ }
+ return resolved
+}
+
+export async function assertManagedSkillParentTopology(
+ record: ManagedSkillDestination
+): Promise {
+ const homeBoundary = dirname(dirname(dirname(record.unresolvedPath)))
+ if (await hasSymlinkedAncestor(dirname(record.unresolvedPath), homeBoundary)) {
+ throw new Error('skill-topology-changed')
+ }
+ const resolvedParent = await realpath(dirname(record.unresolvedPath))
+ if (
+ normalizedSkillIdentityPath(resolvedParent) !==
+ normalizedSkillIdentityPath(dirname(record.resolvedPath))
+ ) {
+ throw new Error('skill-topology-changed')
+ }
+}
+
+export async function classifySkillInstallationTopology(
+ root: SkillScanRoot,
+ unresolvedPath: string,
+ canonicalRootPath: string
+): Promise {
+ let logicalStat: Awaited>
+ 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,
+ entryType: 'symlink',
+ errorCategory: 'missing'
+ }
+ }
+ throw error
+ }
+ const linked = logicalStat.isSymbolicLink()
+ const entryType =
+ linked && process.platform === 'win32' ? 'junction' : linked ? 'symlink' : 'directory'
+ let resolvedPath: string
+ let resolvedStat: Awaited>
+ try {
+ resolvedPath = await realpath(unresolvedPath)
+ resolvedStat = await stat(resolvedPath)
+ } catch {
+ return {
+ topology: 'broken-link',
+ resolvedPath: null,
+ identity: null,
+ entryType,
+ errorCategory: 'dangling-link'
+ }
+ }
+ if (!resolvedStat.isDirectory()) {
+ return {
+ topology: 'broken-link',
+ resolvedPath,
+ identity: null,
+ entryType,
+ 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, entryType, errorCategory: null }
+}
diff --git a/src/main/skills/skill-managed-update.ts b/src/main/skills/skill-managed-update.ts
new file mode 100644
index 00000000000..633d4a9908d
--- /dev/null
+++ b/src/main/skills/skill-managed-update.ts
@@ -0,0 +1,43 @@
+import type { ExecutionHostId } from '../../shared/execution-host'
+import type { ManagedSkillDestination, SkillManagementLedger } from '../../shared/skill-management'
+import { loadSkillBundleArtifacts } from './skill-bundle-artifacts'
+import { publishBundledSkill } from './skill-package-publish'
+import { inventoryManagedSkills } from './skill-management-inventory'
+
+export async function updateManagedSkill(args: {
+ ledger: SkillManagementLedger
+ hostId: ExecutionHostId
+ homeDir: string
+ installationId: string
+ resourceRoot?: string
+ now?: number
+ commit: (destination: ManagedSkillDestination) => void | Promise
+}): Promise {
+ const record = args.ledger.destinations[args.installationId]
+ if (!record || record.hostId !== args.hostId) {
+ throw new Error('managed-skill-not-found')
+ }
+ const artifacts = await loadSkillBundleArtifacts(args.resourceRoot)
+ const inventory = await inventoryManagedSkills(args)
+ const installation = inventory.installations.find((entry) => entry.id === record.id)
+ if (
+ !installation?.managed ||
+ installation.hostId !== record.hostId ||
+ installation.physicalIdentity !== record.physicalIdentity ||
+ installation.resolvedPath !== record.resolvedPath
+ ) {
+ throw new Error('managed-skill-identity-changed')
+ }
+ const current = artifacts.manifest.skills.find((entry) => entry.name === record.skillName)
+ if (!current) {
+ throw new Error('skill-bundle-entry-missing')
+ }
+ return publishBundledSkill({
+ record,
+ current,
+ releasedSnapshots: artifacts.releasedSnapshots[record.skillName] ?? [],
+ packagesRoot: artifacts.packagesRoot,
+ commit: args.commit,
+ now: args.now
+ })
+}
diff --git a/src/main/skills/skill-management-inaccessible-candidate.ts b/src/main/skills/skill-management-inaccessible-candidate.ts
new file mode 100644
index 00000000000..0123a40f5fb
--- /dev/null
+++ b/src/main/skills/skill-management-inaccessible-candidate.ts
@@ -0,0 +1,63 @@
+import type { ExecutionHostId } from '../../shared/execution-host'
+import type {
+ SkillCurrentBundleEntry,
+ SkillManagementInstallation
+} from '../../shared/skill-management'
+import type { SkillScanRoot } from './skill-discovery-sources'
+import { skillDestinationId } from './skill-installation-topology'
+
+export type ClassifiedSkillCandidate = {
+ installation: SkillManagementInstallation
+ logicalEntryType: 'directory' | 'symlink' | 'junction'
+}
+
+function errorCategory(error: unknown, fallback: string): string {
+ return error &&
+ typeof error === 'object' &&
+ 'code' in error &&
+ (error.code === 'EACCES' || error.code === 'EPERM')
+ ? 'skill-candidate-access-denied'
+ : fallback
+}
+
+export function createInaccessibleSkillCandidate(args: {
+ root: SkillScanRoot
+ name: string
+ current: SkillCurrentBundleEntry
+ hostId: ExecutionHostId
+ unresolvedPath: string
+ error: unknown
+ fallbackErrorCategory?: string
+}): ClassifiedSkillCandidate {
+ return {
+ logicalEntryType: 'directory',
+ installation: {
+ id: skillDestinationId(args.hostId, args.unresolvedPath, args.name),
+ hostId: args.hostId,
+ name: args.name,
+ description: null,
+ rootId: args.root.id,
+ providers: args.root.providers,
+ unresolvedPath: args.unresolvedPath,
+ resolvedPath: null,
+ physicalIdentity: null,
+ topology: 'read-only',
+ status: 'inaccessible',
+ eligible: false,
+ managed: false,
+ installedReleaseRevision: null,
+ installedAppVersion: null,
+ installedPackageDigest: null,
+ currentReleaseRevision: args.current.releaseRevision,
+ currentPackageDigest: args.current.packageDigest,
+ currentAppVersion: args.current.appVersion,
+ errorCategory: errorCategory(
+ args.error,
+ args.fallbackErrorCategory ?? 'skill-candidate-lstat-failed'
+ ),
+ adoptionPromptEligible: false,
+ lockCorroborated: false,
+ actionsSupported: false
+ }
+ }
+}
diff --git a/src/main/skills/skill-management-inventory.test.ts b/src/main/skills/skill-management-inventory.test.ts
new file mode 100644
index 00000000000..db85b863862
--- /dev/null
+++ b/src/main/skills/skill-management-inventory.test.ts
@@ -0,0 +1,348 @@
+import {
+ cp,
+ link,
+ lstat,
+ mkdir,
+ mkdtemp,
+ readFile,
+ rm,
+ symlink,
+ truncate,
+ unlink,
+ writeFile
+} from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join, relative, resolve } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { ManagedSkillDestination, SkillManagementLedger } from '../../shared/skill-management'
+import { emptySkillManagementLedger } from './skill-management-ledger'
+import { inventoryManagedSkills } from './skill-management-inventory'
+import { classifySkillInstallationTopology } from './skill-installation-topology'
+
+const resourceRoot = resolve('resources')
+const temporaryHomes: string[] = []
+
+async function createHome(): Promise {
+ const home = await mkdtemp(join(tmpdir(), 'orca-skill-inventory-'))
+ temporaryHomes.push(home)
+ return home
+}
+
+async function installSkill(
+ home: string,
+ root: '.agents' | '.codex',
+ name: string
+): Promise {
+ const destination = join(home, root, 'skills', name)
+ await mkdir(dirname(destination), { recursive: true })
+ await cp(resolve('skills', name), destination, { recursive: true })
+ return destination
+}
+
+afterEach(async () => {
+ await Promise.all(temporaryHomes.splice(0).map((home) => rm(home, { recursive: true })))
+})
+
+describe('managed skill inventory', () => {
+ it('recognizes an exact current canonical installation as adoptable', async () => {
+ const home = await createHome()
+ await installSkill(home, '.agents', 'orca-cli')
+
+ const inventory = await inventoryManagedSkills({
+ ledger: emptySkillManagementLedger(),
+ hostId: 'local',
+ homeDir: home,
+ resourceRoot
+ })
+ const installation = inventory.installations.find((entry) => entry.name === 'orca-cli')
+
+ expect(installation).toMatchObject({
+ topology: 'canonical-copy',
+ status: 'known-current',
+ eligible: true,
+ managed: false
+ })
+ expect(inventory.adoptionCandidateCount).toBe(1)
+ })
+
+ it('suppresses only the explicitly dismissed destination snapshot tuple', async () => {
+ const home = await createHome()
+ await installSkill(home, '.agents', 'orca-cli')
+ await installSkill(home, '.agents', 'orchestration')
+ const initial = await inventoryManagedSkills({
+ ledger: emptySkillManagementLedger(),
+ hostId: 'local',
+ homeDir: home,
+ resourceRoot
+ })
+ const dismissed = initial.installations.find((entry) => entry.name === 'orca-cli')!
+
+ const next = await inventoryManagedSkills({
+ ledger: {
+ schemaVersion: 1,
+ destinations: {},
+ dismissedAdoptionCandidates: [
+ {
+ hostId: dismissed.hostId,
+ physicalIdentity: dismissed.physicalIdentity!,
+ skillName: dismissed.name,
+ snapshotDigest: dismissed.installedPackageDigest!,
+ dismissedAt: 1
+ }
+ ]
+ },
+ hostId: 'local',
+ homeDir: home,
+ resourceRoot
+ })
+
+ expect(next.installations.find((entry) => entry.name === 'orca-cli')).toMatchObject({
+ adoptionPromptEligible: false
+ })
+ expect(next.installations.find((entry) => entry.name === 'orchestration')).toMatchObject({
+ adoptionPromptEligible: true
+ })
+ })
+
+ it('does not adopt a registry snapshot absent from the release mapping', async () => {
+ const home = await createHome()
+ await installSkill(home, '.agents', 'orca-cli')
+ const isolatedResources = join(home, 'resources')
+ await cp(resolve('resources/skills'), join(isolatedResources, 'skills'), { recursive: true })
+ const mappingPath = join(isolatedResources, 'skills', 'release-mapping.json')
+ const mapping = JSON.parse(await readFile(mappingPath, 'utf8')) as {
+ releases: { skills: Record }[]
+ }
+ const manifest = JSON.parse(
+ await readFile(join(isolatedResources, 'skills', 'current-manifest.json'), 'utf8')
+ ) as { skills: { name: string; releaseRevision: number }[] }
+ const currentRevision = manifest.skills.find(
+ (skill) => skill.name === 'orca-cli'
+ )!.releaseRevision
+ for (const release of mapping.releases) {
+ if (release.skills['orca-cli'] === currentRevision) {
+ delete release.skills['orca-cli']
+ }
+ }
+ await writeFile(mappingPath, JSON.stringify(mapping))
+
+ const inventory = await inventoryManagedSkills({
+ ledger: emptySkillManagementLedger(),
+ hostId: 'local',
+ homeDir: home,
+ resourceRoot: isolatedResources
+ })
+
+ expect(inventory.installations.find((entry) => entry.name === 'orca-cli')).toMatchObject({
+ status: 'unknown',
+ eligible: false
+ })
+ })
+
+ it('recognizes exact hardlinked files because publication replaces directory entries', async () => {
+ const home = await createHome()
+ const destination = await installSkill(home, '.agents', 'orca-cli')
+ const skillMarkdown = join(destination, 'SKILL.md')
+ const alias = join(home, 'hardlink-alias')
+ await link(skillMarkdown, alias)
+
+ const inventory = await inventoryManagedSkills({
+ ledger: emptySkillManagementLedger(),
+ hostId: 'local',
+ homeDir: home,
+ resourceRoot
+ })
+ // Why: staged publish uses rename/temp+rename, which breaks the hardlink
+ // instead of writing through to the other linked file.
+ expect(inventory.installations.find((entry) => entry.name === 'orca-cli')).toMatchObject({
+ status: 'known-current',
+ eligible: true
+ })
+ await unlink(alias)
+ })
+
+ it('treats CRLF-only installer output as the same released text snapshot', async () => {
+ const home = await createHome()
+ const destination = await installSkill(home, '.agents', 'orca-cli')
+ const skillPath = join(destination, 'SKILL.md')
+ const source = await readFile(skillPath, 'utf8')
+ await writeFile(skillPath, source.replace(/\n/g, '\r\n'))
+
+ const inventory = await inventoryManagedSkills({
+ ledger: emptySkillManagementLedger(),
+ hostId: 'local',
+ homeDir: home,
+ resourceRoot
+ })
+
+ expect(inventory.installations.find((entry) => entry.name === 'orca-cli')?.status).toBe(
+ 'known-current'
+ )
+ })
+
+ it('fails closed after an adopted package is edited', async () => {
+ const home = await createHome()
+ const destination = await installSkill(home, '.agents', 'orca-cli')
+ const initial = await inventoryManagedSkills({
+ ledger: emptySkillManagementLedger(),
+ hostId: 'local',
+ homeDir: home,
+ resourceRoot
+ })
+ const installation = initial.installations.find((entry) => entry.name === 'orca-cli')
+ expect(installation?.physicalIdentity).toBeTruthy()
+ expect(installation?.installedPackageDigest).toBeTruthy()
+ const record: ManagedSkillDestination = {
+ id: installation!.id,
+ hostId: 'local',
+ homeIdentity: home,
+ rootId: 'home-agents',
+ unresolvedPath: destination,
+ resolvedPath: destination,
+ physicalIdentity: installation!.physicalIdentity!,
+ entryType: 'directory',
+ skillName: 'orca-cli',
+ source: 'stablyai/orca',
+ sourcePath: 'skills/orca-cli',
+ sourceRef: null,
+ installedReleaseRevision: installation!.installedReleaseRevision!,
+ installedPackageDigest: installation!.installedPackageDigest!,
+ installedFiles: [],
+ lastWrittenPackageDigest: null,
+ lastAttemptedBundleFingerprint: null,
+ lastOutcome: 'adopted',
+ lastErrorCategory: null,
+ adoptedFrom: 'exact-snapshot',
+ adoptedAt: 1,
+ updatedAt: 1
+ }
+ const ledger: SkillManagementLedger = {
+ schemaVersion: 1,
+ destinations: { [record.id]: record },
+ dismissedAdoptionCandidates: []
+ }
+ await writeFile(join(destination, 'extra.txt'), 'local edit')
+
+ const inventory = await inventoryManagedSkills({
+ ledger,
+ hostId: 'local',
+ homeDir: home,
+ resourceRoot
+ })
+
+ expect(inventory.installations.find((entry) => entry.name === 'orca-cli')).toMatchObject({
+ status: 'modified',
+ eligible: false,
+ managed: false
+ })
+ })
+
+ it('bounds one oversized candidate without hiding healthy siblings', async () => {
+ const home = await createHome()
+ const oversized = await installSkill(home, '.agents', 'orca-cli')
+ await installSkill(home, '.agents', 'orchestration')
+ const oversizedFile = join(oversized, 'oversized.bin')
+ await writeFile(oversizedFile, '')
+ await truncate(oversizedFile, 4 * 1024 * 1024 + 1)
+
+ const inventory = await inventoryManagedSkills({
+ ledger: emptySkillManagementLedger(),
+ hostId: 'local',
+ homeDir: home,
+ resourceRoot
+ })
+
+ expect(inventory.installations.find((entry) => entry.name === 'orca-cli')).toMatchObject({
+ status: 'unknown',
+ errorCategory: 'skill-package-file-size-limit'
+ })
+ expect(inventory.installations.find((entry) => entry.name === 'orchestration')).toMatchObject({
+ status: 'known-current',
+ eligible: true
+ })
+ })
+
+ it('classifies one candidate lstat failure without rejecting healthy siblings', async () => {
+ const home = await createHome()
+ await installSkill(home, '.agents', 'orca-cli')
+
+ const inventory = await inventoryManagedSkills({
+ ledger: emptySkillManagementLedger(),
+ hostId: 'local',
+ homeDir: home,
+ resourceRoot,
+ candidateLstat: async (path) => {
+ if (path.includes(`${join(home, '.codex')}`) && path.endsWith('orca-cli')) {
+ throw Object.assign(new Error('denied'), { code: 'EACCES' })
+ }
+ return lstat(path)
+ }
+ })
+
+ expect(
+ inventory.installations.find(
+ (entry) => entry.name === 'orca-cli' && entry.status === 'inaccessible'
+ )
+ ).toMatchObject({ errorCategory: 'skill-candidate-access-denied' })
+ expect(
+ inventory.installations.find(
+ (entry) => entry.name === 'orca-cli' && entry.status === 'known-current'
+ )
+ ).toBeTruthy()
+ })
+
+ it('classifies a topology race without rejecting healthy siblings', async () => {
+ const home = await createHome()
+ await installSkill(home, '.agents', 'orca-cli')
+ await installSkill(home, '.codex', 'orca-cli')
+
+ const inventory = await inventoryManagedSkills({
+ ledger: emptySkillManagementLedger(),
+ hostId: 'local',
+ homeDir: home,
+ resourceRoot,
+ classifyTopology: async (root, path, canonicalRootPath) => {
+ if (root.id === 'home-codex' && path.endsWith('orca-cli')) {
+ throw Object.assign(new Error('raced'), { code: 'EIO' })
+ }
+ return classifySkillInstallationTopology(root, path, canonicalRootPath)
+ }
+ })
+
+ expect(
+ inventory.installations.find(
+ (entry) => entry.name === 'orca-cli' && entry.status === 'inaccessible'
+ )
+ ).toMatchObject({ errorCategory: 'skill-candidate-topology-failed' })
+ expect(
+ inventory.installations.find(
+ (entry) => entry.name === 'orca-cli' && entry.status === 'known-current'
+ )
+ ).toBeTruthy()
+ })
+
+ it.runIf(process.platform !== 'win32')(
+ 'deduplicates a provider alias into its canonical physical copy',
+ async () => {
+ const home = await createHome()
+ const destination = await installSkill(home, '.agents', 'orca-cli')
+ const alias = join(home, '.claude', 'skills', 'orca-cli')
+ await mkdir(dirname(alias), { recursive: true })
+ await symlink(relative(dirname(alias), destination), alias)
+
+ const inventory = await inventoryManagedSkills({
+ ledger: emptySkillManagementLedger(),
+ hostId: 'local',
+ homeDir: home,
+ resourceRoot
+ })
+ const matches = inventory.installations.filter((entry) => entry.name === 'orca-cli')
+
+ expect(matches).toHaveLength(1)
+ expect(matches[0]).toMatchObject({
+ topology: 'canonical-copy',
+ providers: expect.arrayContaining(['agent-skills', 'codex', 'claude'])
+ })
+ }
+ )
+})
diff --git a/src/main/skills/skill-management-inventory.ts b/src/main/skills/skill-management-inventory.ts
new file mode 100644
index 00000000000..a4f754a7052
--- /dev/null
+++ b/src/main/skills/skill-management-inventory.ts
@@ -0,0 +1,301 @@
+import type { Stats } from 'node:fs'
+import { lstat, realpath } from 'node:fs/promises'
+import { join, resolve } from 'node:path'
+import type { ExecutionHostId } from '../../shared/execution-host'
+import { summarizeSkillMarkdown } from '../../shared/skill-metadata'
+import type {
+ SkillCurrentBundleEntry,
+ SkillManagementInstallation,
+ SkillManagementInventory,
+ SkillManagementLedger,
+ SkillManagementStatus
+} from '../../shared/skill-management'
+import { buildSkillDiscoverySources, type SkillScanRoot } from './skill-discovery-sources'
+import { loadSkillBundleArtifacts, type SkillBundleArtifacts } from './skill-bundle-artifacts'
+import { matchingReleasedSnapshot, observeSkillPackage } from './skill-package-identity'
+import { skillAdoptionDismissalKey } from './skill-management-ledger'
+import {
+ createInaccessibleSkillCandidate,
+ type ClassifiedSkillCandidate
+} from './skill-management-inaccessible-candidate'
+import {
+ classifySkillInstallationTopology,
+ type ClassifiedSkillTopology,
+ normalizedSkillIdentityPath,
+ skillDestinationId,
+ skillTopologyPriority
+} from './skill-installation-topology'
+import { readOfficialSkillsCliLockEntries } from './skills-cli-lock-provenance'
+
+type CandidateLstat = (path: string) => Promise
+type CandidateTopology = (
+ root: SkillScanRoot,
+ unresolvedPath: string,
+ canonicalRootPath: string
+) => Promise
+
+function knownStatus(
+ managed: boolean,
+ installedRevision: number,
+ currentRevision: number
+): SkillManagementStatus {
+ if (installedRevision > currentRevision) {
+ return 'newer-known'
+ }
+ if (installedRevision < currentRevision) {
+ return managed ? 'managed-update-available' : 'known-update-available'
+ }
+ return managed ? 'managed-current' : 'known-current'
+}
+
+async function classifyCandidate(args: {
+ root: SkillScanRoot
+ name: string
+ current: SkillCurrentBundleEntry
+ artifacts: SkillBundleArtifacts
+ ledger: SkillManagementLedger
+ hostId: ExecutionHostId
+ homeIdentity: string
+ canonicalRootPath: string
+ lockCorroboratedNames: ReadonlySet
+ actionsSupported: boolean
+ candidateLstat: CandidateLstat
+ classifyTopology: CandidateTopology
+}): Promise {
+ const unresolvedPath = join(args.root.path, args.name)
+ try {
+ await args.candidateLstat(unresolvedPath)
+ } catch (error) {
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
+ return null
+ }
+ return createInaccessibleSkillCandidate({ ...args, unresolvedPath, error })
+ }
+ let topology: ClassifiedSkillTopology
+ try {
+ topology = await args.classifyTopology(args.root, unresolvedPath, args.canonicalRootPath)
+ } catch (error) {
+ return createInaccessibleSkillCandidate({
+ ...args,
+ unresolvedPath,
+ error,
+ fallbackErrorCategory: 'skill-candidate-topology-failed'
+ })
+ }
+ const base = {
+ hostId: args.hostId,
+ name: args.name,
+ description: null,
+ rootId: args.root.id,
+ providers: args.root.providers,
+ unresolvedPath,
+ resolvedPath: topology.resolvedPath,
+ physicalIdentity: topology.identity,
+ topology: topology.topology,
+ currentReleaseRevision: args.current.releaseRevision,
+ currentPackageDigest: args.current.packageDigest,
+ currentAppVersion: args.current.appVersion,
+ errorCategory: topology.errorCategory,
+ adoptionPromptEligible: false,
+ lockCorroborated: args.root.id === 'home-agents' && args.lockCorroboratedNames.has(args.name),
+ actionsSupported: args.hostId === 'local' && args.actionsSupported
+ }
+ if (!topology.resolvedPath || !topology.identity) {
+ return {
+ logicalEntryType: topology.entryType,
+ installation: {
+ ...base,
+ id: skillDestinationId(args.hostId, unresolvedPath, args.name),
+ status: 'inaccessible',
+ eligible: false,
+ managed: false,
+ installedReleaseRevision: null,
+ installedAppVersion: null,
+ installedPackageDigest: null
+ }
+ }
+ }
+
+ const id = skillDestinationId(args.hostId, unresolvedPath, args.name)
+ const managedRecord = args.ledger.destinations[id]
+ if (topology.topology === 'external-link' || topology.topology === 'read-only') {
+ return {
+ logicalEntryType: topology.entryType,
+ installation: {
+ ...base,
+ id,
+ status: 'externally-managed',
+ eligible: false,
+ managed: false,
+ installedReleaseRevision: null,
+ installedAppVersion: null,
+ installedPackageDigest: null
+ }
+ }
+ }
+
+ try {
+ const observed = await observeSkillPackage(topology.resolvedPath)
+ const skillMarkdown = observed.files.find((file) => file.path === 'SKILL.md')
+ const description = skillMarkdown
+ ? summarizeSkillMarkdown(skillMarkdown.bytes.toString('utf8')).description
+ : null
+ const match = matchingReleasedSnapshot(
+ observed,
+ args.artifacts.releasedSnapshots[args.name] ?? []
+ )
+ const managed = Boolean(
+ managedRecord &&
+ managedRecord.hostId === args.hostId &&
+ managedRecord.homeIdentity === args.homeIdentity &&
+ managedRecord.physicalIdentity === topology.identity &&
+ match &&
+ managedRecord.installedPackageDigest === match.packageDigest
+ )
+ return {
+ logicalEntryType: topology.entryType,
+ installation: {
+ ...base,
+ description,
+ id,
+ status: match
+ ? managed && managedRecord?.lastOutcome === 'failed'
+ ? 'update-failed'
+ : knownStatus(managed, match.releaseRevision, args.current.releaseRevision)
+ : managedRecord
+ ? 'modified'
+ : 'unknown',
+ eligible: Boolean(match),
+ managed,
+ installedReleaseRevision: match?.releaseRevision ?? null,
+ installedAppVersion: match
+ ? (args.artifacts.releasedAppVersions[args.name]?.[match.releaseRevision] ?? null)
+ : null,
+ installedPackageDigest: match?.packageDigest ?? observed.observedDigest
+ }
+ }
+ } catch (error) {
+ return {
+ logicalEntryType: topology.entryType,
+ installation: {
+ ...base,
+ id,
+ status: managedRecord ? 'modified' : 'unknown',
+ eligible: false,
+ managed: false,
+ installedReleaseRevision: null,
+ installedAppVersion: null,
+ installedPackageDigest: null,
+ errorCategory: error instanceof Error ? error.message : 'read-failed'
+ }
+ }
+ }
+}
+
+export async function inventoryManagedSkills(args: {
+ ledger: SkillManagementLedger
+ hostId: ExecutionHostId
+ homeDir: string
+ resourceRoot?: string
+ xdgStateHome?: string | null
+ actionsSupported?: boolean
+ candidateLstat?: CandidateLstat
+ classifyTopology?: CandidateTopology
+}): Promise {
+ const artifacts = await loadSkillBundleArtifacts(args.resourceRoot)
+ const roots = buildSkillDiscoverySources({ homeDir: args.homeDir, cwd: args.homeDir }).filter(
+ (root) => root.sourceKind === 'home'
+ )
+ const canonicalRootPath = roots.find((root) => root.id === 'home-agents')?.path
+ if (!canonicalRootPath) {
+ throw new Error('Missing canonical agent skills root')
+ }
+ const homeIdentity = normalizedSkillIdentityPath(
+ await realpath(args.homeDir).catch(() => resolve(args.homeDir))
+ )
+ const lockCorroboratedNames = await readOfficialSkillsCliLockEntries({
+ homeDir: args.homeDir,
+ xdgStateHome:
+ args.xdgStateHome === undefined
+ ? args.hostId === 'local'
+ ? process.env.XDG_STATE_HOME
+ : null
+ : args.xdgStateHome
+ })
+ const candidates = await Promise.all(
+ artifacts.manifest.skills.flatMap((current) =>
+ roots.map((root) =>
+ classifyCandidate({
+ root,
+ name: current.name,
+ current,
+ artifacts,
+ ledger: args.ledger,
+ hostId: args.hostId,
+ homeIdentity,
+ canonicalRootPath,
+ lockCorroboratedNames,
+ actionsSupported: args.actionsSupported ?? true,
+ candidateLstat: args.candidateLstat ?? ((path) => lstat(path)),
+ classifyTopology: args.classifyTopology ?? classifySkillInstallationTopology
+ })
+ )
+ )
+ )
+
+ const deduped = new Map()
+ for (const candidate of candidates) {
+ if (!candidate) {
+ continue
+ }
+ const installation = candidate.installation
+ const key =
+ installation.physicalIdentity && installation.topology !== 'external-link'
+ ? `${installation.hostId}\0${installation.name}\0${installation.physicalIdentity}`
+ : `${installation.hostId}\0${installation.unresolvedPath}`
+ 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
+ }
+ }
+
+ const dismissed = new Set(args.ledger.dismissedAdoptionCandidates.map(skillAdoptionDismissalKey))
+ const installations = [...deduped.values()].sort(
+ (left, right) =>
+ left.name.localeCompare(right.name) || left.unresolvedPath.localeCompare(right.unresolvedPath)
+ )
+ for (const installation of installations) {
+ installation.adoptionPromptEligible = Boolean(
+ installation.eligible &&
+ !installation.managed &&
+ installation.physicalIdentity &&
+ installation.installedPackageDigest &&
+ !dismissed.has(
+ skillAdoptionDismissalKey({
+ hostId: installation.hostId,
+ physicalIdentity: installation.physicalIdentity,
+ skillName: installation.name,
+ snapshotDigest: installation.installedPackageDigest
+ })
+ )
+ )
+ }
+ const adoptionCandidateCount = installations.filter(
+ (installation) => installation.adoptionPromptEligible
+ ).length
+
+ return {
+ schemaVersion: 1,
+ hostId: args.hostId,
+ installations,
+ adoptionCandidateCount,
+ scannedAt: Date.now()
+ }
+}
diff --git a/src/main/skills/skill-management-ledger.test.ts b/src/main/skills/skill-management-ledger.test.ts
new file mode 100644
index 00000000000..0b7d3df0585
--- /dev/null
+++ b/src/main/skills/skill-management-ledger.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from 'vitest'
+import type { ManagedSkillDestination } from '../../shared/skill-management'
+import { normalizeSkillManagementLedger } from './skill-management-ledger'
+
+function destination(): ManagedSkillDestination {
+ const digest = 'a'.repeat(64)
+ return {
+ id: 'destination',
+ hostId: 'local',
+ homeIdentity: '/home/alice',
+ rootId: 'home-agents',
+ unresolvedPath: '/home/alice/.agents/skills/orca-cli',
+ resolvedPath: '/home/alice/.agents/skills/orca-cli',
+ physicalIdentity: '1:2',
+ entryType: 'directory',
+ skillName: 'orca-cli',
+ source: 'stablyai/orca',
+ sourcePath: 'skills/orca-cli',
+ sourceRef: null,
+ installedReleaseRevision: 1,
+ installedPackageDigest: digest,
+ installedFiles: [
+ {
+ path: 'SKILL.md',
+ size: 1,
+ executable: false,
+ classification: 'text',
+ exactSha256: digest,
+ textNormalizedSha256: digest,
+ identitySha256: digest
+ }
+ ],
+ lastWrittenPackageDigest: null,
+ lastAttemptedBundleFingerprint: digest,
+ lastOutcome: 'adopted',
+ lastErrorCategory: null,
+ adoptedFrom: 'exact-snapshot',
+ adoptedAt: 1,
+ updatedAt: 1
+ }
+}
+
+describe('skill management ledger parsing', () => {
+ it('retains a completely valid destination', () => {
+ const record = destination()
+ expect(
+ normalizeSkillManagementLedger({
+ schemaVersion: 1,
+ destinations: { [record.id]: record },
+ dismissedAdoptionCandidates: []
+ }).destinations[record.id]
+ ).toEqual(record)
+ })
+
+ it('drops a destination when any write-authority identity is malformed', () => {
+ const record = destination()
+ const malformed = { ...record, physicalIdentity: undefined }
+ expect(
+ normalizeSkillManagementLedger({
+ schemaVersion: 1,
+ destinations: { [record.id]: malformed },
+ dismissedAdoptionCandidates: []
+ }).destinations
+ ).toEqual({})
+ })
+})
diff --git a/src/main/skills/skill-management-ledger.ts b/src/main/skills/skill-management-ledger.ts
new file mode 100644
index 00000000000..83370aced45
--- /dev/null
+++ b/src/main/skills/skill-management-ledger.ts
@@ -0,0 +1,127 @@
+import type {
+ DismissedSkillAdoptionCandidate,
+ ManagedSkillDestination,
+ SkillManagementLedger
+} from '../../shared/skill-management'
+import { normalizeExecutionHostId } from '../../shared/execution-host'
+
+const SHA256 = /^[a-f0-9]{64}$/
+
+export function emptySkillManagementLedger(): SkillManagementLedger {
+ return { schemaVersion: 1, destinations: {}, dismissedAdoptionCandidates: [] }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+function isNullableString(value: unknown): value is string | null {
+ return value === null || typeof value === 'string'
+}
+
+function isFileIdentity(value: unknown): boolean {
+ return (
+ isRecord(value) &&
+ typeof value.path === 'string' &&
+ value.path.length > 0 &&
+ !value.path.startsWith('/') &&
+ !value.path.includes('\\') &&
+ !value.path.split('/').includes('..') &&
+ Number.isSafeInteger(value.size) &&
+ (value.size as number) >= 0 &&
+ typeof value.executable === 'boolean' &&
+ (value.classification === 'text' || value.classification === 'binary') &&
+ typeof value.exactSha256 === 'string' &&
+ SHA256.test(value.exactSha256) &&
+ (value.textNormalizedSha256 === null ||
+ (typeof value.textNormalizedSha256 === 'string' &&
+ SHA256.test(value.textNormalizedSha256))) &&
+ typeof value.identitySha256 === 'string' &&
+ SHA256.test(value.identitySha256)
+ )
+}
+
+function isManagedDestination(key: string, value: unknown): value is ManagedSkillDestination {
+ if (!isRecord(value)) {
+ return false
+ }
+ return (
+ value.id === key &&
+ typeof value.hostId === 'string' &&
+ normalizeExecutionHostId(value.hostId) === value.hostId &&
+ typeof value.homeIdentity === 'string' &&
+ typeof value.rootId === 'string' &&
+ typeof value.unresolvedPath === 'string' &&
+ typeof value.resolvedPath === 'string' &&
+ typeof value.physicalIdentity === 'string' &&
+ (value.entryType === 'directory' ||
+ value.entryType === 'symlink' ||
+ value.entryType === 'junction') &&
+ typeof value.skillName === 'string' &&
+ value.source === 'stablyai/orca' &&
+ typeof value.sourcePath === 'string' &&
+ isNullableString(value.sourceRef) &&
+ Number.isSafeInteger(value.installedReleaseRevision) &&
+ (value.installedReleaseRevision as number) >= 0 &&
+ typeof value.installedPackageDigest === 'string' &&
+ SHA256.test(value.installedPackageDigest) &&
+ Array.isArray(value.installedFiles) &&
+ value.installedFiles.every(isFileIdentity) &&
+ isNullableString(value.lastWrittenPackageDigest) &&
+ (value.lastWrittenPackageDigest === null || SHA256.test(value.lastWrittenPackageDigest)) &&
+ isNullableString(value.lastAttemptedBundleFingerprint) &&
+ (value.lastAttemptedBundleFingerprint === null ||
+ SHA256.test(value.lastAttemptedBundleFingerprint)) &&
+ (value.lastOutcome === 'adopted' ||
+ value.lastOutcome === 'updated' ||
+ value.lastOutcome === 'replaced' ||
+ value.lastOutcome === 'failed') &&
+ isNullableString(value.lastErrorCategory) &&
+ (value.adoptedFrom === 'orca-install' ||
+ value.adoptedFrom === 'exact-snapshot' ||
+ value.adoptedFrom === 'explicit-replacement') &&
+ typeof value.adoptedAt === 'number' &&
+ Number.isFinite(value.adoptedAt) &&
+ typeof value.updatedAt === 'number' &&
+ Number.isFinite(value.updatedAt)
+ )
+}
+
+export function normalizeSkillManagementLedger(value: unknown): SkillManagementLedger {
+ if (!isRecord(value) || value.schemaVersion !== 1 || !isRecord(value.destinations)) {
+ return emptySkillManagementLedger()
+ }
+
+ const destinations = Object.fromEntries(
+ Object.entries(value.destinations).filter((entry): entry is [string, ManagedSkillDestination] =>
+ isManagedDestination(entry[0], entry[1])
+ )
+ )
+ const dismissedAdoptionCandidates = Array.isArray(value.dismissedAdoptionCandidates)
+ ? value.dismissedAdoptionCandidates.filter(
+ (candidate): candidate is DismissedSkillAdoptionCandidate =>
+ isRecord(candidate) &&
+ typeof candidate.hostId === 'string' &&
+ typeof candidate.physicalIdentity === 'string' &&
+ typeof candidate.skillName === 'string' &&
+ typeof candidate.snapshotDigest === 'string' &&
+ typeof candidate.dismissedAt === 'number'
+ )
+ : []
+
+ return { schemaVersion: 1, destinations, dismissedAdoptionCandidates }
+}
+
+export function skillAdoptionDismissalKey(
+ candidate: Pick<
+ DismissedSkillAdoptionCandidate,
+ 'hostId' | 'physicalIdentity' | 'skillName' | 'snapshotDigest'
+ >
+): string {
+ return [
+ candidate.hostId,
+ candidate.physicalIdentity,
+ candidate.skillName,
+ candidate.snapshotDigest
+ ].join('\0')
+}
diff --git a/src/main/skills/skill-mutation-authority.test.ts b/src/main/skills/skill-mutation-authority.test.ts
new file mode 100644
index 00000000000..4039d3681e3
--- /dev/null
+++ b/src/main/skills/skill-mutation-authority.test.ts
@@ -0,0 +1,74 @@
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { canMutateManagedSkills, type SkillMutationRuntimeFacts } from './skill-mutation-authority'
+
+const temporaryRoot = join(process.cwd(), '.tmp-authority')
+
+function facts(overrides: Partial = {}): SkillMutationRuntimeFacts {
+ return {
+ isPackaged: true,
+ buildIdentity: 'stable',
+ homeDir: '/real/home',
+ userDataDir: '/real/user-data',
+ temporaryRoot,
+ ...overrides
+ }
+}
+
+describe('skill mutation authority', () => {
+ it('allows only the packaged stable build channel by default', () => {
+ expect(canMutateManagedSkills(facts())).toBe(true)
+ expect(canMutateManagedSkills(facts({ buildIdentity: 'rc' }))).toBe(false)
+ expect(canMutateManagedSkills(facts({ buildIdentity: null }))).toBe(false)
+ })
+
+ it('rejects unpackaged access to a real home', () => {
+ expect(canMutateManagedSkills(facts({ isPackaged: false, buildIdentity: null }))).toBe(false)
+ })
+
+ it('allows unpackaged tests only when both actual roots match isolated temporary roots', () => {
+ const homeDir = join(temporaryRoot, 'home')
+ const userDataDir = join(temporaryRoot, 'user-data')
+ expect(
+ canMutateManagedSkills(
+ facts({
+ isPackaged: false,
+ buildIdentity: null,
+ homeDir,
+ userDataDir,
+ isolatedDevHomeDir: homeDir,
+ isolatedDevUserDataDir: userDataDir
+ })
+ )
+ ).toBe(true)
+ })
+
+ it('rejects channel and root-isolation mismatches', () => {
+ const homeDir = join(temporaryRoot, 'home')
+ const userDataDir = join(temporaryRoot, 'user-data')
+ expect(
+ canMutateManagedSkills(
+ facts({
+ isPackaged: false,
+ buildIdentity: 'rc',
+ homeDir,
+ userDataDir,
+ isolatedDevHomeDir: '/real/home',
+ isolatedDevUserDataDir: userDataDir
+ })
+ )
+ ).toBe(false)
+ expect(
+ canMutateManagedSkills(
+ facts({
+ isPackaged: false,
+ buildIdentity: null,
+ homeDir,
+ userDataDir: homeDir,
+ isolatedDevHomeDir: homeDir,
+ isolatedDevUserDataDir: homeDir
+ })
+ )
+ ).toBe(false)
+ })
+})
diff --git a/src/main/skills/skill-mutation-authority.ts b/src/main/skills/skill-mutation-authority.ts
new file mode 100644
index 00000000000..281bf04f689
--- /dev/null
+++ b/src/main/skills/skill-mutation-authority.ts
@@ -0,0 +1,47 @@
+import { isAbsolute, relative, resolve, sep } from 'node:path'
+
+export type SkillMutationRuntimeFacts = {
+ isPackaged: boolean
+ buildIdentity: 'stable' | 'rc' | null
+ homeDir: string
+ userDataDir: string
+ isolatedDevHomeDir?: string | null
+ isolatedDevUserDataDir?: string | null
+ temporaryRoot: string
+}
+
+function samePath(left: string, right: string): boolean {
+ const normalize = (value: string): string =>
+ process.platform === 'win32' ? resolve(value).toLocaleLowerCase('en-US') : resolve(value)
+ return normalize(left) === normalize(right)
+}
+
+function isStrictChild(path: string, root: string): boolean {
+ const candidate = relative(resolve(root), resolve(path))
+ return (
+ candidate.length > 0 &&
+ candidate !== '..' &&
+ !candidate.startsWith(`..${sep}`) &&
+ !isAbsolute(candidate)
+ )
+}
+
+export function canMutateManagedSkills(facts: SkillMutationRuntimeFacts): boolean {
+ if (facts.isPackaged) {
+ // Why: the build identity is CI-pinned for official artifacts; runtime or
+ // renderer flags cannot promote an RC/custom package into a stable writer.
+ return facts.buildIdentity === 'stable'
+ }
+ if (!facts.isolatedDevHomeDir || !facts.isolatedDevUserDataDir) {
+ return false
+ }
+ return (
+ samePath(facts.homeDir, facts.isolatedDevHomeDir) &&
+ samePath(facts.userDataDir, facts.isolatedDevUserDataDir) &&
+ !samePath(facts.homeDir, facts.userDataDir) &&
+ isStrictChild(facts.homeDir, facts.temporaryRoot) &&
+ isStrictChild(facts.userDataDir, facts.temporaryRoot) &&
+ !isStrictChild(facts.homeDir, facts.userDataDir) &&
+ !isStrictChild(facts.userDataDir, facts.homeDir)
+ )
+}
diff --git a/src/main/skills/skill-orphan-transaction-sweep.ts b/src/main/skills/skill-orphan-transaction-sweep.ts
new file mode 100644
index 00000000000..6cbc86f01c6
--- /dev/null
+++ b/src/main/skills/skill-orphan-transaction-sweep.ts
@@ -0,0 +1,106 @@
+import { lstat, mkdir, readFile, readdir, realpath, rm } from 'node:fs/promises'
+import { dirname, join, resolve } from 'node:path'
+import type { SkillManagementLedger } from '../../shared/skill-management'
+import { normalizedSkillIdentityPath } from './skill-installation-topology'
+import {
+ parseSkillTransactionMarker,
+ type SkillTransactionMarker
+} from './skill-transaction-marker'
+import { RESERVED_SKILL_TRANSACTION_DIRECTORY } from './skill-transaction-paths'
+import { acquireSkillDestinationLock } from './skill-transaction-recovery'
+import { recoverTransactionToPrior } from './skill-transaction-restore'
+
+async function assertApprovedDestinationParent(
+ marker: SkillTransactionMarker,
+ rootIdentity: string
+): Promise {
+ if (normalizedSkillIdentityPath(dirname(resolve(marker.destinationPath))) !== rootIdentity) {
+ throw new Error('skill-transaction-destination-outside-root')
+ }
+ const [parentStat, parentIdentity] = await Promise.all([
+ lstat(dirname(marker.destinationPath)),
+ realpath(dirname(marker.destinationPath)).then(normalizedSkillIdentityPath)
+ ])
+ if (parentStat.isSymbolicLink() || parentIdentity !== rootIdentity) {
+ throw new Error('skill-transaction-destination-outside-root')
+ }
+}
+
+export async function sweepOrphanedSkillTransactions(
+ approvedSkillsRoot: string,
+ ledger: SkillManagementLedger
+): Promise {
+ const linkedRoot = await Promise.all([
+ lstat(approvedSkillsRoot),
+ lstat(dirname(approvedSkillsRoot))
+ ])
+ .then((entries) => entries.some((entry) => entry.isSymbolicLink()))
+ .catch(() => true)
+ if (linkedRoot) {
+ return
+ }
+ const rootIdentity = normalizedSkillIdentityPath(await realpath(approvedSkillsRoot))
+ const reservedRoots = [
+ join(dirname(approvedSkillsRoot), RESERVED_SKILL_TRANSACTION_DIRECTORY),
+ join(approvedSkillsRoot, RESERVED_SKILL_TRANSACTION_DIRECTORY)
+ ]
+ for (const reservedRoot of reservedRoots) {
+ const reservedRootSafe = await lstat(reservedRoot)
+ .then((entry) => entry.isDirectory() && !entry.isSymbolicLink())
+ .catch(() => false)
+ if (!reservedRootSafe) {
+ continue
+ }
+ const entries = await readdir(reservedRoot, { withFileTypes: true })
+ for (const entry of entries) {
+ if (!entry.isDirectory() || entry.name === 'locks') {
+ continue
+ }
+ const transactionRoot = join(reservedRoot, entry.name)
+ const marker = await readFile(join(transactionRoot, 'transaction.json'), 'utf8')
+ .then(parseSkillTransactionMarker)
+ .catch(() => null)
+ if (!marker || marker.hostId !== 'local') {
+ continue
+ }
+ try {
+ await assertApprovedDestinationParent(marker, rootIdentity)
+ } catch {
+ continue
+ }
+ const locksRoot = join(reservedRoot, 'locks')
+ await mkdir(locksRoot, { recursive: true })
+ const locksRootSafe = await lstat(locksRoot).then(
+ (lockStat) => lockStat.isDirectory() && !lockStat.isSymbolicLink()
+ )
+ if (!locksRootSafe) {
+ continue
+ }
+ let release: (() => Promise) | null = null
+ try {
+ release = await acquireSkillDestinationLock(join(locksRoot, marker.destinationId))
+ const record = ledger.destinations[marker.destinationId]
+ const committed = Boolean(
+ marker.phase === 'verified' &&
+ record?.hostId === marker.hostId &&
+ normalizedSkillIdentityPath(record.resolvedPath) ===
+ normalizedSkillIdentityPath(marker.destinationPath) &&
+ record.installedPackageDigest === marker.currentSnapshot.packageDigest
+ )
+ await recoverTransactionToPrior({
+ transactionRoot,
+ marker,
+ assertParentAuthority: () => assertApprovedDestinationParent(marker, rootIdentity),
+ currentDisposition: committed ? 'committed' : 'restore'
+ })
+ await rm(transactionRoot, { recursive: true, force: true })
+ } catch (error) {
+ if (!(error instanceof Error) || error.message !== 'skill-update-busy') {
+ throw error
+ }
+ } finally {
+ await release?.()
+ }
+ }
+ }
+}
diff --git a/src/main/skills/skill-owned-temp-publication.ts b/src/main/skills/skill-owned-temp-publication.ts
new file mode 100644
index 00000000000..23c811a3dd6
--- /dev/null
+++ b/src/main/skills/skill-owned-temp-publication.ts
@@ -0,0 +1,108 @@
+import { randomUUID } from 'node:crypto'
+import { mkdir, open, readFile, type FileHandle } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import type { SkillBundleFileIdentity } from '../../shared/skill-management'
+import { skillPhysicalIdentity } from './skill-installation-topology'
+import { describeObservedSkillFile, matchesFileIdentity } from './skill-package-identity'
+import type { PublicationCallbacks } from './skill-package-publication'
+import { retrySkillRename } from './skill-transaction-rename'
+
+export type SkillOwnedTempRuntime = {
+ readSource?: (path: string) => Promise
+ writeBytes?: (handle: FileHandle, bytes: Buffer) => Promise
+ chmodTemp?: (handle: FileHandle, mode: number) => Promise
+ syncTemp?: (handle: FileHandle) => Promise
+}
+
+export async function publishSkillFileThroughOwnedTemp(args: {
+ destinationRoot: string
+ sourcePath: string
+ expectedFile: SkillBundleFileIdentity
+ direction: 'forward' | 'rollback'
+ packagePhysicalIdentity: string
+ operationIndex: number
+ callbacks: PublicationCallbacks
+ runtime?: SkillOwnedTempRuntime
+}): Promise {
+ const livePath = join(args.destinationRoot, ...args.expectedFile.path.split('/'))
+ const temporaryRelativePath = `${args.expectedFile.path}.orca-${randomUUID()}.tmp`
+ const temporaryPath = join(args.destinationRoot, ...temporaryRelativePath.split('/'))
+ const applying = {
+ kind: 'in-place',
+ direction: args.direction,
+ physicalIdentity: args.packagePhysicalIdentity,
+ temporary: null,
+ operationIndex: args.operationIndex,
+ operationState: 'applying'
+ } as const
+ await args.callbacks.assertBoundary(applying)
+ await mkdir(dirname(livePath), { recursive: true })
+ await args.callbacks.assertBoundary(applying)
+ const mode = args.expectedFile.executable ? 0o755 : 0o644
+ const handle = await open(temporaryPath, 'wx', mode)
+ const creating = {
+ ...applying,
+ temporary: {
+ path: temporaryRelativePath,
+ physicalIdentity: skillPhysicalIdentity(temporaryPath, await handle.stat()),
+ expectedFile: args.expectedFile,
+ state: 'creating'
+ }
+ } as const
+ try {
+ // Why: partial bytes are recoverable only after the exclusively created
+ // inode is durably recorded as transaction-owned.
+ await args.callbacks.journal(creating)
+ await args.callbacks.assertBoundary(creating)
+ const bytes = await (args.runtime?.readSource ?? readFile)(args.sourcePath)
+ if (
+ !matchesFileIdentity(
+ describeObservedSkillFile(args.expectedFile.path, bytes, args.expectedFile.executable),
+ args.expectedFile
+ )
+ ) {
+ throw new Error('skill-owned-temp-source-changed')
+ }
+ await args.callbacks.assertBoundary(creating)
+ await (args.runtime?.writeBytes ?? ((target, value) => target.writeFile(value)))(handle, bytes)
+ const written = {
+ ...creating,
+ temporary: { ...creating.temporary, state: 'written' as const }
+ }
+ await args.callbacks.assertBoundary(written)
+ await args.callbacks.journal(written)
+ if (process.platform !== 'win32') {
+ await args.callbacks.assertBoundary(written)
+ await (args.runtime?.chmodTemp ?? ((target, nextMode) => target.chmod(nextMode)))(
+ handle,
+ mode
+ )
+ }
+ const ready = {
+ ...written,
+ temporary: { ...written.temporary, state: 'ready' as const }
+ }
+ await args.callbacks.assertBoundary(ready)
+ await args.callbacks.journal(ready)
+ await args.callbacks.assertBoundary(ready)
+ await (args.runtime?.syncTemp ?? ((target) => target.sync()))(handle)
+ await args.callbacks.assertBoundary(ready)
+ } finally {
+ await handle.close()
+ }
+ const ready = {
+ ...creating,
+ temporary: { ...creating.temporary, state: 'ready' as const }
+ }
+ await args.callbacks.assertBoundary(ready)
+ await retrySkillRename(temporaryPath, livePath, () => args.callbacks.assertBoundary(ready))
+ const applied = {
+ ...ready,
+ temporary: { ...ready.temporary, state: 'applied' as const }
+ }
+ await args.callbacks.assertBoundary(applied)
+ await args.callbacks.journal(applied)
+ // Why: do not discard the temp inode proof if another writer replaces the
+ // exact-content target while the applied marker is being persisted.
+ await args.callbacks.assertBoundary(applied)
+}
diff --git a/src/main/skills/skill-package-identity.ts b/src/main/skills/skill-package-identity.ts
new file mode 100644
index 00000000000..0df2be320ea
--- /dev/null
+++ b/src/main/skills/skill-package-identity.ts
@@ -0,0 +1,228 @@
+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, SkillReleasedSnapshot } from '../../shared/skill-management'
+
+type ObservedSkillFile = SkillBundleFileIdentity & { bytes: Buffer }
+
+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 {
+ 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 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: normalized && !executable ? sha256(normalized) : exactSha256,
+ bytes
+ }
+}
+
+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
+ }))
+ )
+ )
+ )
+}
+
+export 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 {
+ const files: ObservedSkillFile[] = []
+ const caseFoldedPaths = new Map()
+ let entryCount = 0
+ let totalBytes = 0
+
+ async function visit(directory: string, depth: number): Promise {
+ 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)
+ }
+ entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))
+ 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')
+ }
+ if (fileStat.size > limits.maximumSingleFileBytes) {
+ throw new Error('skill-package-file-size-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 matchingReleasedSnapshot(
+ observed: ObservedSkillPackage,
+ snapshots: readonly SkillReleasedSnapshot[]
+): SkillReleasedSnapshot | null {
+ for (const snapshot of snapshots.toReversed()) {
+ if (snapshot.files.length !== observed.files.length) {
+ continue
+ }
+ const matches = snapshot.files.every((expected, index) => {
+ const actual = observed.files[index]
+ return Boolean(actual && matchesFileIdentity(actual, expected))
+ })
+ if (matches) {
+ return snapshot
+ }
+ }
+ return null
+}
+
+export function persistedObservedFiles(observed: ObservedSkillPackage): SkillBundleFileIdentity[] {
+ return observed.files.map(({ bytes: _bytes, ...file }) => file)
+}
diff --git a/src/main/skills/skill-package-observation-limits.test.ts b/src/main/skills/skill-package-observation-limits.test.ts
new file mode 100644
index 00000000000..3cd5930c642
--- /dev/null
+++ b/src/main/skills/skill-package-observation-limits.test.ts
@@ -0,0 +1,73 @@
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { observeSkillPackage } from './skill-package-identity'
+
+const roots: string[] = []
+const baseLimits = {
+ maximumDepth: 4,
+ maximumEntries: 8,
+ maximumFiles: 4,
+ maximumSingleFileBytes: 8,
+ maximumTotalBytes: 16
+}
+
+async function root(): Promise {
+ const value = await mkdtemp(join(tmpdir(), 'orca-skill-observation-'))
+ roots.push(value)
+ return value
+}
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((value) => rm(value, { recursive: true, force: true })))
+})
+
+describe('skill package observation limits', () => {
+ it.each([
+ {
+ name: 'depth',
+ limits: { ...baseLimits, maximumDepth: 1 },
+ prepare: async (directory: string) =>
+ mkdir(join(directory, 'one', 'two'), { recursive: true }),
+ error: 'skill-package-depth-limit'
+ },
+ {
+ name: 'entries',
+ limits: { ...baseLimits, maximumEntries: 1 },
+ prepare: async (directory: string) => {
+ await writeFile(join(directory, 'a'), 'a')
+ await writeFile(join(directory, 'b'), 'b')
+ },
+ error: 'skill-package-entry-limit'
+ },
+ {
+ name: 'files',
+ limits: { ...baseLimits, maximumFiles: 1 },
+ prepare: async (directory: string) => {
+ await writeFile(join(directory, 'a'), 'a')
+ await writeFile(join(directory, 'b'), 'b')
+ },
+ error: 'skill-package-file-count-limit'
+ },
+ {
+ name: 'single file bytes',
+ limits: { ...baseLimits, maximumSingleFileBytes: 1 },
+ prepare: async (directory: string) => writeFile(join(directory, 'a'), 'ab'),
+ error: 'skill-package-file-size-limit'
+ },
+ {
+ name: 'aggregate bytes',
+ limits: { ...baseLimits, maximumTotalBytes: 2 },
+ prepare: async (directory: string) => {
+ await writeFile(join(directory, 'a'), 'ab')
+ await writeFile(join(directory, 'b'), 'c')
+ },
+ error: 'skill-package-total-size-limit'
+ }
+ ])('fails closed at the $name limit', async ({ limits, prepare, error }) => {
+ const directory = await root()
+ await prepare(directory)
+ await expect(observeSkillPackage(directory, limits)).rejects.toThrow(error)
+ })
+})
diff --git a/src/main/skills/skill-package-publication.test.ts b/src/main/skills/skill-package-publication.test.ts
new file mode 100644
index 00000000000..431479e3417
--- /dev/null
+++ b/src/main/skills/skill-package-publication.test.ts
@@ -0,0 +1,445 @@
+import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { ManagedSkillDestination, SkillReleasedSnapshot } from '../../shared/skill-management'
+import { observeSkillPackage, persistedObservedFiles } from './skill-package-identity'
+import type { SkillOwnedTempRuntime } from './skill-owned-temp-publication'
+import { publishSkillPackageInPlace } from './skill-package-publication'
+import { skillPhysicalIdentity } from './skill-installation-topology'
+import type { SkillTransactionMarker } from './skill-transaction-marker'
+import { assertTransactionOwnedState } from './skill-transaction-owned-state'
+import type { SkillPublicationState } from './skill-transaction-publication-state'
+import { retrySkillRename } from './skill-transaction-rename'
+
+const roots: string[] = []
+
+async function createPackage(root: string, values: Record): Promise {
+ await mkdir(root, { recursive: true })
+ for (const [path, contents] of Object.entries(values)) {
+ await writeFile(join(root, path), contents)
+ }
+}
+
+async function snapshot(root: string, releaseRevision: number): Promise {
+ const observed = await observeSkillPackage(root)
+ return {
+ releaseRevision,
+ packageDigest: observed.observedDigest,
+ gitTreeSha: '',
+ files: persistedObservedFiles(observed)
+ }
+}
+
+async function swapFixture() {
+ const root = await mkdtemp(join(tmpdir(), 'orca-swap-authority-'))
+ roots.push(root)
+ const live = join(root, 'skills', 'orca-cli')
+ const transactionRoot = join(root, 'transaction')
+ const stageRoot = join(transactionRoot, 'stage')
+ const backupRoot = join(transactionRoot, 'backup')
+ await createPackage(live, { 'SKILL.md': 'old entry', 'asset.txt': 'old asset' })
+ await createPackage(stageRoot, { 'SKILL.md': 'new entry', 'asset.txt': 'new asset' })
+ await cp(live, backupRoot, { recursive: true })
+ const prior = await snapshot(live, 1)
+ const current = await snapshot(stageRoot, 2)
+ const record = {
+ resolvedPath: live,
+ physicalIdentity: skillPhysicalIdentity(live, await stat(live))
+ } as ManagedSkillDestination
+ let marker: SkillTransactionMarker = {
+ schemaVersion: 1,
+ transactionId: 'test',
+ createdAt: 1,
+ destinationId: '000000000000000000000000',
+ destinationPath: live,
+ physicalIdentity: record.physicalIdentity,
+ hostId: 'local',
+ skillName: 'orca-cli',
+ phase: 'publishing',
+ stagePhysicalIdentity: skillPhysicalIdentity(stageRoot, await stat(stageRoot)),
+ backupPhysicalIdentity: skillPhysicalIdentity(backupRoot, await stat(backupRoot)),
+ stageBuild: {
+ state: 'ready',
+ physicalIdentity: skillPhysicalIdentity(stageRoot, await stat(stageRoot)),
+ operationIndex: current.files.length,
+ pendingPath: null,
+ activeFile: null
+ },
+ backupBuild: {
+ state: 'ready',
+ physicalIdentity: skillPhysicalIdentity(backupRoot, await stat(backupRoot)),
+ operationIndex: prior.files.length,
+ pendingPath: null,
+ activeFile: null
+ },
+ publication: null,
+ priorSnapshot: prior,
+ currentSnapshot: current
+ }
+ const history: SkillPublicationState[] = []
+ const journal = async (publication: SkillPublicationState): Promise => {
+ marker = { ...marker, publication }
+ history.push(publication)
+ }
+ const assertBoundary = async (
+ publication: SkillPublicationState,
+ expectedLiveState?: Awaited>
+ ): Promise => {
+ const liveState = await assertTransactionOwnedState({
+ transactionRoot,
+ marker: { ...marker, publication },
+ assertParent: async () => undefined,
+ allowAppliedOperation: false
+ })
+ if (expectedLiveState && liveState !== expectedLiveState) {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ }
+ return {
+ live,
+ transactionRoot,
+ stageRoot,
+ oldLiveRoot: join(transactionRoot, 'old-live'),
+ record,
+ prior,
+ current: { ...current, name: 'orca-cli', sourcePath: '', appVersion: '1.0.0' },
+ callbacks: { journal, assertBoundary },
+ history,
+ marker: () => marker
+ }
+}
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('skill package publication boundaries', () => {
+ it('rejects a temp replacement made while the creating journal is awaited', async () => {
+ const fixture = await swapFixture()
+ let replaced = false
+ const journal = async (publication: SkillPublicationState): Promise => {
+ await fixture.callbacks.journal(publication)
+ if (
+ !replaced &&
+ publication.kind === 'in-place' &&
+ publication.temporary?.state === 'creating'
+ ) {
+ replaced = true
+ const temporaryPath = join(fixture.live, ...publication.temporary.path.split('/'))
+ await rm(temporaryPath)
+ await cp(join(fixture.stageRoot, publication.temporary.expectedFile.path), temporaryPath)
+ }
+ }
+
+ await expect(
+ publishSkillPackageInPlace({
+ record: fixture.record,
+ prior: fixture.prior,
+ current: fixture.current,
+ stageRoot: fixture.stageRoot,
+ callbacks: { ...fixture.callbacks, journal }
+ })
+ ).rejects.toThrow('skill-transaction-recovery-required')
+ expect(replaced).toBe(true)
+ expect(await readFile(join(fixture.live, 'asset.txt'), 'utf8')).toBe('old asset')
+ })
+
+ it.each([
+ {
+ boundary: 'source read',
+ runtime: (): SkillOwnedTempRuntime => ({
+ readSource: async (source) => {
+ const bytes = await readFile(source)
+ await writeFile(source, 'independent source edit')
+ return bytes
+ }
+ })
+ },
+ {
+ boundary: 'handle write',
+ runtime: (fixture: Awaited>): SkillOwnedTempRuntime => ({
+ writeBytes: async (handle, bytes) => {
+ await handle.writeFile(bytes)
+ await writeFile(join(fixture.live, 'SKILL.md'), 'independent live edit')
+ }
+ })
+ },
+ {
+ boundary: 'chmod',
+ runtime: (fixture: Awaited>): SkillOwnedTempRuntime => ({
+ chmodTemp: async (handle, mode) => {
+ await handle.chmod(mode)
+ await writeFile(join(fixture.live, 'SKILL.md'), 'independent live edit')
+ }
+ })
+ },
+ {
+ boundary: 'sync',
+ runtime: (fixture: Awaited>): SkillOwnedTempRuntime => ({
+ syncTemp: async (handle) => {
+ await handle.sync()
+ await writeFile(join(fixture.live, 'SKILL.md'), 'independent live edit')
+ }
+ })
+ }
+ ])('rechecks authority after the $boundary await', async ({ runtime }) => {
+ const fixture = await swapFixture()
+ await expect(
+ publishSkillPackageInPlace({
+ record: fixture.record,
+ prior: fixture.prior,
+ current: fixture.current,
+ stageRoot: fixture.stageRoot,
+ callbacks: fixture.callbacks,
+ ownedTempRuntime: runtime(fixture)
+ })
+ ).rejects.toThrow('skill-transaction-recovery-required')
+ })
+
+ it('retains creating evidence when handle write mutates then throws', async () => {
+ const fixture = await swapFixture()
+ await expect(
+ publishSkillPackageInPlace({
+ record: fixture.record,
+ prior: fixture.prior,
+ current: fixture.current,
+ stageRoot: fixture.stageRoot,
+ callbacks: fixture.callbacks,
+ ownedTempRuntime: {
+ writeBytes: async (handle, bytes) => {
+ await handle.write(bytes.subarray(0, 1))
+ throw new Error('injected-write-failure')
+ }
+ }
+ })
+ ).rejects.toThrow('injected-write-failure')
+ expect(fixture.history.at(-1)).toMatchObject({
+ kind: 'in-place',
+ temporary: { state: 'creating' }
+ })
+ expect(await readFile(join(fixture.live, 'asset.txt'), 'utf8')).toBe('old asset')
+ })
+
+ it('journals each forward temp inode through creating, written, ready, and applied', async () => {
+ const fixture = await swapFixture()
+ await publishSkillPackageInPlace({
+ record: fixture.record,
+ prior: fixture.prior,
+ current: fixture.current,
+ stageRoot: fixture.stageRoot,
+ callbacks: fixture.callbacks
+ })
+
+ const states = fixture.history.flatMap((publication) =>
+ publication.kind === 'in-place' && publication.temporary ? [publication.temporary.state] : []
+ )
+ expect(states).toEqual([
+ 'creating',
+ 'written',
+ 'ready',
+ 'applied',
+ 'creating',
+ 'written',
+ 'ready',
+ 'applied'
+ ])
+ })
+
+ it('rejects an exact wrong-inode target replacement after temp rename', async () => {
+ const fixture = await swapFixture()
+ let replaced = false
+ const journal = async (publication: SkillPublicationState): Promise => {
+ await fixture.callbacks.journal(publication)
+ if (
+ !replaced &&
+ publication.kind === 'in-place' &&
+ publication.temporary?.state === 'applied'
+ ) {
+ replaced = true
+ const target = join(fixture.live, publication.temporary.expectedFile.path)
+ await rm(target)
+ await cp(join(fixture.stageRoot, publication.temporary.expectedFile.path), target)
+ }
+ }
+
+ await expect(
+ publishSkillPackageInPlace({
+ record: fixture.record,
+ prior: fixture.prior,
+ current: fixture.current,
+ stageRoot: fixture.stageRoot,
+ callbacks: { ...fixture.callbacks, journal }
+ })
+ ).rejects.toThrow('skill-transaction-recovery-required')
+ expect(replaced).toBe(true)
+ })
+
+ it('does not overwrite an in-place edit made between ordered file operations', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-publication-boundary-'))
+ roots.push(root)
+ const live = join(root, 'skills', 'orca-cli')
+ const transactionRoot = join(root, 'transaction')
+ const stageRoot = join(transactionRoot, 'stage')
+ const backupRoot = join(transactionRoot, 'backup')
+ await createPackage(live, { 'SKILL.md': 'old entry', 'asset.txt': 'old asset' })
+ await createPackage(stageRoot, { 'SKILL.md': 'new entry', 'asset.txt': 'new asset' })
+ await cp(live, backupRoot, { recursive: true })
+ const prior = await snapshot(live, 1)
+ const current = await snapshot(stageRoot, 2)
+ const record = {
+ resolvedPath: live,
+ physicalIdentity: skillPhysicalIdentity(live, await stat(live))
+ } as ManagedSkillDestination
+ let marker: SkillTransactionMarker = {
+ schemaVersion: 1,
+ transactionId: 'test',
+ createdAt: 1,
+ destinationId: '000000000000000000000000',
+ destinationPath: live,
+ physicalIdentity: record.physicalIdentity,
+ hostId: 'local',
+ skillName: 'orca-cli',
+ phase: 'publishing',
+ stagePhysicalIdentity: skillPhysicalIdentity(stageRoot, await stat(stageRoot)),
+ backupPhysicalIdentity: skillPhysicalIdentity(backupRoot, await stat(backupRoot)),
+ stageBuild: {
+ state: 'ready',
+ physicalIdentity: skillPhysicalIdentity(stageRoot, await stat(stageRoot)),
+ operationIndex: current.files.length,
+ pendingPath: null,
+ activeFile: null
+ },
+ backupBuild: {
+ state: 'ready',
+ physicalIdentity: skillPhysicalIdentity(backupRoot, await stat(backupRoot)),
+ operationIndex: prior.files.length,
+ pendingPath: null,
+ activeFile: null
+ },
+ publication: null,
+ priorSnapshot: prior,
+ currentSnapshot: current
+ }
+ const journal = async (publication: SkillPublicationState): Promise => {
+ marker = { ...marker, publication }
+ }
+ let edited = false
+ const assertBoundary = async (publication: SkillPublicationState): Promise => {
+ if (
+ !edited &&
+ publication.kind === 'in-place' &&
+ publication.operationState === 'applying' &&
+ publication.operationIndex === 1
+ ) {
+ edited = true
+ await writeFile(join(live, 'SKILL.md'), 'independent edit')
+ }
+ await assertTransactionOwnedState({
+ transactionRoot,
+ marker: { ...marker, publication },
+ assertParent: async () => undefined,
+ allowAppliedOperation: false
+ })
+ }
+
+ await expect(
+ publishSkillPackageInPlace({
+ record,
+ current: { ...current, name: 'orca-cli', sourcePath: '', appVersion: '1.0.0' },
+ prior,
+ stageRoot,
+ callbacks: { journal, assertBoundary }
+ })
+ ).rejects.toThrow('skill-transaction-recovery-required')
+
+ expect(await readFile(join(live, 'asset.txt'), 'utf8')).toBe('new asset')
+ expect(await readFile(join(live, 'SKILL.md'), 'utf8')).toBe('independent edit')
+ expect(marker.publication).toMatchObject({ operationIndex: 1, operationState: 'applying' })
+ })
+
+ it('revalidates after a sharing-violation retry window', async () => {
+ let renameAttempts = 0
+ let boundaryChecks = 0
+ let changedDuringWait = false
+ const renameEntry = async (): Promise => {
+ renameAttempts += 1
+ if (renameAttempts === 1) {
+ throw Object.assign(new Error('busy'), { code: 'EPERM' })
+ }
+ }
+ await expect(
+ retrySkillRename(
+ 'source',
+ 'destination',
+ async () => {
+ boundaryChecks += 1
+ if (changedDuringWait) {
+ throw new Error('skill-content-changed')
+ }
+ },
+ renameEntry,
+ async () => {
+ changedDuringWait = true
+ }
+ )
+ ).rejects.toThrow('skill-content-changed')
+ expect(renameAttempts).toBe(1)
+ expect(boundaryChecks).toBe(2)
+ })
+
+ it.each(['replaced', 'missing'] as const)(
+ 'does not rename a %s journaled temp file',
+ async (mutation) => {
+ const fixture = await swapFixture()
+ let activeMarker = fixture.marker()
+ let mutated = false
+ const journal = async (publication: SkillPublicationState): Promise => {
+ activeMarker = { ...activeMarker, publication }
+ }
+ const assertBoundary = async (publication: SkillPublicationState): Promise => {
+ if (!mutated && publication.kind === 'in-place' && publication.temporary) {
+ mutated = true
+ const temporaryPath = join(fixture.live, ...publication.temporary.path.split('/'))
+ await rm(temporaryPath)
+ if (mutation === 'replaced') {
+ await cp(
+ join(fixture.stageRoot, publication.temporary.expectedFile.path),
+ temporaryPath
+ )
+ }
+ }
+ await assertTransactionOwnedState({
+ transactionRoot: fixture.transactionRoot,
+ marker: { ...activeMarker, publication },
+ assertParent: async () => undefined,
+ allowAppliedOperation: false
+ })
+ }
+ await expect(
+ publishSkillPackageInPlace({
+ record: fixture.record,
+ prior: fixture.prior,
+ current: fixture.current,
+ stageRoot: fixture.stageRoot,
+ callbacks: { journal, assertBoundary }
+ })
+ ).rejects.toThrow('skill-transaction-recovery-required')
+ expect(await readFile(join(fixture.live, 'asset.txt'), 'utf8')).toBe('old asset')
+ if (mutation === 'replaced') {
+ expect(
+ await readFile(
+ join(
+ fixture.live,
+ ...(activeMarker.publication?.kind === 'in-place' &&
+ activeMarker.publication.temporary
+ ? activeMarker.publication.temporary.path.split('/')
+ : [])
+ ),
+ 'utf8'
+ )
+ ).toBe('new asset')
+ }
+ }
+ )
+})
diff --git a/src/main/skills/skill-package-publication.ts b/src/main/skills/skill-package-publication.ts
new file mode 100644
index 00000000000..3a118ed098d
--- /dev/null
+++ b/src/main/skills/skill-package-publication.ts
@@ -0,0 +1,216 @@
+import { rmdir, unlink } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import type {
+ ManagedSkillDestination,
+ SkillCurrentBundleEntry,
+ SkillReleasedSnapshot
+} from '../../shared/skill-management'
+import {
+ skillInPlaceOperations,
+ type SkillPublicationState
+} from './skill-transaction-publication-state'
+import type { SkillTransactionLiveState } from './skill-transaction-owned-state'
+import { publishSkillFileThroughOwnedTemp } from './skill-owned-temp-publication'
+import type { SkillOwnedTempRuntime } from './skill-owned-temp-publication'
+import { retrySkillRename, type SkillRenameRuntime } from './skill-transaction-rename'
+
+export type PublicationCallbacks = {
+ journal: (state: SkillPublicationState) => Promise
+ assertBoundary: (
+ state: SkillPublicationState,
+ expectedLiveState?: SkillTransactionLiveState
+ ) => Promise
+}
+
+export type SkillPackageSwapOutcome =
+ | { kind: 'complete' }
+ | { kind: 'pre-mutation-sharing-violation'; error: unknown }
+
+export function destinationUsesCrlf(record: ManagedSkillDestination): boolean {
+ return record.installedFiles.some(
+ (file) =>
+ file.classification === 'text' &&
+ file.textNormalizedSha256 !== null &&
+ file.exactSha256 !== file.textNormalizedSha256
+ )
+}
+
+export async function publishSkillPackageInPlace(args: {
+ record: ManagedSkillDestination
+ current: SkillCurrentBundleEntry
+ prior: SkillReleasedSnapshot
+ stageRoot: string
+ callbacks: PublicationCallbacks
+ ownedTempRuntime?: SkillOwnedTempRuntime
+}): Promise {
+ const operations = skillInPlaceOperations(args.prior, args.current)
+ const currentByPath = new Map(args.current.files.map((file) => [file.path, file]))
+ await args.callbacks.journal({
+ kind: 'in-place',
+ direction: 'forward',
+ physicalIdentity: args.record.physicalIdentity,
+ temporary: null,
+ operationIndex: 0,
+ operationState: 'ready'
+ })
+ for (const [operationIndex, operation] of operations.entries()) {
+ const applying = {
+ kind: 'in-place',
+ direction: 'forward',
+ physicalIdentity: args.record.physicalIdentity,
+ temporary: null,
+ operationIndex,
+ operationState: 'applying'
+ } as const
+ await args.callbacks.journal(applying)
+ const livePath = join(args.record.resolvedPath, ...operation.path.split('/'))
+ if (operation.kind === 'publish') {
+ const file = currentByPath.get(operation.path)
+ if (!file) {
+ throw new Error('skill-publication-operation-invalid')
+ }
+ await publishSkillFileThroughOwnedTemp({
+ destinationRoot: args.record.resolvedPath,
+ sourcePath: join(args.stageRoot, ...file.path.split('/')),
+ expectedFile: file,
+ direction: 'forward',
+ packagePhysicalIdentity: args.record.physicalIdentity,
+ operationIndex,
+ callbacks: args.callbacks,
+ runtime: args.ownedTempRuntime
+ })
+ } else {
+ await args.callbacks.assertBoundary(applying)
+ await unlink(livePath)
+ }
+ const ready = {
+ kind: 'in-place',
+ direction: 'forward',
+ physicalIdentity: args.record.physicalIdentity,
+ temporary: null,
+ operationIndex: operationIndex + 1,
+ operationState: 'ready'
+ } as const
+ await args.callbacks.journal(ready)
+ if (operation.kind === 'remove') {
+ let parent = dirname(livePath)
+ while (parent !== args.record.resolvedPath) {
+ await args.callbacks.assertBoundary(ready)
+ await rmdir(parent).catch(() => undefined)
+ parent = dirname(parent)
+ }
+ }
+ }
+}
+
+async function publishPackageSwap(args: {
+ record: ManagedSkillDestination
+ stageRoot: string
+ transactionRoot: string
+ callbacks: PublicationCallbacks
+ renameRuntime?: SkillRenameRuntime
+}): Promise {
+ const oldLiveRoot = join(args.transactionRoot, 'old-live')
+ const movingLive = { kind: 'package-swap', step: 'moving-live' } as const
+ await args.callbacks.journal(movingLive)
+ try {
+ await retrySkillRename(
+ args.record.resolvedPath,
+ oldLiveRoot,
+ () => args.callbacks.assertBoundary(movingLive, 'prior'),
+ args.renameRuntime?.renameEntry,
+ args.renameRuntime?.wait
+ )
+ } catch (error) {
+ if (
+ isSharingViolation(error, args.renameRuntime?.windowsFallback ?? process.platform === 'win32')
+ ) {
+ return { kind: 'pre-mutation-sharing-violation', error }
+ }
+ throw error
+ }
+ await args.callbacks.journal({ kind: 'package-swap', step: 'live-moved' })
+ const movingStage = { kind: 'package-swap', step: 'moving-stage' } as const
+ await args.callbacks.journal(movingStage)
+ try {
+ await retrySkillRename(
+ args.stageRoot,
+ args.record.resolvedPath,
+ () => args.callbacks.assertBoundary(movingStage, 'swap-absent'),
+ args.renameRuntime?.renameEntry,
+ args.renameRuntime?.wait
+ )
+ } catch (error) {
+ // Why: only the marker-owned old-live directory may fill the verified gap.
+ await retrySkillRename(
+ oldLiveRoot,
+ args.record.resolvedPath,
+ () => args.callbacks.assertBoundary(movingStage, 'swap-absent'),
+ args.renameRuntime?.renameEntry,
+ args.renameRuntime?.wait
+ )
+ await args.callbacks.journal(movingLive)
+ throw error
+ }
+ await args.callbacks.journal({ kind: 'package-swap', step: 'stage-moved' })
+ // Why: old-live retains the original directory identity until the ledger commit.
+ await args.callbacks.journal({ kind: 'package-swap', step: 'complete' })
+ return { kind: 'complete' }
+}
+
+function isSharingViolation(error: unknown, enabled: boolean): boolean {
+ return Boolean(
+ enabled &&
+ error &&
+ typeof error === 'object' &&
+ 'code' in error &&
+ (error.code === 'EPERM' || error.code === 'EBUSY')
+ )
+}
+
+export async function rollbackSkillPackageSwap(args: {
+ record: ManagedSkillDestination
+ transactionRoot: string
+ callbacks: PublicationCallbacks
+ renameRuntime?: SkillRenameRuntime
+}): Promise {
+ const stageRoot = join(args.transactionRoot, 'stage')
+ const oldLiveRoot = join(args.transactionRoot, 'old-live')
+ const movingCurrent = { kind: 'package-swap', step: 'rollback-moving-current' } as const
+ await args.callbacks.journal(movingCurrent)
+ await retrySkillRename(
+ args.record.resolvedPath,
+ stageRoot,
+ () => args.callbacks.assertBoundary(movingCurrent, 'current'),
+ args.renameRuntime?.renameEntry,
+ args.renameRuntime?.wait
+ )
+ await args.callbacks.journal({ kind: 'package-swap', step: 'rollback-current-moved' })
+ const movingOldLive = { kind: 'package-swap', step: 'rollback-moving-old-live' } as const
+ await args.callbacks.journal(movingOldLive)
+ await retrySkillRename(
+ oldLiveRoot,
+ args.record.resolvedPath,
+ () => args.callbacks.assertBoundary(movingOldLive, 'swap-absent'),
+ args.renameRuntime?.renameEntry,
+ args.renameRuntime?.wait
+ )
+ await args.callbacks.journal({ kind: 'package-swap', step: 'moving-live' })
+}
+
+export async function replaceSkillPackage(args: {
+ record: ManagedSkillDestination
+ current: SkillCurrentBundleEntry
+ prior: SkillReleasedSnapshot
+ stageRoot: string
+ transactionRoot: string
+ callbacks: PublicationCallbacks
+ renameRuntime?: SkillRenameRuntime
+}): Promise {
+ const outcome = await publishPackageSwap(args)
+ if (outcome.kind === 'pre-mutation-sharing-violation') {
+ // Why: the in-place journal may replace swap state only while live is untouched.
+ await args.callbacks.assertBoundary({ kind: 'package-swap', step: 'moving-live' }, 'prior')
+ await publishSkillPackageInPlace(args)
+ }
+}
diff --git a/src/main/skills/skill-package-publish.test.ts b/src/main/skills/skill-package-publish.test.ts
new file mode 100644
index 00000000000..3477dd5742d
--- /dev/null
+++ b/src/main/skills/skill-package-publish.test.ts
@@ -0,0 +1,176 @@
+import {
+ cp,
+ lstat,
+ mkdir,
+ mkdtemp,
+ realpath,
+ rename,
+ rm,
+ stat,
+ 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 {
+ ManagedSkillDestination,
+ SkillCurrentBundleEntry,
+ SkillReleasedSnapshot
+} from '../../shared/skill-management'
+import { observeSkillPackage, persistedObservedFiles } from './skill-package-identity'
+import { publishBundledSkill } from './skill-package-publish'
+import { skillPhysicalIdentity } from './skill-installation-topology'
+
+const roots: string[] = []
+
+async function writePackage(root: string, prefix: string): Promise {
+ await mkdir(root, { recursive: true })
+ await writeFile(join(root, 'SKILL.md'), `${prefix} entry`)
+ await writeFile(join(root, 'asset.txt'), `${prefix} asset`)
+}
+
+async function fixture() {
+ const root = await mkdtemp(join(tmpdir(), 'orca-publish-identity-'))
+ roots.push(root)
+ const live = join(root, 'home', '.agents', 'skills', 'orca-cli')
+ const packagesRoot = join(root, 'packages')
+ const currentRoot = join(packagesRoot, 'orca-cli')
+ await writePackage(live, 'old')
+ await writePackage(currentRoot, 'new')
+ const priorObserved = await observeSkillPackage(live)
+ const currentObserved = await observeSkillPackage(currentRoot)
+ const prior: SkillReleasedSnapshot = {
+ releaseRevision: 1,
+ packageDigest: priorObserved.observedDigest,
+ gitTreeSha: '',
+ files: persistedObservedFiles(priorObserved)
+ }
+ const current: SkillCurrentBundleEntry = {
+ name: 'orca-cli',
+ sourcePath: 'skills/orca-cli',
+ appVersion: '2.0.0',
+ releaseRevision: 2,
+ packageDigest: currentObserved.observedDigest,
+ gitTreeSha: '',
+ files: persistedObservedFiles(currentObserved)
+ }
+ const resolvedLive = await realpath(live)
+ const record: ManagedSkillDestination = {
+ id: '000000000000000000000000',
+ hostId: 'local',
+ homeIdentity: join(root, 'home'),
+ rootId: 'home-agents',
+ unresolvedPath: live,
+ resolvedPath: resolvedLive,
+ physicalIdentity: skillPhysicalIdentity(resolvedLive, await stat(resolvedLive)),
+ entryType: 'directory',
+ skillName: 'orca-cli',
+ source: 'stablyai/orca',
+ sourcePath: 'skills/orca-cli',
+ sourceRef: null,
+ installedReleaseRevision: 1,
+ installedPackageDigest: prior.packageDigest,
+ installedFiles: prior.files,
+ lastWrittenPackageDigest: null,
+ lastAttemptedBundleFingerprint: null,
+ lastOutcome: 'adopted',
+ lastErrorCategory: null,
+ adoptedFrom: 'exact-snapshot',
+ adoptedAt: 1,
+ updatedAt: 1
+ }
+ return { root, live, packagesRoot, prior, current, record }
+}
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('skill package durable identity', () => {
+ it.each(['package-swap', 'in-place'] as const)(
+ 'does not commit an exact-content independent replacement after %s publication',
+ async (mode) => {
+ const value = await fixture()
+ const replacement = join(value.root, 'independent-replacement')
+ await cp(join(value.packagesRoot, 'orca-cli'), replacement, { recursive: true })
+ let committed = false
+ let replacementInjected = false
+
+ await expect(
+ publishBundledSkill({
+ record: value.record,
+ current: value.current,
+ releasedSnapshots: [value.prior, value.current],
+ packagesRoot: value.packagesRoot,
+ commit: () => {
+ committed = true
+ },
+ publicationRenameRuntime:
+ mode === 'in-place'
+ ? {
+ windowsFallback: true,
+ renameEntry: async () => {
+ throw Object.assign(new Error('busy'), { code: 'EPERM' })
+ },
+ wait: async () => undefined
+ }
+ : undefined,
+ beforeDurableCommitValidation: async () => {
+ replacementInjected = true
+ await rm(value.live, { recursive: true })
+ await rename(replacement, value.live)
+ }
+ })
+ ).rejects.toThrow()
+
+ expect(replacementInjected).toBe(true)
+ expect(committed).toBe(false)
+ expect((await observeSkillPackage(value.live)).observedDigest).toBe(
+ value.current.packageDigest
+ )
+ }
+ )
+
+ it.each(['before-entry', 'before-workspace'] as const)(
+ 'writes no workspace through a parent link swapped %s',
+ async (timing) => {
+ const value = await fixture()
+ const agentsRoot = join(value.root, 'home', '.agents')
+ const originalAgentsRoot = join(value.root, 'original-agents')
+ const externalAgentsRoot = join(value.root, 'external-agents')
+ const swapParent = async (): Promise => {
+ await rename(agentsRoot, originalAgentsRoot)
+ await mkdir(join(externalAgentsRoot, 'skills'), { recursive: true })
+ await cp(
+ join(originalAgentsRoot, 'skills', 'orca-cli'),
+ join(externalAgentsRoot, 'skills', 'orca-cli'),
+ { recursive: true }
+ )
+ await symlink(
+ externalAgentsRoot,
+ agentsRoot,
+ process.platform === 'win32' ? 'junction' : 'dir'
+ )
+ }
+ if (timing === 'before-entry') {
+ await swapParent()
+ }
+
+ await expect(
+ publishBundledSkill({
+ record: value.record,
+ current: value.current,
+ releasedSnapshots: [value.prior, value.current],
+ packagesRoot: value.packagesRoot,
+ commit: () => undefined,
+ beforeWorkspaceCreationValidation: timing === 'before-workspace' ? swapParent : undefined
+ })
+ ).rejects.toThrow('skill-topology-changed')
+
+ expect(
+ await lstat(join(externalAgentsRoot, '.orca-skill-transactions')).catch(() => null)
+ ).toBeNull()
+ }
+ )
+})
diff --git a/src/main/skills/skill-package-publish.ts b/src/main/skills/skill-package-publish.ts
new file mode 100644
index 00000000000..ae9ebcff557
--- /dev/null
+++ b/src/main/skills/skill-package-publish.ts
@@ -0,0 +1,171 @@
+import { mkdir, rm, stat } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import type {
+ ManagedSkillDestination,
+ SkillCurrentBundleEntry
+} from '../../shared/skill-management'
+import { skillPhysicalIdentity } from './skill-installation-topology'
+import {
+ matchingReleasedSnapshot,
+ observeSkillPackage,
+ persistedObservedFiles
+} from './skill-package-identity'
+import {
+ acquireSkillDestinationLock,
+ recoverMarkedSkillTransactions
+} from './skill-transaction-recovery'
+import { destinationUsesCrlf, replaceSkillPackage } from './skill-package-publication'
+import type { SkillRenameRuntime } from './skill-transaction-rename'
+import { createSkillTransactionContext } from './skill-package-transaction-context'
+import {
+ assertManagedDestinationUnchanged,
+ assertPublishedDestinationOwned
+} from './skill-published-destination-authority'
+import { recoverTransactionToPrior } from './skill-transaction-restore'
+import { buildSkillTransactionPackage } from './skill-transaction-package-build'
+import { assertSkillTransactionWorkspace } from './skill-transaction-workspace'
+
+export async function publishBundledSkill(args: {
+ record: ManagedSkillDestination
+ current: SkillCurrentBundleEntry
+ releasedSnapshots: Parameters[1]
+ packagesRoot: string
+ commit: (destination: ManagedSkillDestination) => void | Promise
+ now?: number
+ beforeDurableCommitValidation?: () => void | Promise
+ beforeWorkspaceCreationValidation?: () => void | Promise
+ publicationRenameRuntime?: SkillRenameRuntime
+}): Promise {
+ if (args.current.releaseRevision <= args.record.installedReleaseRevision) {
+ await assertManagedDestinationUnchanged(args.record, args.releasedSnapshots)
+ await args.commit(args.record)
+ return args.record
+ }
+ await assertManagedDestinationUnchanged(args.record, args.releasedSnapshots)
+ const transaction = await createSkillTransactionContext({
+ record: args.record,
+ current: args.current,
+ releasedSnapshots: args.releasedSnapshots,
+ now: args.now ?? Date.now(),
+ beforeWorkspaceValidation: args.beforeWorkspaceCreationValidation
+ })
+ // Why: no workspace directory may be created after a parent or mount swap.
+ await transaction.assertAuthority(true)
+ await mkdir(dirname(transaction.paths.lockRoot), { recursive: true })
+ await transaction.assertAuthority(true)
+ const releaseLock = await acquireSkillDestinationLock(transaction.paths.lockRoot, () =>
+ transaction.assertAuthority(true)
+ )
+ let retainTransaction = false
+ let publicationStarted = false
+ try {
+ await transaction.assertAuthority(true)
+ await recoverMarkedSkillTransactions({
+ reservedRoot: transaction.paths.reservedRoot,
+ record: args.record
+ })
+ await transaction.assertAuthority(true)
+ await mkdir(transaction.paths.transactionRoot, { recursive: true })
+ await transaction.assertAuthority(true)
+ await transaction.writeMarker('staging', true)
+ await buildSkillTransactionPackage({
+ sourceRoot: join(args.packagesRoot, args.record.skillName),
+ root: transaction.paths.stageRoot,
+ snapshot: transaction.marker('staging').currentSnapshot,
+ crlf: destinationUsesCrlf(args.record),
+ assertSourceAuthority: () => transaction.assertAuthority(true),
+ journal: transaction.journalStageBuild,
+ assertBoundary: transaction.assertStageBuild
+ })
+ await buildSkillTransactionPackage({
+ sourceRoot: args.record.resolvedPath,
+ root: transaction.paths.backupRoot,
+ snapshot: transaction.priorSnapshot,
+ crlf: false,
+ assertSourceAuthority: () => transaction.assertAuthority(true),
+ journal: transaction.journalBackupBuild,
+ assertBoundary: transaction.assertBackupBuild
+ })
+ await transaction.assertAuthority(true)
+ publicationStarted = true
+ await replaceSkillPackage({
+ ...args,
+ prior: transaction.priorSnapshot,
+ stageRoot: transaction.paths.stageRoot,
+ transactionRoot: transaction.paths.transactionRoot,
+ callbacks: transaction.callbacks,
+ renameRuntime: args.publicationRenameRuntime
+ })
+ const installed = await observeSkillPackage(args.record.resolvedPath)
+ if (!matchingReleasedSnapshot(installed, [args.current])) {
+ throw new Error('published-skill-package-invalid')
+ }
+ const now = args.now ?? Date.now()
+ const publishingMarker = transaction.marker('publishing')
+ const nextResolvedPath = await assertPublishedDestinationOwned({
+ record: args.record,
+ transactionRoot: transaction.paths.transactionRoot,
+ marker: publishingMarker
+ })
+ const nextPhysicalIdentity = skillPhysicalIdentity(
+ nextResolvedPath,
+ await stat(nextResolvedPath)
+ )
+ const destination: ManagedSkillDestination = {
+ ...args.record,
+ resolvedPath: nextResolvedPath,
+ physicalIdentity: nextPhysicalIdentity,
+ installedReleaseRevision: args.current.releaseRevision,
+ installedPackageDigest: args.current.packageDigest,
+ installedFiles: persistedObservedFiles(installed),
+ lastWrittenPackageDigest: args.current.packageDigest,
+ lastAttemptedBundleFingerprint: args.current.packageDigest,
+ lastOutcome: 'updated',
+ lastErrorCategory: null,
+ updatedAt: now
+ }
+ const verifiedMarker = transaction.marker('verified')
+ await transaction.writeVerifiedMarker(async () => {
+ await assertPublishedDestinationOwned({
+ record: args.record,
+ transactionRoot: transaction.paths.transactionRoot,
+ marker: verifiedMarker
+ })
+ })
+ // Why: the rollback package remains marked until durable Orca ownership
+ // state agrees with the verified live package.
+ await args.beforeDurableCommitValidation?.()
+ await assertPublishedDestinationOwned({
+ record: args.record,
+ transactionRoot: transaction.paths.transactionRoot,
+ marker: verifiedMarker
+ })
+ await args.commit(destination)
+ return destination
+ } catch (error) {
+ try {
+ await recoverTransactionToPrior({
+ transactionRoot: transaction.paths.transactionRoot,
+ marker: transaction.marker(publicationStarted ? 'publishing' : 'staging'),
+ assertParentAuthority: transaction.assertParentAuthority,
+ currentDisposition: 'restore'
+ })
+ } catch (restoreError) {
+ retainTransaction = true
+ throw new AggregateError([error, restoreError], 'skill-transaction-recovery-required')
+ }
+ throw error
+ } finally {
+ const workspaceSafe = await assertSkillTransactionWorkspace(args.record, transaction.workspace)
+ .then(() => true)
+ .catch(() => false)
+ if (workspaceSafe && !retainTransaction) {
+ await rm(transaction.paths.transactionRoot, { recursive: true, force: true }).catch(
+ () => undefined
+ )
+ }
+ if (workspaceSafe) {
+ await releaseLock().catch(() => undefined)
+ }
+ }
+}
diff --git a/src/main/skills/skill-package-rollback.ts b/src/main/skills/skill-package-rollback.ts
new file mode 100644
index 00000000000..5ff0b54a7fc
--- /dev/null
+++ b/src/main/skills/skill-package-rollback.ts
@@ -0,0 +1,95 @@
+import { lstat, rmdir, unlink } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import type { ManagedSkillDestination, SkillReleasedSnapshot } from '../../shared/skill-management'
+import type { PublicationCallbacks } from './skill-package-publication'
+import { publishSkillFileThroughOwnedTemp } from './skill-owned-temp-publication'
+import type { SkillOwnedTempRuntime } from './skill-owned-temp-publication'
+import { skillInPlaceOperations } from './skill-transaction-publication-state'
+import type { SkillInPlacePublicationState } from './skill-transaction-publication-state'
+
+export async function clearSkillPublicationTemporary(args: {
+ record: ManagedSkillDestination
+ publication: SkillInPlacePublicationState
+ callbacks: PublicationCallbacks
+ ownedTempRuntime?: SkillOwnedTempRuntime
+}): Promise {
+ if (!args.publication.temporary) {
+ return
+ }
+ await args.callbacks.assertBoundary(args.publication)
+ const temporaryPath = join(
+ args.record.resolvedPath,
+ ...args.publication.temporary.path.split('/')
+ )
+ if (await lstat(temporaryPath).catch(() => null)) {
+ await args.callbacks.assertBoundary(args.publication)
+ await unlink(temporaryPath)
+ }
+ await args.callbacks.journal({ ...args.publication, temporary: null })
+}
+
+export async function rollbackSkillPackageInPlace(args: {
+ record: ManagedSkillDestination
+ prior: SkillReleasedSnapshot
+ current: SkillReleasedSnapshot
+ backupRoot: string
+ startingIndex: number
+ physicalIdentity: string
+ callbacks: PublicationCallbacks
+ ownedTempRuntime?: SkillOwnedTempRuntime
+}): Promise {
+ const operations = skillInPlaceOperations(args.prior, args.current)
+ const priorByPath = new Map(args.prior.files.map((file) => [file.path, file]))
+ for (let operationIndex = args.startingIndex; operationIndex > 0; operationIndex -= 1) {
+ const operation = operations[operationIndex - 1]
+ if (!operation) {
+ throw new Error('skill-publication-operation-invalid')
+ }
+ const priorFile = priorByPath.get(operation.path)
+ const applying = {
+ kind: 'in-place',
+ direction: 'rollback',
+ physicalIdentity: args.physicalIdentity,
+ temporary: null,
+ operationIndex,
+ operationState: 'applying'
+ } as const
+ await args.callbacks.journal(applying)
+ const livePath = join(args.record.resolvedPath, ...operation.path.split('/'))
+ if (operation.kind === 'publish' && !priorFile) {
+ await args.callbacks.assertBoundary(applying)
+ await unlink(livePath)
+ } else {
+ if (!priorFile) {
+ throw new Error('skill-publication-operation-invalid')
+ }
+ await publishSkillFileThroughOwnedTemp({
+ destinationRoot: args.record.resolvedPath,
+ sourcePath: join(args.backupRoot, ...priorFile.path.split('/')),
+ expectedFile: priorFile,
+ direction: 'rollback',
+ packagePhysicalIdentity: args.physicalIdentity,
+ operationIndex,
+ callbacks: args.callbacks,
+ runtime: args.ownedTempRuntime
+ })
+ }
+ const ready = {
+ kind: 'in-place',
+ direction: 'rollback',
+ physicalIdentity: args.physicalIdentity,
+ temporary: null,
+ operationIndex: operationIndex - 1,
+ operationState: 'ready'
+ } as const
+ await args.callbacks.journal(ready)
+ if (operation.kind === 'publish' && !priorFile) {
+ let parent = dirname(livePath)
+ while (parent !== args.record.resolvedPath) {
+ await args.callbacks.assertBoundary(ready)
+ await rmdir(parent).catch(() => undefined)
+ parent = dirname(parent)
+ }
+ }
+ }
+}
diff --git a/src/main/skills/skill-package-swap-authority.test.ts b/src/main/skills/skill-package-swap-authority.test.ts
new file mode 100644
index 00000000000..0f4c3daf517
--- /dev/null
+++ b/src/main/skills/skill-package-swap-authority.test.ts
@@ -0,0 +1,229 @@
+import { cp, mkdir, mkdtemp, rename, rm, stat, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { ManagedSkillDestination, SkillReleasedSnapshot } from '../../shared/skill-management'
+import { observeSkillPackage, persistedObservedFiles } from './skill-package-identity'
+import { replaceSkillPackage, rollbackSkillPackageSwap } from './skill-package-publication'
+import { skillPhysicalIdentity } from './skill-installation-topology'
+import type { SkillTransactionMarker } from './skill-transaction-marker'
+import { assertTransactionOwnedState } from './skill-transaction-owned-state'
+import type { SkillPublicationState } from './skill-transaction-publication-state'
+
+const roots: string[] = []
+
+async function createPackage(root: string, values: Record): Promise {
+ await mkdir(root, { recursive: true })
+ for (const [path, contents] of Object.entries(values)) {
+ await writeFile(join(root, path), contents)
+ }
+}
+
+async function snapshot(root: string, releaseRevision: number): Promise {
+ const observed = await observeSkillPackage(root)
+ return {
+ releaseRevision,
+ packageDigest: observed.observedDigest,
+ gitTreeSha: '',
+ files: persistedObservedFiles(observed)
+ }
+}
+
+async function fixture() {
+ const root = await mkdtemp(join(tmpdir(), 'orca-swap-authority-'))
+ roots.push(root)
+ const live = join(root, 'skills', 'orca-cli')
+ const transactionRoot = join(root, 'transaction')
+ const stageRoot = join(transactionRoot, 'stage')
+ const backupRoot = join(transactionRoot, 'backup')
+ await createPackage(live, { 'SKILL.md': 'old entry', 'asset.txt': 'old asset' })
+ await createPackage(stageRoot, { 'SKILL.md': 'new entry', 'asset.txt': 'new asset' })
+ await cp(live, backupRoot, { recursive: true })
+ const prior = await snapshot(live, 1)
+ const current = await snapshot(stageRoot, 2)
+ const record = {
+ resolvedPath: live,
+ physicalIdentity: skillPhysicalIdentity(live, await stat(live))
+ } as ManagedSkillDestination
+ let marker: SkillTransactionMarker = {
+ schemaVersion: 1,
+ transactionId: 'test',
+ createdAt: 1,
+ destinationId: '000000000000000000000000',
+ destinationPath: live,
+ physicalIdentity: record.physicalIdentity,
+ hostId: 'local',
+ skillName: 'orca-cli',
+ phase: 'publishing',
+ stagePhysicalIdentity: skillPhysicalIdentity(stageRoot, await stat(stageRoot)),
+ backupPhysicalIdentity: skillPhysicalIdentity(backupRoot, await stat(backupRoot)),
+ stageBuild: {
+ state: 'ready',
+ physicalIdentity: skillPhysicalIdentity(stageRoot, await stat(stageRoot)),
+ operationIndex: current.files.length,
+ pendingPath: null,
+ activeFile: null
+ },
+ backupBuild: {
+ state: 'ready',
+ physicalIdentity: skillPhysicalIdentity(backupRoot, await stat(backupRoot)),
+ operationIndex: prior.files.length,
+ pendingPath: null,
+ activeFile: null
+ },
+ publication: null,
+ priorSnapshot: prior,
+ currentSnapshot: current
+ }
+ const history: SkillPublicationState[] = []
+ const journal = async (publication: SkillPublicationState): Promise => {
+ marker = { ...marker, publication }
+ history.push(publication)
+ }
+ const assertBoundary = async (
+ publication: SkillPublicationState,
+ expectedLiveState?: Awaited>
+ ): Promise => {
+ const liveState = await assertTransactionOwnedState({
+ transactionRoot,
+ marker: { ...marker, publication },
+ assertParent: async () => undefined,
+ allowAppliedOperation: false
+ })
+ if (expectedLiveState && liveState !== expectedLiveState) {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ }
+ return {
+ live,
+ transactionRoot,
+ stageRoot,
+ oldLiveRoot: join(transactionRoot, 'old-live'),
+ record,
+ prior,
+ current: { ...current, name: 'orca-cli', sourcePath: '', appVersion: '1.0.0' },
+ callbacks: { journal, assertBoundary },
+ history,
+ marker: () => marker
+ }
+}
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('skill package swap authority', () => {
+ it('falls back in place only when the first live rename never mutated', async () => {
+ const value = await fixture()
+ await replaceSkillPackage({
+ ...value,
+ callbacks: value.callbacks,
+ renameRuntime: {
+ windowsFallback: true,
+ renameEntry: async () => {
+ throw Object.assign(new Error('busy'), { code: 'EPERM' })
+ },
+ wait: async () => undefined
+ }
+ })
+ expect((await observeSkillPackage(value.live)).observedDigest).toBe(value.current.packageDigest)
+ expect(value.history.some((state) => state.kind === 'in-place')).toBe(true)
+ })
+
+ it('never falls back after live moved and the stage rename exhausted retries', async () => {
+ const value = await fixture()
+ const renameEntry: typeof rename = async (source, destination) => {
+ if (source.toString() === value.stageRoot) {
+ throw Object.assign(new Error('busy'), { code: 'EPERM' })
+ }
+ await rename(source, destination)
+ }
+ await expect(
+ replaceSkillPackage({
+ ...value,
+ callbacks: value.callbacks,
+ renameRuntime: { windowsFallback: true, renameEntry, wait: async () => undefined }
+ })
+ ).rejects.toMatchObject({ code: 'EPERM' })
+ expect(value.marker().publication).toEqual({ kind: 'package-swap', step: 'moving-live' })
+ expect(value.history.some((state) => state.kind === 'in-place')).toBe(false)
+ })
+
+ it('rejects fallback when a failing first rename nevertheless moved live', async () => {
+ const value = await fixture()
+ let firstAttempt = true
+ const renameEntry: typeof rename = async (source, destination) => {
+ if (firstAttempt) {
+ firstAttempt = false
+ await rename(source, destination)
+ throw Object.assign(new Error('ambiguous busy result'), { code: 'EPERM' })
+ }
+ await rename(source, destination)
+ }
+ await expect(
+ replaceSkillPackage({
+ ...value,
+ callbacks: value.callbacks,
+ renameRuntime: { windowsFallback: true, renameEntry, wait: async () => undefined }
+ })
+ ).rejects.toThrow('skill-transaction-recovery-required')
+ expect(value.history.some((state) => state.kind === 'in-place')).toBe(false)
+ expect(await stat(value.oldLiveRoot)).toBeTruthy()
+ await expect(stat(value.live)).rejects.toMatchObject({ code: 'ENOENT' })
+ })
+
+ it('retains swap authority when stage publication and old-live restore are busy', async () => {
+ const value = await fixture()
+ let firstRename = true
+ const renameEntry: typeof rename = async (source, destination) => {
+ if (firstRename) {
+ firstRename = false
+ await rename(source, destination)
+ return
+ }
+ throw Object.assign(new Error('busy'), { code: 'EBUSY' })
+ }
+ await expect(
+ replaceSkillPackage({
+ ...value,
+ callbacks: value.callbacks,
+ renameRuntime: { windowsFallback: true, renameEntry, wait: async () => undefined }
+ })
+ ).rejects.toMatchObject({ code: 'EBUSY' })
+ expect(value.marker().publication).toEqual({ kind: 'package-swap', step: 'moving-stage' })
+ expect(value.history.some((state) => state.kind === 'in-place')).toBe(false)
+ expect(await stat(value.oldLiveRoot)).toBeTruthy()
+ await expect(stat(value.live)).rejects.toMatchObject({ code: 'ENOENT' })
+ })
+
+ it.each(['current', 'old-live'] as const)(
+ 'retains the package-swap rollback journal when %s rename stays busy',
+ async (failure) => {
+ const value = await fixture()
+ await rename(value.live, value.oldLiveRoot)
+ await rename(value.stageRoot, value.live)
+ await value.callbacks.journal({ kind: 'package-swap', step: 'complete' })
+ const renameEntry: typeof rename = async (source, destination) => {
+ const shouldFail =
+ (failure === 'current' && source.toString() === value.live) ||
+ (failure === 'old-live' && source.toString() === value.oldLiveRoot)
+ if (shouldFail) {
+ throw Object.assign(new Error('busy'), { code: 'EPERM' })
+ }
+ await rename(source, destination)
+ }
+ await expect(
+ rollbackSkillPackageSwap({
+ record: value.record,
+ transactionRoot: value.transactionRoot,
+ callbacks: value.callbacks,
+ renameRuntime: { windowsFallback: true, renameEntry, wait: async () => undefined }
+ })
+ ).rejects.toMatchObject({ code: 'EPERM' })
+ expect(value.marker().publication).toEqual({
+ kind: 'package-swap',
+ step: failure === 'current' ? 'rollback-moving-current' : 'rollback-moving-old-live'
+ })
+ }
+ )
+})
diff --git a/src/main/skills/skill-package-transaction-context.ts b/src/main/skills/skill-package-transaction-context.ts
new file mode 100644
index 00000000000..1024a67b0c4
--- /dev/null
+++ b/src/main/skills/skill-package-transaction-context.ts
@@ -0,0 +1,202 @@
+import { randomUUID } from 'node:crypto'
+import { join } from 'node:path'
+import type {
+ ManagedSkillDestination,
+ SkillCurrentBundleEntry,
+ SkillReleasedSnapshot
+} from '../../shared/skill-management'
+import { assertManagedSkillParentTopology } from './skill-installation-topology'
+import type { PublicationCallbacks } from './skill-package-publication'
+import { assertManagedDestinationUnchanged } from './skill-published-destination-authority'
+import {
+ writeSkillTransactionMarker,
+ type SkillTransactionMarker
+} from './skill-transaction-marker'
+import { assertTransactionOwnedState } from './skill-transaction-owned-state'
+import type { SkillPublicationState } from './skill-transaction-publication-state'
+import {
+ assertSkillTransactionPackageBuild,
+ type SkillTransactionPackageBuildEvidence
+} from './skill-transaction-package-build'
+import {
+ assertSkillTransactionWorkspace,
+ resolveSkillTransactionWorkspace,
+ type SkillTransactionWorkspace
+} from './skill-transaction-workspace'
+
+export type SkillTransactionContext = {
+ workspace: SkillTransactionWorkspace
+ paths: {
+ reservedRoot: string
+ transactionRoot: string
+ stageRoot: string
+ backupRoot: string
+ lockRoot: string
+ }
+ priorSnapshot: SkillReleasedSnapshot
+ callbacks: PublicationCallbacks
+ assertAuthority: (checkContent: boolean) => Promise
+ assertParentAuthority: () => Promise
+ journalStageBuild: (evidence: SkillTransactionPackageBuildEvidence) => Promise
+ assertStageBuild: (evidence: SkillTransactionPackageBuildEvidence) => Promise
+ journalBackupBuild: (evidence: SkillTransactionPackageBuildEvidence) => Promise
+ assertBackupBuild: (evidence: SkillTransactionPackageBuildEvidence) => Promise
+ marker: (phase: SkillTransactionMarker['phase']) => SkillTransactionMarker
+ writeMarker: (phase: SkillTransactionMarker['phase'], checkContent: boolean) => Promise
+ writeVerifiedMarker: (assertPublished: () => Promise) => Promise
+}
+
+export async function createSkillTransactionContext(args: {
+ record: ManagedSkillDestination
+ current: SkillCurrentBundleEntry
+ releasedSnapshots: readonly SkillReleasedSnapshot[]
+ now: number
+ beforeWorkspaceValidation?: () => void | Promise
+}): Promise {
+ const workspace = await resolveSkillTransactionWorkspace(args.record)
+ await args.beforeWorkspaceValidation?.()
+ const assertAuthority = async (checkContent: boolean): Promise => {
+ if (checkContent) {
+ await assertManagedDestinationUnchanged(args.record, args.releasedSnapshots)
+ }
+ await assertSkillTransactionWorkspace(args.record, workspace)
+ }
+ await assertAuthority(true)
+ const reservedRoot = workspace.reservedRoot
+ const transactionRoot = join(reservedRoot, randomUUID())
+ const stageRoot = join(transactionRoot, 'stage')
+ const backupRoot = join(transactionRoot, 'backup')
+ const priorSnapshot: SkillReleasedSnapshot = {
+ releaseRevision: args.record.installedReleaseRevision,
+ packageDigest: args.record.installedPackageDigest,
+ gitTreeSha: args.record.sourceRef ?? '',
+ files: args.record.installedFiles
+ }
+ const currentSnapshot: SkillReleasedSnapshot = {
+ releaseRevision: args.current.releaseRevision,
+ packageDigest: args.current.packageDigest,
+ gitTreeSha: args.current.gitTreeSha,
+ files: args.current.files
+ }
+ let stagePhysicalIdentity: string | null = null
+ let backupPhysicalIdentity: string | null = null
+ let stageBuild: SkillTransactionPackageBuildEvidence | null = null
+ let backupBuild: SkillTransactionPackageBuildEvidence | null = null
+ let publication: SkillPublicationState | null = null
+ const transactionId = randomUUID()
+ const marker = (phase: SkillTransactionMarker['phase']): SkillTransactionMarker => ({
+ schemaVersion: 1,
+ transactionId,
+ createdAt: args.now,
+ destinationId: args.record.id,
+ destinationPath: args.record.resolvedPath,
+ physicalIdentity: args.record.physicalIdentity,
+ hostId: args.record.hostId,
+ skillName: args.record.skillName,
+ phase,
+ stagePhysicalIdentity,
+ backupPhysicalIdentity,
+ stageBuild,
+ backupBuild,
+ publication,
+ priorSnapshot,
+ currentSnapshot
+ })
+ const assertParentAuthority = () => assertManagedSkillParentTopology(args.record)
+ const assertBuildAuthority = async (): Promise => {
+ await assertAuthority(true)
+ if (stageBuild) {
+ await assertSkillTransactionPackageBuild({
+ root: stageRoot,
+ evidence: stageBuild,
+ snapshot: currentSnapshot
+ })
+ }
+ if (backupBuild) {
+ await assertSkillTransactionPackageBuild({
+ root: backupRoot,
+ evidence: backupBuild,
+ snapshot: priorSnapshot
+ })
+ }
+ }
+ const writeBuildMarker = async (): Promise => {
+ await writeSkillTransactionMarker(transactionRoot, marker('staging'), assertBuildAuthority)
+ }
+ const journal = async (next: SkillPublicationState): Promise => {
+ publication = next
+ await assertAuthority(false)
+ await writeSkillTransactionMarker(transactionRoot, marker('publishing'), () =>
+ assertAuthority(false)
+ )
+ }
+ const assertBoundary: PublicationCallbacks['assertBoundary'] = async (
+ state,
+ expectedLiveState
+ ) => {
+ const liveState = await assertTransactionOwnedState({
+ transactionRoot,
+ marker: { ...marker('publishing'), publication: state },
+ assertParent: assertParentAuthority,
+ allowAppliedOperation: false
+ })
+ await assertAuthority(false)
+ if (expectedLiveState && liveState !== expectedLiveState) {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ }
+ return {
+ workspace,
+ paths: {
+ reservedRoot,
+ transactionRoot,
+ stageRoot,
+ backupRoot,
+ lockRoot: join(reservedRoot, 'locks', args.record.id)
+ },
+ priorSnapshot,
+ callbacks: { journal, assertBoundary },
+ assertAuthority,
+ assertParentAuthority,
+ journalStageBuild: async (evidence) => {
+ stageBuild = evidence
+ stagePhysicalIdentity = evidence.physicalIdentity
+ await writeBuildMarker()
+ },
+ assertStageBuild: async (evidence) => {
+ await assertAuthority(true)
+ await assertSkillTransactionPackageBuild({
+ root: stageRoot,
+ evidence,
+ snapshot: currentSnapshot
+ })
+ },
+ journalBackupBuild: async (evidence) => {
+ backupBuild = evidence
+ backupPhysicalIdentity = evidence.physicalIdentity
+ await writeBuildMarker()
+ },
+ assertBackupBuild: async (evidence) => {
+ await assertAuthority(true)
+ await assertSkillTransactionPackageBuild({
+ root: backupRoot,
+ evidence,
+ snapshot: priorSnapshot
+ })
+ },
+ marker,
+ writeMarker: async (phase, checkContent) => {
+ await writeSkillTransactionMarker(transactionRoot, marker(phase), () =>
+ assertAuthority(checkContent)
+ )
+ },
+ writeVerifiedMarker: async (assertPublished) => {
+ await writeSkillTransactionMarker(transactionRoot, marker('verified'), async () => {
+ // Why: marker durability must never outlive the exact destination
+ // identity and content proof it claims.
+ await assertAuthority(false)
+ await assertPublished()
+ })
+ }
+ }
+}
diff --git a/src/main/skills/skill-published-destination-authority.ts b/src/main/skills/skill-published-destination-authority.ts
new file mode 100644
index 00000000000..9bf80e8d24b
--- /dev/null
+++ b/src/main/skills/skill-published-destination-authority.ts
@@ -0,0 +1,52 @@
+import type { ManagedSkillDestination } from '../../shared/skill-management'
+import {
+ assertManagedSkillParentTopology,
+ assertManagedSkillTopology
+} from './skill-installation-topology'
+import { matchingReleasedSnapshot, observeSkillPackage } from './skill-package-identity'
+import type { SkillTransactionMarker } from './skill-transaction-marker'
+import { assertTransactionOwnedState } from './skill-transaction-owned-state'
+
+export async function assertManagedDestinationUnchanged(
+ record: ManagedSkillDestination,
+ snapshots: Parameters[1]
+): Promise {
+ const resolved = await assertManagedSkillTopology(record)
+ const observed = await observeSkillPackage(resolved)
+ const snapshot = matchingReleasedSnapshot(observed, snapshots)
+ if (!snapshot || snapshot.packageDigest !== record.installedPackageDigest) {
+ throw new Error('skill-content-changed')
+ }
+}
+
+export async function assertPublishedDestinationOwned(args: {
+ record: ManagedSkillDestination
+ transactionRoot: string
+ marker: SkillTransactionMarker
+}): Promise {
+ const publication = args.marker.publication
+ if (!publication) {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ const expectedPhysicalIdentity =
+ publication.kind === 'package-swap'
+ ? args.marker.stagePhysicalIdentity
+ : args.record.physicalIdentity
+ if (!expectedPhysicalIdentity) {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ const state = await assertTransactionOwnedState({
+ transactionRoot: args.transactionRoot,
+ marker: args.marker,
+ assertParent: () => assertManagedSkillParentTopology(args.record),
+ allowAppliedOperation: false
+ })
+ if (state !== 'current') {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ // Why: exact bytes do not authorize claiming a directory replaced after publish.
+ return assertManagedSkillTopology({
+ ...args.record,
+ physicalIdentity: expectedPhysicalIdentity
+ })
+}
diff --git a/src/main/skills/skill-transaction-marker.test.ts b/src/main/skills/skill-transaction-marker.test.ts
new file mode 100644
index 00000000000..ba416e954d2
--- /dev/null
+++ b/src/main/skills/skill-transaction-marker.test.ts
@@ -0,0 +1,235 @@
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { SkillReleasedSnapshot } from '../../shared/skill-management'
+import {
+ observeSkillPackage,
+ persistedObservedFiles,
+ skillPackageDigest
+} from './skill-package-identity'
+import {
+ parseSkillTransactionMarker,
+ type SkillTransactionMarker
+} from './skill-transaction-marker'
+import { skillInPlaceOperations } from './skill-transaction-publication-state'
+
+const roots: string[] = []
+
+async function snapshot(contents: string, revision: number): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'orca-marker-snapshot-'))
+ roots.push(root)
+ await writeFile(join(root, 'SKILL.md'), contents)
+ await writeFile(join(root, 'asset.txt'), `${contents} asset`)
+ const observed = await observeSkillPackage(root)
+ return {
+ releaseRevision: revision,
+ packageDigest: observed.observedDigest,
+ gitTreeSha: '',
+ files: persistedObservedFiles(observed)
+ }
+}
+
+async function fixture(): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'orca-marker-'))
+ roots.push(root)
+ const destinationPath = join(root, 'skills', 'orca-cli')
+ await mkdir(destinationPath, { recursive: true })
+ const priorSnapshot = await snapshot('old', 1)
+ const currentSnapshot = await snapshot('new', 2)
+ return {
+ schemaVersion: 1,
+ transactionId: 'transaction',
+ createdAt: 1,
+ destinationId: '000000000000000000000000',
+ destinationPath,
+ physicalIdentity: 'live-identity',
+ hostId: 'local',
+ skillName: 'orca-cli',
+ phase: 'verified',
+ stagePhysicalIdentity: 'stage-identity',
+ backupPhysicalIdentity: 'backup-identity',
+ stageBuild: {
+ state: 'ready',
+ physicalIdentity: 'stage-identity',
+ operationIndex: currentSnapshot.files.length,
+ pendingPath: null,
+ activeFile: null
+ },
+ backupBuild: {
+ state: 'ready',
+ physicalIdentity: 'backup-identity',
+ operationIndex: priorSnapshot.files.length,
+ pendingPath: null,
+ activeFile: null
+ },
+ publication: { kind: 'package-swap', step: 'complete' },
+ priorSnapshot,
+ currentSnapshot
+ }
+}
+
+function parse(marker: unknown): SkillTransactionMarker | null {
+ return parseSkillTransactionMarker(JSON.stringify(marker))
+}
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('skill transaction marker semantics', () => {
+ it('requires phase-valid package build evidence', async () => {
+ const marker = await fixture()
+ expect(parse({ ...marker, stageBuild: null })).toBeNull()
+ expect(
+ parse({
+ ...marker,
+ phase: 'staging',
+ publication: null,
+ stageBuild: { ...marker.stageBuild, state: 'creating' }
+ })
+ ).toBeNull()
+ expect(
+ parse({
+ ...marker,
+ phase: 'staging',
+ publication: null,
+ backupBuild: { ...marker.backupBuild, state: 'creating' },
+ stageBuild: { ...marker.stageBuild, state: 'creating' }
+ })
+ ).toBeNull()
+ expect(
+ parse({
+ ...marker,
+ phase: 'staging',
+ publication: null,
+ stageBuild: {
+ state: 'creating',
+ physicalIdentity: marker.stagePhysicalIdentity,
+ operationIndex: 0,
+ pendingPath: null,
+ activeFile: {
+ path: marker.currentSnapshot.files[0]!.path,
+ physicalIdentity: 'active',
+ contentState: 'invalid'
+ }
+ },
+ backupBuild: null,
+ backupPhysicalIdentity: null
+ })
+ ).toBeNull()
+ })
+
+ it('accepts only terminal verified publication states', async () => {
+ const marker = await fixture()
+ expect(parse(marker)).not.toBeNull()
+ expect(
+ parse({ ...marker, publication: { kind: 'package-swap', step: 'moving-stage' } })
+ ).toBeNull()
+ const finalIndex = skillInPlaceOperations(marker.priorSnapshot, marker.currentSnapshot).length
+ const inPlace = {
+ kind: 'in-place',
+ direction: 'forward',
+ physicalIdentity: marker.physicalIdentity,
+ temporary: null,
+ operationIndex: finalIndex,
+ operationState: 'ready'
+ } as const
+ expect(parse({ ...marker, publication: inPlace })).not.toBeNull()
+ expect(
+ parse({ ...marker, publication: { ...inPlace, operationIndex: finalIndex - 1 } })
+ ).toBeNull()
+ expect(parse({ ...marker, publication: { ...inPlace, direction: 'rollback' } })).toBeNull()
+ expect(parse({ ...marker, publication: { ...inPlace, operationState: 'applying' } })).toBeNull()
+ })
+
+ it.each(['digest', 'order', 'duplicate', 'unsafe'])(
+ 'rejects a snapshot with corrupted %s semantics',
+ async (corruption) => {
+ const marker = await fixture()
+ const files = marker.currentSnapshot.files.map((file) => ({ ...file }))
+ if (corruption === 'order') {
+ files.reverse()
+ }
+ if (corruption === 'duplicate') {
+ files[1] = { ...files[0]! }
+ }
+ if (corruption === 'unsafe') {
+ files[0] = { ...files[0]!, path: '../escape' }
+ }
+ const currentSnapshot = {
+ ...marker.currentSnapshot,
+ files,
+ packageDigest: corruption === 'digest' ? '0'.repeat(64) : skillPackageDigest(files)
+ }
+ expect(parse({ ...marker, currentSnapshot })).toBeNull()
+ }
+ )
+
+ it('rejects inconsistent revisions and malformed temp authority', async () => {
+ const marker = await fixture()
+ expect(
+ parse({
+ ...marker,
+ currentSnapshot: {
+ ...marker.currentSnapshot,
+ releaseRevision: marker.priorSnapshot.releaseRevision
+ }
+ })
+ ).toBeNull()
+ const operation = skillInPlaceOperations(marker.priorSnapshot, marker.currentSnapshot)[0]!
+ const expectedFile = marker.currentSnapshot.files.find((file) => file.path === operation.path)!
+ const publication = {
+ kind: 'in-place',
+ direction: 'forward',
+ physicalIdentity: marker.physicalIdentity,
+ temporary: {
+ path: 'asset.txt.orca-test.tmp',
+ physicalIdentity: '',
+ expectedFile,
+ state: 'ready'
+ },
+ operationIndex: 0,
+ operationState: 'applying'
+ } as const
+ expect(parse({ ...marker, phase: 'publishing', publication })).toBeNull()
+ const validPublication = {
+ ...publication,
+ temporary: { ...publication.temporary, physicalIdentity: 'temp-identity' }
+ }
+ expect(parse({ ...marker, phase: 'publishing', publication: validPublication })).not.toBeNull()
+ expect(
+ parse({
+ ...marker,
+ phase: 'publishing',
+ publication: {
+ ...validPublication,
+ temporary: { ...validPublication.temporary, state: 'unknown' }
+ }
+ })
+ ).toBeNull()
+ expect(
+ parse({
+ ...marker,
+ phase: 'publishing',
+ publication: {
+ ...validPublication,
+ temporary: { ...validPublication.temporary, path: 'other.orca-test.tmp' }
+ }
+ })
+ ).toBeNull()
+ expect(
+ parse({
+ ...marker,
+ phase: 'publishing',
+ publication: {
+ ...validPublication,
+ temporary: {
+ ...validPublication.temporary,
+ expectedFile: marker.priorSnapshot.files.find((file) => file.path === operation.path)!
+ }
+ }
+ })
+ ).toBeNull()
+ })
+})
diff --git a/src/main/skills/skill-transaction-marker.ts b/src/main/skills/skill-transaction-marker.ts
new file mode 100644
index 00000000000..99caa7bb16f
--- /dev/null
+++ b/src/main/skills/skill-transaction-marker.ts
@@ -0,0 +1,291 @@
+import { randomUUID } from 'node:crypto'
+import { rename, rm, writeFile } from 'node:fs/promises'
+import { basename, isAbsolute, join, posix } from 'node:path'
+import { normalizeExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
+import type { SkillReleasedSnapshot } from '../../shared/skill-management'
+import { normalizedSkillIdentityPath } from './skill-installation-topology'
+import {
+ isSkillTransactionPackageBuildEvidence,
+ type SkillTransactionPackageBuildEvidence
+} from './skill-transaction-package-build'
+import {
+ sameCompleteFileIdentity,
+ skillInPlaceOperations,
+ type SkillInPlacePublicationState,
+ type SkillPublicationState
+} from './skill-transaction-publication-state'
+import {
+ isSkillBundleFileIdentity,
+ isSkillReleasedSnapshot
+} from './skill-transaction-snapshot-validation'
+
+export type SkillTransactionMarker = {
+ schemaVersion: 1
+ transactionId: string
+ createdAt: number
+ destinationId: string
+ destinationPath: string
+ physicalIdentity: string
+ hostId: ExecutionHostId
+ skillName: string
+ phase: 'staging' | 'publishing' | 'verified'
+ stagePhysicalIdentity: string | null
+ backupPhysicalIdentity: string | null
+ stageBuild: SkillTransactionPackageBuildEvidence | null
+ backupBuild: SkillTransactionPackageBuildEvidence | null
+ publication: SkillPublicationState | null
+ priorSnapshot: SkillReleasedSnapshot
+ currentSnapshot: SkillReleasedSnapshot
+}
+
+function validInPlaceSemantics(
+ publication: SkillInPlacePublicationState,
+ prior: SkillReleasedSnapshot,
+ current: SkillReleasedSnapshot
+): boolean {
+ const operations = skillInPlaceOperations(prior, current)
+ if (publication.operationIndex > operations.length) {
+ return false
+ }
+ if (publication.operationState === 'ready') {
+ return publication.temporary === null
+ }
+ const operation =
+ operations[
+ publication.direction === 'forward'
+ ? publication.operationIndex
+ : publication.operationIndex - 1
+ ]
+ if (!operation) {
+ return false
+ }
+ const expected =
+ publication.direction === 'forward'
+ ? current.files.find((file) => file.path === operation.path)
+ : prior.files.find((file) => file.path === operation.path)
+ if (!publication.temporary) {
+ return true
+ }
+ return Boolean(
+ expected &&
+ publication.temporary.path.startsWith(`${operation.path}.orca-`) &&
+ sameCompleteFileIdentity(publication.temporary.expectedFile, expected)
+ )
+}
+
+function validBuildSemantics(
+ evidence: SkillTransactionPackageBuildEvidence | null,
+ snapshot: SkillReleasedSnapshot
+): boolean {
+ if (!evidence) {
+ return true
+ }
+ if (evidence.operationIndex > snapshot.files.length) {
+ return false
+ }
+ if (evidence.state === 'ready') {
+ return (
+ evidence.operationIndex === snapshot.files.length &&
+ evidence.pendingPath === null &&
+ evidence.activeFile === null
+ )
+ }
+ if (evidence.pendingPath !== null) {
+ return (
+ evidence.activeFile === null &&
+ snapshot.files[evidence.operationIndex]?.path === evidence.pendingPath
+ )
+ }
+ if (!evidence.activeFile) {
+ return true
+ }
+ return snapshot.files[evidence.operationIndex]?.path === evidence.activeFile.path
+}
+
+function validMarkerSemantics(marker: SkillTransactionMarker): boolean {
+ if (marker.currentSnapshot.releaseRevision <= marker.priorSnapshot.releaseRevision) {
+ return false
+ }
+ if (
+ !validBuildSemantics(marker.stageBuild, marker.currentSnapshot) ||
+ !validBuildSemantics(marker.backupBuild, marker.priorSnapshot) ||
+ marker.stagePhysicalIdentity !== (marker.stageBuild?.physicalIdentity ?? null) ||
+ marker.backupPhysicalIdentity !== (marker.backupBuild?.physicalIdentity ?? null) ||
+ (marker.backupBuild && marker.stageBuild?.state !== 'ready')
+ ) {
+ return false
+ }
+ if (!marker.publication) {
+ return marker.phase === 'staging'
+ }
+ if (marker.stageBuild?.state !== 'ready' || marker.backupBuild?.state !== 'ready') {
+ return false
+ }
+ if (
+ marker.publication.kind === 'in-place' &&
+ !validInPlaceSemantics(marker.publication, marker.priorSnapshot, marker.currentSnapshot)
+ ) {
+ return false
+ }
+ if (marker.phase !== 'verified') {
+ return true
+ }
+ if (marker.publication.kind === 'package-swap') {
+ return marker.publication.step === 'complete'
+ }
+ return (
+ marker.publication.direction === 'forward' &&
+ marker.publication.operationState === 'ready' &&
+ marker.publication.temporary === null &&
+ marker.publication.operationIndex ===
+ skillInPlaceOperations(marker.priorSnapshot, marker.currentSnapshot).length
+ )
+}
+
+function isPublicationState(value: unknown): value is SkillPublicationState {
+ if (typeof value !== 'object' || value === null || !('kind' in value)) {
+ return false
+ }
+ if (value.kind === 'in-place') {
+ return (
+ 'operationIndex' in value &&
+ typeof value.operationIndex === 'number' &&
+ Number.isSafeInteger(value.operationIndex) &&
+ value.operationIndex >= 0 &&
+ 'direction' in value &&
+ (value.direction === 'forward' || value.direction === 'rollback') &&
+ 'physicalIdentity' in value &&
+ typeof value.physicalIdentity === 'string' &&
+ value.physicalIdentity.length > 0 &&
+ 'temporary' in value &&
+ (value.temporary === null ||
+ (typeof value.temporary === 'object' &&
+ value.temporary !== null &&
+ 'path' in value.temporary &&
+ typeof value.temporary.path === 'string' &&
+ value.temporary.path.endsWith('.tmp') &&
+ !posix.isAbsolute(value.temporary.path) &&
+ posix.normalize(value.temporary.path) === value.temporary.path &&
+ !value.temporary.path.split('/').includes('..') &&
+ !value.temporary.path.includes('\\') &&
+ 'physicalIdentity' in value.temporary &&
+ typeof value.temporary.physicalIdentity === 'string' &&
+ value.temporary.physicalIdentity.length > 0 &&
+ 'state' in value.temporary &&
+ (value.temporary.state === 'creating' ||
+ value.temporary.state === 'written' ||
+ value.temporary.state === 'ready' ||
+ value.temporary.state === 'applied') &&
+ 'expectedFile' in value.temporary &&
+ isSkillBundleFileIdentity(value.temporary.expectedFile))) &&
+ 'operationState' in value &&
+ (value.operationState === 'ready' || value.operationState === 'applying')
+ )
+ }
+ return (
+ value.kind === 'package-swap' &&
+ 'step' in value &&
+ (value.step === 'moving-live' ||
+ value.step === 'live-moved' ||
+ value.step === 'moving-stage' ||
+ value.step === 'stage-moved' ||
+ value.step === 'rollback-moving-current' ||
+ value.step === 'rollback-current-moved' ||
+ value.step === 'rollback-moving-old-live' ||
+ value.step === 'complete')
+ )
+}
+
+export function parseSkillTransactionMarker(value: string): SkillTransactionMarker | null {
+ try {
+ const parsed: unknown = JSON.parse(value)
+ if (typeof parsed !== 'object' || parsed === null) {
+ return null
+ }
+ const marker = parsed as Partial
+ const hostId = normalizeExecutionHostId(marker.hostId)
+ if (
+ marker.schemaVersion !== 1 ||
+ typeof marker.transactionId !== 'string' ||
+ marker.transactionId.length === 0 ||
+ typeof marker.createdAt !== 'number' ||
+ !Number.isFinite(marker.createdAt) ||
+ typeof marker.destinationId !== 'string' ||
+ !/^[a-f0-9]{24}$/.test(marker.destinationId) ||
+ typeof marker.destinationPath !== 'string' ||
+ !isAbsolute(marker.destinationPath) ||
+ typeof marker.physicalIdentity !== 'string' ||
+ marker.physicalIdentity.length === 0 ||
+ typeof marker.skillName !== 'string' ||
+ marker.skillName.length === 0 ||
+ (marker.stagePhysicalIdentity !== null &&
+ (typeof marker.stagePhysicalIdentity !== 'string' ||
+ marker.stagePhysicalIdentity.length === 0)) ||
+ (marker.backupPhysicalIdentity !== null &&
+ (typeof marker.backupPhysicalIdentity !== 'string' ||
+ marker.backupPhysicalIdentity.length === 0)) ||
+ (marker.stageBuild !== null && !isSkillTransactionPackageBuildEvidence(marker.stageBuild)) ||
+ (marker.backupBuild !== null &&
+ !isSkillTransactionPackageBuildEvidence(marker.backupBuild)) ||
+ !hostId ||
+ (marker.phase !== 'staging' &&
+ marker.phase !== 'publishing' &&
+ marker.phase !== 'verified') ||
+ !isSkillReleasedSnapshot(marker.priorSnapshot) ||
+ !isSkillReleasedSnapshot(marker.currentSnapshot)
+ ) {
+ return null
+ }
+ const validated = { ...marker, hostId } as SkillTransactionMarker
+ if (!validMarkerSemantics(validated)) {
+ return null
+ }
+ if (
+ (marker.phase === 'staging' && marker.publication !== null) ||
+ (marker.phase !== 'staging' && !isPublicationState(marker.publication)) ||
+ (marker.phase !== 'staging' &&
+ (marker.stageBuild?.state !== 'ready' || marker.backupBuild?.state !== 'ready'))
+ ) {
+ return null
+ }
+ if (
+ normalizedSkillIdentityPath(basename(marker.destinationPath)) !==
+ normalizedSkillIdentityPath(marker.skillName)
+ ) {
+ return null
+ }
+ return validated
+ } catch {
+ return null
+ }
+}
+
+export async function writeSkillTransactionMarker(
+ transactionRoot: string,
+ marker: SkillTransactionMarker,
+ beforeMutation: () => Promise = async () => undefined
+): Promise {
+ const markerPath = join(transactionRoot, 'transaction.json')
+ const temporaryPath = join(transactionRoot, `.transaction-${randomUUID()}.tmp`)
+ const serialized = JSON.stringify(marker)
+ if (!parseSkillTransactionMarker(serialized)) {
+ throw new Error('skill-transaction-marker-invalid')
+ }
+ try {
+ await beforeMutation()
+ await writeFile(temporaryPath, serialized)
+ // Why: recovery must observe either the old or new phase, never a torn
+ // marker that could discard the only verified rollback package.
+ await beforeMutation()
+ await rename(temporaryPath, markerPath)
+ } finally {
+ // Why: a redirected transaction path is no longer Orca-owned cleanup.
+ if (
+ await beforeMutation()
+ .then(() => true)
+ .catch(() => false)
+ ) {
+ await rm(temporaryPath, { force: true }).catch(() => undefined)
+ }
+ }
+}
diff --git a/src/main/skills/skill-transaction-owned-state.ts b/src/main/skills/skill-transaction-owned-state.ts
new file mode 100644
index 00000000000..20b9ff78fbd
--- /dev/null
+++ b/src/main/skills/skill-transaction-owned-state.ts
@@ -0,0 +1,305 @@
+import { lstat, stat } from 'node:fs/promises'
+import { join } from 'node:path'
+import { matchingReleasedSnapshot, observeSkillPackage } from './skill-package-identity'
+import { skillPhysicalIdentity } from './skill-installation-topology'
+import type { SkillTransactionMarker } from './skill-transaction-marker'
+import { assertSkillTransactionPackageBuild } from './skill-transaction-package-build'
+import {
+ matchesInPlacePublicationState,
+ skillInPlaceOperations
+} from './skill-transaction-publication-state'
+
+export type SkillTransactionLiveState = 'prior' | 'partial' | 'current' | 'swap-absent'
+
+async function isAbsent(path: string): Promise {
+ return lstat(path)
+ .then(() => false)
+ .catch((error) => {
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
+ return true
+ }
+ throw error
+ })
+}
+
+async function matchesOwnedPackage(args: {
+ path: string
+ physicalIdentity: string
+ snapshot: SkillTransactionMarker['priorSnapshot']
+}): Promise {
+ try {
+ const entry = await lstat(args.path)
+ if (!entry.isDirectory() || entry.isSymbolicLink()) {
+ return false
+ }
+ const pathStat = await stat(args.path)
+ if (skillPhysicalIdentity(args.path, pathStat) !== args.physicalIdentity) {
+ return false
+ }
+ return Boolean(matchingReleasedSnapshot(await observeSkillPackage(args.path), [args.snapshot]))
+ } catch {
+ return false
+ }
+}
+
+async function assertStageCurrent(
+ transactionRoot: string,
+ marker: SkillTransactionMarker
+): Promise {
+ if (
+ !marker.stageBuild ||
+ marker.stageBuild.state !== 'ready' ||
+ marker.stageBuild.physicalIdentity !== marker.stagePhysicalIdentity
+ ) {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ await assertSkillTransactionPackageBuild({
+ root: join(transactionRoot, 'stage'),
+ evidence: marker.stageBuild,
+ snapshot: marker.currentSnapshot
+ }).catch(() => {
+ throw new Error('skill-transaction-recovery-required')
+ })
+}
+
+async function assertOldLivePrior(
+ transactionRoot: string,
+ marker: SkillTransactionMarker
+): Promise {
+ if (
+ !(await matchesOwnedPackage({
+ path: join(transactionRoot, 'old-live'),
+ physicalIdentity: marker.physicalIdentity,
+ snapshot: marker.priorSnapshot
+ }))
+ ) {
+ throw new Error('skill-transaction-recovery-required')
+ }
+}
+
+async function assertBackupPrior(
+ transactionRoot: string,
+ marker: SkillTransactionMarker
+): Promise {
+ if (
+ !marker.backupBuild ||
+ marker.backupBuild.state !== 'ready' ||
+ marker.backupBuild.physicalIdentity !== marker.backupPhysicalIdentity
+ ) {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ await assertSkillTransactionPackageBuild({
+ root: join(transactionRoot, 'backup'),
+ evidence: marker.backupBuild,
+ snapshot: marker.priorSnapshot
+ }).catch(() => {
+ throw new Error('skill-transaction-recovery-required')
+ })
+}
+
+async function liveOwnedSnapshot(
+ marker: SkillTransactionMarker,
+ snapshot: SkillTransactionMarker['priorSnapshot'],
+ physicalIdentity: string
+): Promise {
+ return matchesOwnedPackage({ path: marker.destinationPath, physicalIdentity, snapshot })
+}
+
+async function assertInPlaceState(args: {
+ transactionRoot: string
+ marker: SkillTransactionMarker
+ allowAppliedOperation: boolean
+}): Promise {
+ const publication = args.marker.publication
+ if (!publication || publication.kind !== 'in-place' || !args.marker.stagePhysicalIdentity) {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ if (publication.direction === 'forward') {
+ await assertStageCurrent(args.transactionRoot, args.marker)
+ }
+ const liveStat = await stat(args.marker.destinationPath).catch(() => null)
+ if (
+ !liveStat ||
+ skillPhysicalIdentity(args.marker.destinationPath, liveStat) !== publication.physicalIdentity
+ ) {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ if (publication.temporary) {
+ const temporaryPath = join(
+ args.marker.destinationPath,
+ ...publication.temporary.path.split('/')
+ )
+ const temporaryEntry = await lstat(temporaryPath).catch(() => null)
+ if (
+ temporaryEntry &&
+ (!temporaryEntry.isFile() ||
+ temporaryEntry.isSymbolicLink() ||
+ skillPhysicalIdentity(temporaryPath, await stat(temporaryPath)) !==
+ publication.temporary.physicalIdentity)
+ ) {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ if (!temporaryEntry) {
+ const operations = skillInPlaceOperations(
+ args.marker.priorSnapshot,
+ args.marker.currentSnapshot
+ )
+ const operation =
+ operations[
+ publication.direction === 'forward'
+ ? publication.operationIndex
+ : publication.operationIndex - 1
+ ]
+ const targetPath = operation
+ ? join(args.marker.destinationPath, ...operation.path.split('/'))
+ : null
+ const targetEntry = targetPath ? await lstat(targetPath).catch(() => null) : null
+ // Why: a missing ready temp proves a completed rename only when its inode
+ // is now the exact operation target; equal bytes from another writer do not.
+ if (
+ publication.temporary.state === 'creating' ||
+ publication.temporary.state === 'written' ||
+ !targetPath ||
+ !targetEntry?.isFile() ||
+ targetEntry.isSymbolicLink() ||
+ skillPhysicalIdentity(targetPath, await stat(targetPath)) !==
+ publication.temporary.physicalIdentity
+ ) {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ } else if (publication.temporary.state === 'applied') {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ }
+ const observed = await observeSkillPackage(args.marker.destinationPath).catch(() => null)
+ if (
+ !observed ||
+ !matchesInPlacePublicationState({
+ observed,
+ prior: args.marker.priorSnapshot,
+ current: args.marker.currentSnapshot,
+ publication,
+ allowAppliedOperation: args.allowAppliedOperation
+ })
+ ) {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ if (matchingReleasedSnapshot(observed, [args.marker.priorSnapshot])) {
+ return 'prior'
+ }
+ if (matchingReleasedSnapshot(observed, [args.marker.currentSnapshot])) {
+ return 'current'
+ }
+ return 'partial'
+}
+
+async function assertSwapState(args: {
+ transactionRoot: string
+ marker: SkillTransactionMarker
+}): Promise {
+ const publication = args.marker.publication
+ if (!publication || publication.kind !== 'package-swap' || !args.marker.stagePhysicalIdentity) {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ const stageRoot = join(args.transactionRoot, 'stage')
+ const oldLiveRoot = join(args.transactionRoot, 'old-live')
+ if (publication.step === 'moving-live') {
+ const livePrior = await liveOwnedSnapshot(
+ args.marker,
+ args.marker.priorSnapshot,
+ args.marker.physicalIdentity
+ )
+ if (livePrior && (await isAbsent(oldLiveRoot))) {
+ await assertStageCurrent(args.transactionRoot, args.marker)
+ return 'prior'
+ }
+ }
+ if (publication.step === 'moving-stage') {
+ const liveCurrent = await liveOwnedSnapshot(
+ args.marker,
+ args.marker.currentSnapshot,
+ args.marker.stagePhysicalIdentity
+ )
+ if (liveCurrent && (await isAbsent(stageRoot))) {
+ await assertOldLivePrior(args.transactionRoot, args.marker)
+ return 'current'
+ }
+ }
+ if (
+ publication.step === 'moving-live' ||
+ publication.step === 'live-moved' ||
+ publication.step === 'moving-stage'
+ ) {
+ if (!(await isAbsent(args.marker.destinationPath))) {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ await Promise.all([
+ assertOldLivePrior(args.transactionRoot, args.marker),
+ assertStageCurrent(args.transactionRoot, args.marker)
+ ])
+ return 'swap-absent'
+ }
+ if (publication.step === 'rollback-moving-old-live') {
+ const livePrior = await liveOwnedSnapshot(
+ args.marker,
+ args.marker.priorSnapshot,
+ args.marker.physicalIdentity
+ )
+ if (livePrior && (await isAbsent(oldLiveRoot))) {
+ await assertStageCurrent(args.transactionRoot, args.marker)
+ return 'prior'
+ }
+ }
+ if (
+ publication.step === 'rollback-moving-current' ||
+ publication.step === 'rollback-current-moved' ||
+ publication.step === 'rollback-moving-old-live'
+ ) {
+ if (publication.step === 'rollback-moving-current') {
+ const liveCurrent = await liveOwnedSnapshot(
+ args.marker,
+ args.marker.currentSnapshot,
+ args.marker.stagePhysicalIdentity
+ )
+ if (liveCurrent && (await isAbsent(stageRoot))) {
+ await assertOldLivePrior(args.transactionRoot, args.marker)
+ return 'current'
+ }
+ }
+ if (!(await isAbsent(args.marker.destinationPath))) {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ await Promise.all([
+ assertOldLivePrior(args.transactionRoot, args.marker),
+ assertStageCurrent(args.transactionRoot, args.marker)
+ ])
+ return 'swap-absent'
+ }
+ const liveCurrent = await liveOwnedSnapshot(
+ args.marker,
+ args.marker.currentSnapshot,
+ args.marker.stagePhysicalIdentity
+ )
+ if (!liveCurrent || !(await isAbsent(stageRoot))) {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ await assertOldLivePrior(args.transactionRoot, args.marker)
+ return 'current'
+}
+
+export async function assertTransactionOwnedState(args: {
+ transactionRoot: string
+ marker: SkillTransactionMarker
+ assertParent: () => Promise
+ allowAppliedOperation: boolean
+}): Promise {
+ await args.assertParent()
+ if (args.marker.phase === 'staging' || !args.marker.publication) {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ await assertBackupPrior(args.transactionRoot, args.marker)
+ if (args.marker.publication.kind === 'in-place') {
+ return assertInPlaceState(args)
+ }
+ return assertSwapState(args)
+}
diff --git a/src/main/skills/skill-transaction-package-build.test.ts b/src/main/skills/skill-transaction-package-build.test.ts
new file mode 100644
index 00000000000..b37c94e0064
--- /dev/null
+++ b/src/main/skills/skill-transaction-package-build.test.ts
@@ -0,0 +1,187 @@
+import { chmod, mkdir, mkdtemp, open, readFile, rename, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { SkillReleasedSnapshot } from '../../shared/skill-management'
+import { observeSkillPackage, persistedObservedFiles } from './skill-package-identity'
+import {
+ assertSkillTransactionPackageBuild,
+ buildSkillTransactionPackage,
+ type SkillTransactionPackageBuildEvidence,
+ type SkillTransactionPackageBuildRuntime
+} from './skill-transaction-package-build'
+
+const roots: string[] = []
+
+async function fixture() {
+ const root = await mkdtemp(join(tmpdir(), 'orca-package-build-'))
+ roots.push(root)
+ const sourceRoot = join(root, 'source')
+ const packageRoot = join(root, 'stage')
+ await mkdir(join(sourceRoot, 'assets'), { recursive: true })
+ await writeFile(join(sourceRoot, 'SKILL.md'), '# Test\n')
+ await writeFile(join(sourceRoot, 'assets', 'run.bin'), Buffer.from([0, 1, 2, 3]))
+ await chmod(join(sourceRoot, 'assets', 'run.bin'), 0o755)
+ const observed = await observeSkillPackage(sourceRoot)
+ const snapshot: SkillReleasedSnapshot = {
+ releaseRevision: 2,
+ packageDigest: observed.observedDigest,
+ gitTreeSha: '',
+ files: persistedObservedFiles(observed)
+ }
+ let lastEvidence: SkillTransactionPackageBuildEvidence | null = null
+ const build = (runtime?: SkillTransactionPackageBuildRuntime) =>
+ buildSkillTransactionPackage({
+ root: packageRoot,
+ sourceRoot,
+ snapshot,
+ crlf: false,
+ assertSourceAuthority: async () => undefined,
+ journal: async (evidence) => {
+ lastEvidence = structuredClone(evidence)
+ },
+ assertBoundary: async (evidence) =>
+ assertSkillTransactionPackageBuild({ root: packageRoot, evidence, snapshot }),
+ runtime
+ })
+ return { root, sourceRoot, packageRoot, snapshot, build, lastEvidence: () => lastEvidence }
+}
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('skill transaction package build authority', () => {
+ it('journals deterministic prefixes and a ready exact package', async () => {
+ const value = await fixture()
+ const ready = await value.build()
+ expect(ready).toEqual({
+ state: 'ready',
+ physicalIdentity: expect.any(String),
+ operationIndex: value.snapshot.files.length,
+ pendingPath: null,
+ activeFile: null
+ })
+ await expect(
+ assertSkillTransactionPackageBuild({
+ root: value.packageRoot,
+ evidence: ready,
+ snapshot: value.snapshot
+ })
+ ).resolves.toBeUndefined()
+ })
+
+ it('does not fill a replacement root introduced while the root journal is awaited', async () => {
+ const value = await fixture()
+ const ownedRoot = `${value.packageRoot}-owned`
+ let replaced = false
+ await expect(
+ buildSkillTransactionPackage({
+ root: value.packageRoot,
+ sourceRoot: value.sourceRoot,
+ snapshot: value.snapshot,
+ crlf: false,
+ assertSourceAuthority: async () => undefined,
+ journal: async () => {
+ if (!replaced) {
+ replaced = true
+ await rename(value.packageRoot, ownedRoot)
+ await mkdir(value.packageRoot)
+ }
+ },
+ assertBoundary: async (evidence) =>
+ assertSkillTransactionPackageBuild({
+ root: value.packageRoot,
+ evidence,
+ snapshot: value.snapshot
+ })
+ })
+ ).rejects.toThrow('skill-transaction-package-build-changed')
+ expect(await observeSkillPackage(value.packageRoot)).toMatchObject({ files: [] })
+ expect(await observeSkillPackage(ownedRoot)).toMatchObject({ files: [] })
+ })
+
+ it.each(['write', 'chmod', 'sync'] as const)(
+ 'retains phase-valid evidence when %s mutates and throws',
+ async (boundary) => {
+ const value = await fixture()
+ const runtime: SkillTransactionPackageBuildRuntime = {}
+ if (boundary === 'write') {
+ runtime.writeBytes = async (handle, bytes) => {
+ await handle.writeFile(bytes.subarray(0, 1))
+ throw new Error('injected-write-failure')
+ }
+ } else if (boundary === 'chmod') {
+ runtime.chmodFile = async (handle, mode) => {
+ await handle.chmod(mode)
+ throw new Error('injected-chmod-failure')
+ }
+ } else {
+ runtime.syncFile = async (handle) => {
+ await handle.sync()
+ throw new Error('injected-sync-failure')
+ }
+ }
+ await expect(value.build(runtime)).rejects.toThrow(`injected-${boundary}-failure`)
+ const evidence = value.lastEvidence()
+ expect(evidence?.state).toBe('creating')
+ await expect(
+ assertSkillTransactionPackageBuild({
+ root: value.packageRoot,
+ evidence: evidence!,
+ snapshot: value.snapshot
+ })
+ ).resolves.toBeUndefined()
+ }
+ )
+
+ it.each(['parent', 'file'] as const)(
+ 'classifies a mutate-and-throw %s boundary exactly',
+ async (boundary) => {
+ const value = await fixture()
+ const runtime: SkillTransactionPackageBuildRuntime =
+ boundary === 'parent'
+ ? {
+ createParent: async (path) => {
+ await mkdir(path, { recursive: true })
+ throw new Error('injected-parent-failure')
+ }
+ }
+ : {
+ openFile: async (path, mode) => {
+ const handle = await open(path, 'wx', mode)
+ await handle.close()
+ throw new Error('injected-file-failure')
+ }
+ }
+ await expect(value.build(runtime)).rejects.toThrow(`injected-${boundary}-failure`)
+ const evidence = value.lastEvidence()
+ expect(evidence?.activeFile).toBeNull()
+ const assertion = assertSkillTransactionPackageBuild({
+ root: value.packageRoot,
+ evidence: evidence!,
+ snapshot: value.snapshot
+ })
+ if (boundary === 'parent') {
+ expect(evidence?.pendingPath).toBe(value.snapshot.files[0]!.path)
+ await expect(assertion).resolves.toBeUndefined()
+ } else {
+ await expect(assertion).rejects.toThrow('skill-transaction-package-build-changed')
+ }
+ }
+ )
+
+ it('rejects unknown directory extras even when file content is exact', async () => {
+ const value = await fixture()
+ const ready = await value.build()
+ await mkdir(join(value.packageRoot, 'unknown'))
+ await expect(
+ assertSkillTransactionPackageBuild({
+ root: value.packageRoot,
+ evidence: ready,
+ snapshot: value.snapshot
+ })
+ ).rejects.toThrow('skill-transaction-package-build-changed')
+ expect(await readFile(join(value.packageRoot, 'SKILL.md'), 'utf8')).toBe('# Test\n')
+ })
+})
diff --git a/src/main/skills/skill-transaction-package-build.ts b/src/main/skills/skill-transaction-package-build.ts
new file mode 100644
index 00000000000..84d8b0256b4
--- /dev/null
+++ b/src/main/skills/skill-transaction-package-build.ts
@@ -0,0 +1,290 @@
+import { lstat, mkdir, open, readFile, readdir, stat, type FileHandle } from 'node:fs/promises'
+import { dirname, join, posix } from 'node:path'
+import type { SkillBundleFileIdentity, SkillReleasedSnapshot } from '../../shared/skill-management'
+import { skillPhysicalIdentity } from './skill-installation-topology'
+import {
+ describeObservedSkillFile,
+ matchesFileIdentity,
+ observeSkillPackage
+} from './skill-package-identity'
+
+export type SkillTransactionPackageBuildEvidence = {
+ state: 'creating' | 'ready'
+ physicalIdentity: string
+ operationIndex: number
+ pendingPath: string | null
+ activeFile: {
+ path: string
+ physicalIdentity: string
+ contentState: 'created' | 'written' | 'ready'
+ } | null
+}
+
+export type SkillTransactionPackageBuildRuntime = {
+ createRoot?: (path: string) => Promise
+ createParent?: (path: string) => Promise
+ openFile?: (path: string, mode: number) => Promise
+ readSource?: (path: string) => Promise
+ writeBytes?: (handle: FileHandle, bytes: Buffer) => Promise
+ chmodFile?: (handle: FileHandle, mode: number) => Promise
+ syncFile?: (handle: FileHandle) => Promise
+}
+
+export function isSkillTransactionPackageBuildEvidence(
+ value: unknown
+): value is SkillTransactionPackageBuildEvidence {
+ if (typeof value !== 'object' || value === null) {
+ return false
+ }
+ const evidence = value as Partial
+ const validPath = (path: unknown): path is string =>
+ typeof path === 'string' &&
+ path.length > 0 &&
+ !path.includes('\\') &&
+ !posix.isAbsolute(path) &&
+ posix.normalize(path) === path &&
+ !path.split('/').includes('..')
+ const activeFile = evidence.activeFile
+ return Boolean(
+ (evidence.state === 'creating' || evidence.state === 'ready') &&
+ typeof evidence.physicalIdentity === 'string' &&
+ evidence.physicalIdentity.length > 0 &&
+ typeof evidence.operationIndex === 'number' &&
+ Number.isSafeInteger(evidence.operationIndex) &&
+ evidence.operationIndex >= 0 &&
+ (evidence.pendingPath === null || validPath(evidence.pendingPath)) &&
+ (activeFile === null ||
+ (typeof activeFile === 'object' &&
+ activeFile !== null &&
+ validPath(activeFile.path) &&
+ typeof activeFile.physicalIdentity === 'string' &&
+ activeFile.physicalIdentity.length > 0 &&
+ (activeFile.contentState === 'created' ||
+ activeFile.contentState === 'written' ||
+ activeFile.contentState === 'ready')))
+ )
+}
+
+function expectedDirectories(paths: readonly string[]): Set {
+ const directories = new Set()
+ for (const path of paths) {
+ const parts = path.split('/')
+ for (let index = 1; index < parts.length; index += 1) {
+ directories.add(parts.slice(0, index).join('/'))
+ }
+ }
+ return directories
+}
+
+async function assertDirectoryShape(root: string, paths: readonly string[]): Promise {
+ const allowed = expectedDirectories(paths)
+ const visit = async (directory: string, relativeDirectory: string): Promise => {
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
+ const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name
+ if (entry.isDirectory()) {
+ if (!allowed.has(relativePath)) {
+ throw new Error('skill-transaction-package-build-changed')
+ }
+ await visit(join(directory, entry.name), relativePath)
+ } else if (!entry.isFile()) {
+ throw new Error('skill-transaction-package-build-changed')
+ }
+ }
+ }
+ await visit(root, '')
+}
+
+function matchesPrefix(
+ observed: Awaited>,
+ snapshot: SkillReleasedSnapshot,
+ count: number
+): boolean {
+ return (
+ observed.files.length === count &&
+ snapshot.files
+ .slice(0, count)
+ .every((expected, index) => matchesFileIdentity(observed.files[index]!, expected))
+ )
+}
+
+export async function assertSkillTransactionPackageBuild(args: {
+ root: string
+ evidence: SkillTransactionPackageBuildEvidence
+ snapshot: SkillReleasedSnapshot
+}): Promise {
+ const rootEntry = await lstat(args.root)
+ if (
+ !rootEntry.isDirectory() ||
+ rootEntry.isSymbolicLink() ||
+ skillPhysicalIdentity(args.root, rootEntry) !== args.evidence.physicalIdentity
+ ) {
+ throw new Error('skill-transaction-package-build-changed')
+ }
+ const observed = await observeSkillPackage(args.root)
+ const expectedPaths = args.snapshot.files
+ .slice(0, args.evidence.operationIndex)
+ .map((file) => file.path)
+ if (args.evidence.activeFile) {
+ const expected = args.snapshot.files[args.evidence.operationIndex]
+ const activePath = join(args.root, ...args.evidence.activeFile.path.split('/'))
+ const activeEntry = await lstat(activePath)
+ const withoutActive = {
+ ...observed,
+ files: observed.files.filter((file) => file.path !== args.evidence.activeFile?.path)
+ }
+ if (
+ !expected ||
+ expected.path !== args.evidence.activeFile.path ||
+ !activeEntry.isFile() ||
+ activeEntry.isSymbolicLink() ||
+ skillPhysicalIdentity(activePath, await stat(activePath)) !==
+ args.evidence.activeFile.physicalIdentity ||
+ observed.files.length !== withoutActive.files.length + 1 ||
+ !matchesPrefix(withoutActive, args.snapshot, args.evidence.operationIndex)
+ ) {
+ throw new Error('skill-transaction-package-build-changed')
+ }
+ const activeObserved = observed.files.find(
+ (file) => file.path === args.evidence.activeFile?.path
+ )
+ if (
+ !activeObserved ||
+ (args.evidence.activeFile.contentState === 'written' &&
+ !matchesFileIdentity({ ...activeObserved, executable: expected.executable }, expected)) ||
+ (args.evidence.activeFile.contentState === 'ready' &&
+ !matchesFileIdentity(activeObserved, expected))
+ ) {
+ throw new Error('skill-transaction-package-build-changed')
+ }
+ await assertDirectoryShape(args.root, [...expectedPaths, expected.path])
+ return
+ }
+ if (!matchesPrefix(observed, args.snapshot, args.evidence.operationIndex)) {
+ throw new Error('skill-transaction-package-build-changed')
+ }
+ await assertDirectoryShape(
+ args.root,
+ args.evidence.pendingPath ? [...expectedPaths, args.evidence.pendingPath] : expectedPaths
+ )
+}
+
+function renderedBytes(source: Buffer, expected: SkillBundleFileIdentity, crlf: boolean): Buffer {
+ if (expected.classification !== 'text' || expected.executable || !crlf) {
+ return source
+ }
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(source)
+ return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'), 'utf8')
+}
+
+export async function buildSkillTransactionPackage(args: {
+ root: string
+ sourceRoot: string
+ snapshot: SkillReleasedSnapshot
+ crlf: boolean
+ assertSourceAuthority: () => Promise
+ journal: (evidence: SkillTransactionPackageBuildEvidence) => Promise
+ assertBoundary: (evidence: SkillTransactionPackageBuildEvidence) => Promise
+ runtime?: SkillTransactionPackageBuildRuntime
+}): Promise {
+ await args.assertSourceAuthority()
+ await (args.runtime?.createRoot ?? ((path) => mkdir(path)))(args.root)
+ const rootEntry = await lstat(args.root)
+ let evidence: SkillTransactionPackageBuildEvidence = {
+ state: 'creating',
+ physicalIdentity: skillPhysicalIdentity(args.root, rootEntry),
+ operationIndex: 0,
+ pendingPath: null,
+ activeFile: null
+ }
+ await args.journal(evidence)
+ for (const [operationIndex, expected] of args.snapshot.files.entries()) {
+ evidence = { ...evidence, operationIndex, pendingPath: expected.path, activeFile: null }
+ await args.journal(evidence)
+ await args.assertBoundary(evidence)
+ const destination = join(args.root, ...expected.path.split('/'))
+ await (args.runtime?.createParent ?? ((path) => mkdir(path, { recursive: true })))(
+ dirname(destination)
+ )
+ await args.assertBoundary(evidence)
+ const mode = expected.executable ? 0o755 : 0o644
+ const handle = await (
+ args.runtime?.openFile ?? ((path, fileMode) => open(path, 'wx', fileMode))
+ )(destination, mode)
+ const activeFileIdentity = skillPhysicalIdentity(destination, await handle.stat())
+ evidence = {
+ ...evidence,
+ operationIndex,
+ pendingPath: null,
+ activeFile: {
+ path: expected.path,
+ physicalIdentity: activeFileIdentity,
+ contentState: 'created'
+ }
+ }
+ try {
+ await args.journal(evidence)
+ await args.assertSourceAuthority()
+ const bytes = renderedBytes(
+ await (args.runtime?.readSource ?? readFile)(
+ join(args.sourceRoot, ...expected.path.split('/'))
+ ),
+ expected,
+ args.crlf
+ )
+ const observedSource = describeObservedSkillFile(expected.path, bytes, expected.executable)
+ if (!matchesFileIdentity(observedSource, expected)) {
+ throw new Error('skill-transaction-package-source-changed')
+ }
+ await args.assertSourceAuthority()
+ await args.assertBoundary(evidence)
+ await (args.runtime?.writeBytes ?? ((target, value) => target.writeFile(value)))(
+ handle,
+ bytes
+ )
+ evidence = {
+ ...evidence,
+ activeFile: {
+ path: expected.path,
+ physicalIdentity: activeFileIdentity,
+ contentState: 'written'
+ }
+ }
+ await args.assertBoundary(evidence)
+ await args.journal(evidence)
+ if (process.platform !== 'win32') {
+ await args.assertBoundary(evidence)
+ await (args.runtime?.chmodFile ?? ((target, fileMode) => target.chmod(fileMode)))(
+ handle,
+ mode
+ )
+ }
+ evidence = {
+ ...evidence,
+ activeFile: {
+ path: expected.path,
+ physicalIdentity: activeFileIdentity,
+ contentState: 'ready'
+ }
+ }
+ await args.assertBoundary(evidence)
+ await args.journal(evidence)
+ await args.assertBoundary(evidence)
+ await (args.runtime?.syncFile ?? ((target) => target.sync()))(handle)
+ await args.assertBoundary(evidence)
+ } finally {
+ await handle.close()
+ }
+ evidence = {
+ ...evidence,
+ operationIndex: operationIndex + 1,
+ pendingPath: null,
+ activeFile: null
+ }
+ await args.assertBoundary(evidence)
+ await args.journal(evidence)
+ }
+ evidence = { ...evidence, state: 'ready' }
+ await args.assertBoundary(evidence)
+ await args.journal(evidence)
+ return evidence
+}
diff --git a/src/main/skills/skill-transaction-paths.ts b/src/main/skills/skill-transaction-paths.ts
new file mode 100644
index 00000000000..550cb2ebb6a
--- /dev/null
+++ b/src/main/skills/skill-transaction-paths.ts
@@ -0,0 +1 @@
+export const RESERVED_SKILL_TRANSACTION_DIRECTORY = '.orca-skill-transactions'
diff --git a/src/main/skills/skill-transaction-publication-state.test.ts b/src/main/skills/skill-transaction-publication-state.test.ts
new file mode 100644
index 00000000000..51339c48e47
--- /dev/null
+++ b/src/main/skills/skill-transaction-publication-state.test.ts
@@ -0,0 +1,220 @@
+import { chmod, mkdir, mkdtemp, readFile, rm, unlink, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { SkillReleasedSnapshot } from '../../shared/skill-management'
+import { observeSkillPackage, persistedObservedFiles } from './skill-package-identity'
+import {
+ matchesInPlacePublicationState,
+ skillInPlaceOperations
+} from './skill-transaction-publication-state'
+
+const roots: string[] = []
+
+async function packageRoot(files: Record): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'orca-publication-state-'))
+ roots.push(root)
+ for (const [path, contents] of Object.entries(files)) {
+ const destination = join(root, ...path.split('/'))
+ await mkdir(dirname(destination), { recursive: true })
+ await writeFile(destination, contents)
+ }
+ return root
+}
+
+async function snapshot(root: string, revision: number): Promise {
+ const observed = await observeSkillPackage(root)
+ return {
+ releaseRevision: revision,
+ packageDigest: observed.observedDigest,
+ gitTreeSha: '',
+ files: persistedObservedFiles(observed)
+ }
+}
+
+async function fixture() {
+ const priorRoot = await packageRoot({
+ 'SKILL.md': 'old entry',
+ 'asset-a.txt': 'old a',
+ 'obsolete-a.txt': 'old obsolete a',
+ 'obsolete-b.txt': 'old obsolete b'
+ })
+ const currentRoot = await packageRoot({
+ 'SKILL.md': 'new entry',
+ 'asset-a.txt': 'new a',
+ 'asset-b.txt': 'new b'
+ })
+ return {
+ priorRoot,
+ currentRoot,
+ prior: await snapshot(priorRoot, 1),
+ current: await snapshot(currentRoot, 2)
+ }
+}
+
+async function stateRoot(currentRoot: string, completed: number): Promise {
+ const root = await packageRoot({
+ 'SKILL.md': 'old entry',
+ 'asset-a.txt': 'old a',
+ 'obsolete-a.txt': 'old obsolete a',
+ 'obsolete-b.txt': 'old obsolete b'
+ })
+ const operations = [
+ ['publish', 'asset-a.txt'],
+ ['publish', 'asset-b.txt'],
+ ['publish', 'SKILL.md'],
+ ['remove', 'obsolete-a.txt'],
+ ['remove', 'obsolete-b.txt']
+ ] as const
+ for (const [kind, path] of operations.slice(0, completed)) {
+ await (kind === 'remove'
+ ? unlink(join(root, path))
+ : writeFile(join(root, path), await readFile(join(currentRoot, path))))
+ }
+ return root
+}
+
+afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
+})
+
+describe('skill transaction publication state', () => {
+ it('accepts every exact publication prefix in asset, semantic-commit, removal order', async () => {
+ const { currentRoot, prior, current } = await fixture()
+ expect(skillInPlaceOperations(prior, current)).toEqual([
+ { kind: 'publish', path: 'asset-a.txt' },
+ { kind: 'publish', path: 'asset-b.txt' },
+ { kind: 'publish', path: 'SKILL.md' },
+ { kind: 'remove', path: 'obsolete-a.txt' },
+ { kind: 'remove', path: 'obsolete-b.txt' }
+ ])
+ for (let operationIndex = 0; operationIndex <= 5; operationIndex += 1) {
+ const observed = await observeSkillPackage(await stateRoot(currentRoot, operationIndex))
+ expect(
+ matchesInPlacePublicationState({
+ observed,
+ prior,
+ current,
+ publication: {
+ kind: 'in-place',
+ direction: 'forward',
+ physicalIdentity: 'test-identity',
+ temporary: null,
+ operationIndex,
+ operationState: 'ready'
+ },
+ allowAppliedOperation: false
+ })
+ ).toBe(true)
+ }
+ })
+
+ it.each([
+ ['semantic commit before assets', { 'SKILL.md': 'new entry' }],
+ ['later asset before earlier asset', { 'asset-b.txt': 'new b' }],
+ ['obsolete removal before commit', { 'obsolete-a.txt': null }],
+ ['unknown extra', { 'unknown.txt': 'unknown' }]
+ ])('rejects impossible state: %s', async (_name, edits) => {
+ const { priorRoot, prior, current } = await fixture()
+ const root = await stateRoot(priorRoot, 0)
+ for (const [path, contents] of Object.entries(edits)) {
+ await (contents === null ? unlink(join(root, path)) : writeFile(join(root, path), contents))
+ }
+ expect(
+ matchesInPlacePublicationState({
+ observed: await observeSkillPackage(root),
+ prior,
+ current,
+ publication: {
+ kind: 'in-place',
+ direction: 'forward',
+ physicalIdentity: 'test-identity',
+ temporary: null,
+ operationIndex: 0,
+ operationState: 'applying'
+ },
+ allowAppliedOperation: true
+ })
+ ).toBe(false)
+ })
+
+ it('rejects a mode-only same-content edit', async () => {
+ if (process.platform === 'win32') {
+ return
+ }
+ const { priorRoot, prior, current } = await fixture()
+ const root = await stateRoot(priorRoot, 0)
+ await chmod(join(root, 'asset-a.txt'), 0o755)
+ expect(
+ matchesInPlacePublicationState({
+ observed: await observeSkillPackage(root),
+ prior,
+ current,
+ publication: {
+ kind: 'in-place',
+ direction: 'forward',
+ physicalIdentity: 'test-identity',
+ temporary: null,
+ operationIndex: 0,
+ operationState: 'ready'
+ },
+ allowAppliedOperation: false
+ })
+ ).toBe(false)
+ })
+
+ it('rejects same-inode independent edits at every publication prefix', async () => {
+ const { currentRoot, prior, current } = await fixture()
+ for (let operationIndex = 0; operationIndex <= 5; operationIndex += 1) {
+ const root = await stateRoot(currentRoot, operationIndex)
+ await writeFile(join(root, 'SKILL.md'), `independent edit at ${operationIndex}`)
+ expect(
+ matchesInPlacePublicationState({
+ observed: await observeSkillPackage(root),
+ prior,
+ current,
+ publication: {
+ kind: 'in-place',
+ direction: 'forward',
+ physicalIdentity: 'same-inode',
+ temporary: null,
+ operationIndex,
+ operationState: 'ready'
+ },
+ allowAppliedOperation: false
+ })
+ ).toBe(false)
+ }
+ })
+
+ it('rejects a binary byte mismatch outside both exact snapshots', async () => {
+ const priorRoot = await packageRoot({
+ 'SKILL.md': 'old entry',
+ 'payload.bin': Buffer.from([0, 1])
+ })
+ const currentRoot = await packageRoot({
+ 'SKILL.md': 'new entry',
+ 'payload.bin': Buffer.from([0, 2])
+ })
+ const observedRoot = await packageRoot({
+ 'SKILL.md': 'old entry',
+ 'payload.bin': Buffer.from([0, 3])
+ })
+ expect(
+ matchesInPlacePublicationState({
+ observed: await observeSkillPackage(observedRoot),
+ prior: await snapshot(priorRoot, 1),
+ current: await snapshot(currentRoot, 2),
+ publication: {
+ kind: 'in-place',
+ direction: 'forward',
+ physicalIdentity: 'same-inode',
+ temporary: null,
+ operationIndex: 0,
+ operationState: 'applying'
+ },
+ allowAppliedOperation: true
+ })
+ ).toBe(false)
+ })
+})
diff --git a/src/main/skills/skill-transaction-publication-state.ts b/src/main/skills/skill-transaction-publication-state.ts
new file mode 100644
index 00000000000..8e00186f54a
--- /dev/null
+++ b/src/main/skills/skill-transaction-publication-state.ts
@@ -0,0 +1,224 @@
+import type { SkillBundleFileIdentity, SkillReleasedSnapshot } from '../../shared/skill-management'
+import { matchesFileIdentity, type ObservedSkillPackage } from './skill-package-identity'
+
+export type SkillInPlacePublicationState = {
+ kind: 'in-place'
+ direction: 'forward' | 'rollback'
+ physicalIdentity: string
+ temporary: SkillPublicationTemporary | null
+ operationIndex: number
+ operationState: 'ready' | 'applying'
+}
+
+export type SkillPublicationTemporary = {
+ path: string
+ physicalIdentity: string
+ expectedFile: SkillBundleFileIdentity
+ state: 'creating' | 'written' | 'ready' | 'applied'
+}
+
+export type SkillPackageSwapState = {
+ kind: 'package-swap'
+ step:
+ | 'moving-live'
+ | 'live-moved'
+ | 'moving-stage'
+ | 'stage-moved'
+ | 'rollback-moving-current'
+ | 'rollback-current-moved'
+ | 'rollback-moving-old-live'
+ | 'complete'
+}
+
+export type SkillPublicationState = SkillInPlacePublicationState | SkillPackageSwapState
+
+export type SkillInPlaceOperation = {
+ kind: 'publish' | 'remove'
+ path: string
+}
+
+function sameFileIdentity(
+ left: SkillBundleFileIdentity | undefined,
+ right: SkillBundleFileIdentity
+): boolean {
+ return Boolean(
+ left &&
+ left.path === right.path &&
+ left.executable === right.executable &&
+ left.classification === right.classification &&
+ left.identitySha256 === right.identitySha256
+ )
+}
+
+export function sameCompleteFileIdentity(
+ left: SkillBundleFileIdentity | undefined,
+ right: SkillBundleFileIdentity
+): boolean {
+ return Boolean(
+ sameFileIdentity(left, right) &&
+ left?.size === right.size &&
+ left.exactSha256 === right.exactSha256 &&
+ left.textNormalizedSha256 === right.textNormalizedSha256
+ )
+}
+
+export function skillInPlaceOperations(
+ prior: SkillReleasedSnapshot,
+ current: SkillReleasedSnapshot
+): SkillInPlaceOperation[] {
+ const priorByPath = new Map(prior.files.map((file) => [file.path, file]))
+ const currentPaths = new Set(current.files.map((file) => file.path))
+ const changed = current.files
+ .filter((file) => !sameFileIdentity(priorByPath.get(file.path), file))
+ .sort((left, right) => left.path.localeCompare(right.path, 'en'))
+ const entryPoint = changed.filter((file) => file.path === 'SKILL.md')
+ const assets = changed.filter((file) => file.path !== 'SKILL.md')
+ const obsolete = prior.files
+ .filter((file) => !currentPaths.has(file.path))
+ .sort((left, right) => left.path.localeCompare(right.path, 'en'))
+ return [
+ ...assets.map((file) => ({ kind: 'publish' as const, path: file.path })),
+ ...entryPoint.map((file) => ({ kind: 'publish' as const, path: file.path })),
+ ...obsolete.map((file) => ({ kind: 'remove' as const, path: file.path }))
+ ]
+}
+
+function expectedFilesAfter(
+ prior: SkillReleasedSnapshot,
+ current: SkillReleasedSnapshot,
+ completedOperations: number
+): SkillBundleFileIdentity[] {
+ const files = new Map(prior.files.map((file) => [file.path, file]))
+ const currentByPath = new Map(current.files.map((file) => [file.path, file]))
+ for (const operation of skillInPlaceOperations(prior, current).slice(0, completedOperations)) {
+ if (operation.kind === 'remove') {
+ files.delete(operation.path)
+ } else {
+ const next = currentByPath.get(operation.path)
+ if (next) {
+ files.set(operation.path, next)
+ }
+ }
+ }
+ return [...files.values()].sort((left, right) => left.path.localeCompare(right.path, 'en'))
+}
+
+function matchesExactFiles(
+ observed: ObservedSkillPackage,
+ expected: readonly SkillBundleFileIdentity[]
+): boolean {
+ return (
+ observed.files.length === expected.length &&
+ expected.every((file, index) => {
+ const actual = observed.files[index]
+ return Boolean(actual && matchesFileIdentity(actual, file))
+ })
+ )
+}
+
+export function matchingInPlaceOperationIndex(args: {
+ observed: ObservedSkillPackage
+ prior: SkillReleasedSnapshot
+ current: SkillReleasedSnapshot
+}): number | null {
+ const operationCount = skillInPlaceOperations(args.prior, args.current).length
+ for (let index = 0; index <= operationCount; index += 1) {
+ if (matchesExactFiles(args.observed, expectedFilesAfter(args.prior, args.current, index))) {
+ return index
+ }
+ }
+ return null
+}
+
+export function matchesInPlacePublicationState(args: {
+ observed: ObservedSkillPackage
+ prior: SkillReleasedSnapshot
+ current: SkillReleasedSnapshot
+ publication: SkillInPlacePublicationState
+ allowAppliedOperation: boolean
+}): boolean {
+ const operations = skillInPlaceOperations(args.prior, args.current)
+ if (args.publication.operationIndex < 0 || args.publication.operationIndex > operations.length) {
+ return false
+ }
+ let observed = args.observed
+ const operation =
+ operations[
+ args.publication.direction === 'forward'
+ ? args.publication.operationIndex
+ : args.publication.operationIndex - 1
+ ]
+ const expectedTemporaryFile =
+ operation &&
+ (args.publication.direction === 'forward'
+ ? args.current.files.find((file) => file.path === operation.path)
+ : args.prior.files.find((file) => file.path === operation.path))
+ let temporaryPresent = false
+ if (args.publication.temporary) {
+ const temporary = observed.files.find((file) => file.path === args.publication.temporary?.path)
+ temporaryPresent = Boolean(temporary)
+ if (
+ !expectedTemporaryFile ||
+ !sameCompleteFileIdentity(args.publication.temporary.expectedFile, expectedTemporaryFile) ||
+ (temporary &&
+ args.publication.temporary.state === 'written' &&
+ !matchesFileIdentity(
+ {
+ ...temporary,
+ executable: args.publication.temporary.expectedFile.executable
+ },
+ {
+ ...args.publication.temporary.expectedFile,
+ path: args.publication.temporary.path
+ }
+ )) ||
+ (temporary &&
+ (args.publication.temporary.state === 'ready' ||
+ args.publication.temporary.state === 'applied') &&
+ !matchesFileIdentity(temporary, {
+ ...args.publication.temporary.expectedFile,
+ path: args.publication.temporary.path
+ }))
+ ) {
+ return false
+ }
+ if (temporary) {
+ observed = { ...observed, files: observed.files.filter((file) => file !== temporary) }
+ }
+ }
+ const candidates = [args.publication.operationIndex]
+ const operationCanHaveApplied =
+ operation?.kind === 'remove' ||
+ args.publication.temporary?.state === 'ready' ||
+ args.publication.temporary?.state === 'applied'
+ if (
+ (args.allowAppliedOperation || args.publication.temporary?.state === 'applied') &&
+ operationCanHaveApplied &&
+ args.publication.operationState === 'applying' &&
+ (args.publication.direction === 'forward'
+ ? args.publication.operationIndex < operations.length
+ : args.publication.operationIndex > 0)
+ ) {
+ const appliedIndex =
+ args.publication.direction === 'forward'
+ ? args.publication.operationIndex + 1
+ : args.publication.operationIndex - 1
+ candidates.push(appliedIndex)
+ if (args.publication.temporary && !temporaryPresent) {
+ candidates.shift()
+ }
+ }
+ if (
+ args.publication.temporary &&
+ !temporaryPresent &&
+ (!args.allowAppliedOperation || args.publication.temporary.state === 'creating') &&
+ args.publication.temporary.state !== 'applied'
+ ) {
+ return false
+ }
+ return candidates
+ .filter((count) => count >= 0)
+ .some((count) =>
+ matchesExactFiles(observed, expectedFilesAfter(args.prior, args.current, count))
+ )
+}
diff --git a/src/main/skills/skill-transaction-recovery.test.ts b/src/main/skills/skill-transaction-recovery.test.ts
new file mode 100644
index 00000000000..00b4aadb2a1
--- /dev/null
+++ b/src/main/skills/skill-transaction-recovery.test.ts
@@ -0,0 +1,618 @@
+import {
+ cp,
+ lstat,
+ mkdir,
+ mkdtemp,
+ readFile,
+ realpath,
+ rename,
+ rm,
+ stat,
+ symlink,
+ writeFile
+} from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join, resolve } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { ManagedSkillDestination, SkillReleasedSnapshot } from '../../shared/skill-management'
+import { observeSkillPackage, persistedObservedFiles } from './skill-package-identity'
+import { skillDestinationId, skillPhysicalIdentity } from './skill-installation-topology'
+import {
+ acquireSkillDestinationLock,
+ recoverMarkedSkillTransactions
+} from './skill-transaction-recovery'
+import { sweepOrphanedSkillTransactions } from './skill-orphan-transaction-sweep'
+import {
+ parseSkillTransactionMarker,
+ writeSkillTransactionMarker,
+ type SkillTransactionMarker
+} from './skill-transaction-marker'
+import { emptySkillManagementLedger } from './skill-management-ledger'
+
+const temporaryRoots: string[] = []
+
+async function fixture() {
+ const root = await mkdtemp(join(tmpdir(), 'orca-skill-recovery-'))
+ temporaryRoots.push(root)
+ const live = join(root, 'skills', 'orca-cli')
+ await mkdir(join(root, 'skills'), { recursive: true })
+ await cp(resolve('skills/orca-cli'), live, { recursive: true })
+ const resolvedLive = await realpath(live)
+ const observed = await observeSkillPackage(resolvedLive)
+ const files = persistedObservedFiles(observed)
+ const snapshot: SkillReleasedSnapshot = {
+ releaseRevision: 1,
+ packageDigest: observed.observedDigest,
+ gitTreeSha: '',
+ files
+ }
+ const record = {
+ id: skillDestinationId('local', live, 'orca-cli'),
+ hostId: 'local',
+ skillName: 'orca-cli',
+ resolvedPath: resolvedLive,
+ unresolvedPath: live,
+ physicalIdentity: skillPhysicalIdentity(resolvedLive, await stat(resolvedLive)),
+ entryType: 'directory',
+ topology: 'canonical-copy',
+ installedReleaseRevision: snapshot.releaseRevision,
+ installedFiles: files,
+ installedPackageDigest: observed.observedDigest
+ } as unknown as ManagedSkillDestination
+ return { root, live, snapshot, record }
+}
+
+function marker(
+ record: ManagedSkillDestination,
+ snapshot: SkillReleasedSnapshot,
+ phase: SkillTransactionMarker['phase']
+): SkillTransactionMarker {
+ return {
+ schemaVersion: 1,
+ transactionId: 'test-transaction',
+ createdAt: 1,
+ destinationId: record.id,
+ destinationPath: record.resolvedPath,
+ physicalIdentity: record.physicalIdentity,
+ hostId: record.hostId,
+ skillName: record.skillName,
+ phase,
+ stagePhysicalIdentity: null,
+ backupPhysicalIdentity: null,
+ stageBuild: null,
+ backupBuild: null,
+ publication: null,
+ priorSnapshot: snapshot,
+ currentSnapshot: { ...snapshot, releaseRevision: snapshot.releaseRevision + 1 }
+ }
+}
+
+async function writePublishingMarker(args: {
+ transaction: string
+ record: ManagedSkillDestination
+ prior: SkillReleasedSnapshot
+ current?: SkillReleasedSnapshot
+ stageSource: string
+ operationIndex?: number
+}): Promise {
+ const stageRoot = join(args.transaction, 'stage')
+ await cp(args.stageSource, stageRoot, { recursive: true })
+ const stagePhysicalIdentity = skillPhysicalIdentity(stageRoot, await stat(stageRoot))
+ const backupRoot = join(args.transaction, 'backup')
+ const backupPhysicalIdentity = skillPhysicalIdentity(backupRoot, await stat(backupRoot))
+ const currentSnapshot = args.current ?? {
+ ...args.prior,
+ releaseRevision: args.prior.releaseRevision + 1
+ }
+ await writeSkillTransactionMarker(args.transaction, {
+ ...marker(args.record, args.prior, 'publishing'),
+ currentSnapshot,
+ stagePhysicalIdentity,
+ backupPhysicalIdentity,
+ stageBuild: {
+ state: 'ready',
+ physicalIdentity: stagePhysicalIdentity,
+ operationIndex: currentSnapshot.files.length,
+ pendingPath: null,
+ activeFile: null
+ },
+ backupBuild: {
+ state: 'ready',
+ physicalIdentity: backupPhysicalIdentity,
+ operationIndex: args.prior.files.length,
+ pendingPath: null,
+ activeFile: null
+ },
+ publication: {
+ kind: 'in-place',
+ direction: 'forward',
+ physicalIdentity: args.record.physicalIdentity,
+ temporary: null,
+ operationIndex: args.operationIndex ?? 0,
+ operationState: 'ready'
+ }
+ })
+}
+
+async function writeSwapMarker(args: {
+ transaction: string
+ record: ManagedSkillDestination
+ snapshot: SkillReleasedSnapshot
+ stageSource: string
+ step: Extract['step']
+}): Promise {
+ const stageRoot = join(args.transaction, 'stage')
+ const backupRoot = join(args.transaction, 'backup')
+ await cp(args.stageSource, stageRoot, { recursive: true })
+ await cp(args.stageSource, backupRoot, { recursive: true })
+ const stagePhysicalIdentity = skillPhysicalIdentity(stageRoot, await stat(stageRoot))
+ const backupPhysicalIdentity = skillPhysicalIdentity(backupRoot, await stat(backupRoot))
+ await writeSkillTransactionMarker(args.transaction, {
+ ...marker(args.record, args.snapshot, 'publishing'),
+ stagePhysicalIdentity,
+ backupPhysicalIdentity,
+ stageBuild: {
+ state: 'ready',
+ physicalIdentity: stagePhysicalIdentity,
+ operationIndex: args.snapshot.files.length,
+ pendingPath: null,
+ activeFile: null
+ },
+ backupBuild: {
+ state: 'ready',
+ physicalIdentity: backupPhysicalIdentity,
+ operationIndex: args.snapshot.files.length,
+ pendingPath: null,
+ activeFile: null
+ },
+ publication: { kind: 'package-swap', step: args.step }
+ })
+}
+
+async function writeCreatingStageMarker(args: {
+ transaction: string
+ record: ManagedSkillDestination
+ snapshot: SkillReleasedSnapshot
+}): Promise {
+ const stageRoot = join(args.transaction, 'stage')
+ const firstFile = args.snapshot.files[0]!
+ const activePath = join(stageRoot, ...firstFile.path.split('/'))
+ await mkdir(dirname(activePath), { recursive: true })
+ await writeFile(activePath, 'partial')
+ const stagePhysicalIdentity = skillPhysicalIdentity(stageRoot, await stat(stageRoot))
+ await writeSkillTransactionMarker(args.transaction, {
+ ...marker(args.record, args.snapshot, 'staging'),
+ stagePhysicalIdentity,
+ stageBuild: {
+ state: 'creating',
+ physicalIdentity: stagePhysicalIdentity,
+ operationIndex: 0,
+ pendingPath: null,
+ activeFile: {
+ path: firstFile.path,
+ physicalIdentity: skillPhysicalIdentity(activePath, await stat(activePath)),
+ contentState: 'created'
+ }
+ }
+ })
+ return stageRoot
+}
+
+afterEach(async () => {
+ await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true })))
+})
+
+describe('skill transaction recovery', () => {
+ it('removes only marked staging transactions', async () => {
+ const { root, record, snapshot } = await fixture()
+ const reservedRoot = join(root, '.orca-skill-transactions')
+ const marked = join(reservedRoot, 'marked')
+ const unmarked = join(reservedRoot, 'user-data')
+ await mkdir(marked, { recursive: true })
+ await mkdir(unmarked, { recursive: true })
+ await writeSkillTransactionMarker(marked, marker(record, snapshot, 'staging'))
+
+ await recoverMarkedSkillTransactions({
+ reservedRoot,
+ record
+ })
+
+ await expect(readFile(join(marked, 'transaction.json'))).rejects.toMatchObject({
+ code: 'ENOENT'
+ })
+ await expect(readFile(join(unmarked, 'transaction.json'))).rejects.toMatchObject({
+ code: 'ENOENT'
+ })
+ expect(await import('node:fs/promises').then(({ stat }) => stat(unmarked))).toBeTruthy()
+ })
+
+ it('cleans only an identity-matched partial stage package', async () => {
+ const { root, record, snapshot } = await fixture()
+ const reservedRoot = join(root, '.orca-skill-transactions')
+ const transaction = join(reservedRoot, 'partial-stage')
+ await mkdir(transaction, { recursive: true })
+ await writeCreatingStageMarker({ transaction, record, snapshot })
+
+ await recoverMarkedSkillTransactions({ reservedRoot, record })
+
+ await expect(lstat(transaction)).rejects.toMatchObject({ code: 'ENOENT' })
+ })
+
+ it('retains a transaction when the recorded partial stage root was replaced', async () => {
+ const { root, record, snapshot } = await fixture()
+ const reservedRoot = join(root, '.orca-skill-transactions')
+ const transaction = join(reservedRoot, 'replaced-stage')
+ await mkdir(transaction, { recursive: true })
+ const stageRoot = await writeCreatingStageMarker({ transaction, record, snapshot })
+ await rm(stageRoot, { recursive: true })
+ await mkdir(stageRoot)
+
+ await expect(recoverMarkedSkillTransactions({ reservedRoot, record })).rejects.toThrow(
+ 'skill-transaction-recovery-required'
+ )
+ await expect(lstat(transaction)).resolves.toBeDefined()
+ })
+
+ it('retains rollback instead of overwriting an unknown same-inode edit', async () => {
+ const { root, live, record, snapshot } = await fixture()
+ const reservedRoot = join(root, '.orca-skill-transactions')
+ const transaction = join(reservedRoot, 'interrupted')
+ await cp(live, join(transaction, 'backup'), { recursive: true })
+ await writePublishingMarker({
+ transaction,
+ record,
+ prior: snapshot,
+ stageSource: live
+ })
+ await writeFile(join(live, 'SKILL.md'), 'partial new package')
+
+ await expect(recoverMarkedSkillTransactions({ reservedRoot, record })).rejects.toThrow(
+ 'skill-transaction-recovery-required'
+ )
+
+ expect(await readFile(join(live, 'SKILL.md'), 'utf8')).toBe('partial new package')
+ expect(await readFile(join(transaction, 'transaction.json'), 'utf8')).toBeTruthy()
+ })
+
+ it('restores a genuine mixed old/new package owned by the transaction', async () => {
+ const { root, live, record, snapshot } = await fixture()
+ const reservedRoot = join(root, '.orca-skill-transactions')
+ const transaction = join(reservedRoot, 'expected-partial')
+ const currentRoot = join(root, 'current-package')
+ await cp(live, join(transaction, 'backup'), { recursive: true })
+ await cp(live, currentRoot, { recursive: true })
+ await writeFile(join(currentRoot, 'SKILL.md'), 'expected current entry point')
+ await writeFile(join(currentRoot, 'asset.txt'), 'expected new asset')
+ const currentObserved = await observeSkillPackage(currentRoot)
+ const currentSnapshot: SkillReleasedSnapshot = {
+ releaseRevision: 2,
+ packageDigest: currentObserved.observedDigest,
+ gitTreeSha: '',
+ files: persistedObservedFiles(currentObserved)
+ }
+ await writePublishingMarker({
+ transaction,
+ record,
+ prior: snapshot,
+ current: currentSnapshot,
+ stageSource: currentRoot,
+ operationIndex: 1
+ })
+ await writeFile(join(live, 'asset.txt'), 'expected new asset')
+
+ await recoverMarkedSkillTransactions({ reservedRoot, record })
+
+ expect(await observeSkillPackage(live)).toMatchObject({
+ observedDigest: snapshot.packageDigest
+ })
+ })
+
+ it.each(['creating', 'ready'] as const)(
+ 'removes an identity-owned %s publication temp before resuming recovery',
+ async (temporaryState) => {
+ const { root, live, record, snapshot } = await fixture()
+ const reservedRoot = join(root, '.orca-skill-transactions')
+ const transaction = join(reservedRoot, 'publication-temp')
+ const currentRoot = join(root, 'temp-current')
+ await cp(live, join(transaction, 'backup'), { recursive: true })
+ await cp(live, currentRoot, { recursive: true })
+ await writeFile(join(currentRoot, 'asset.txt'), 'new asset')
+ const currentObserved = await observeSkillPackage(currentRoot)
+ const currentSnapshot: SkillReleasedSnapshot = {
+ releaseRevision: 2,
+ packageDigest: currentObserved.observedDigest,
+ gitTreeSha: '',
+ files: persistedObservedFiles(currentObserved)
+ }
+ await writePublishingMarker({
+ transaction,
+ record,
+ prior: snapshot,
+ current: currentSnapshot,
+ stageSource: currentRoot
+ })
+ const writtenMarker = parseSkillTransactionMarker(
+ await readFile(join(transaction, 'transaction.json'), 'utf8')
+ )
+ expect(writtenMarker).not.toBeNull()
+ const temporaryPath = 'asset.txt.orca-test.tmp'
+ await writeFile(
+ join(live, temporaryPath),
+ temporaryState === 'creating' ? 'partial' : await readFile(join(currentRoot, 'asset.txt'))
+ )
+ await writeSkillTransactionMarker(transaction, {
+ ...writtenMarker!,
+ publication: {
+ kind: 'in-place',
+ direction: 'forward',
+ physicalIdentity: record.physicalIdentity,
+ temporary: {
+ path: temporaryPath,
+ physicalIdentity: skillPhysicalIdentity(
+ join(live, temporaryPath),
+ await stat(join(live, temporaryPath))
+ ),
+ expectedFile: currentSnapshot.files.find((file) => file.path === 'asset.txt')!,
+ state: temporaryState
+ },
+ operationIndex: 0,
+ operationState: 'applying'
+ }
+ })
+
+ await recoverMarkedSkillTransactions({ reservedRoot, record })
+
+ expect((await observeSkillPackage(live)).observedDigest).toBe(snapshot.packageDigest)
+ await expect(stat(join(live, temporaryPath))).rejects.toMatchObject({ code: 'ENOENT' })
+ }
+ )
+
+ it('does not overwrite unknown content with a different physical identity', async () => {
+ const { root, live, record, snapshot } = await fixture()
+ const reservedRoot = join(root, '.orca-skill-transactions')
+ const transaction = join(reservedRoot, 'independent-replacement')
+ await cp(live, join(transaction, 'backup'), { recursive: true })
+ await writePublishingMarker({
+ transaction,
+ record,
+ prior: snapshot,
+ stageSource: live
+ })
+ await rm(live, { recursive: true })
+ await mkdir(live, { recursive: true })
+ await writeFile(join(live, 'SKILL.md'), 'independently replaced')
+
+ await expect(recoverMarkedSkillTransactions({ reservedRoot, record })).rejects.toThrow(
+ 'skill-transaction-recovery-required'
+ )
+ expect(await readFile(join(live, 'SKILL.md'), 'utf8')).toBe('independently replaced')
+ })
+
+ it('fails closed when the approved parent is replaced by a link before recovery', async () => {
+ const { root, live, record, snapshot } = await fixture()
+ const reservedRoot = join(root, '.orca-skill-transactions')
+ const transaction = join(reservedRoot, 'parent-link-swap')
+ const originalSkills = join(root, 'original-skills')
+ const outsideSkills = join(root, 'outside-skills')
+ await cp(live, join(transaction, 'backup'), { recursive: true })
+ await writePublishingMarker({
+ transaction,
+ record,
+ prior: snapshot,
+ stageSource: live
+ })
+ await writeFile(join(live, 'SKILL.md'), 'interrupted publication')
+ await mkdir(outsideSkills)
+ await rename(join(root, 'skills'), originalSkills)
+ await symlink(
+ outsideSkills,
+ join(root, 'skills'),
+ process.platform === 'win32' ? 'junction' : 'dir'
+ )
+
+ await expect(recoverMarkedSkillTransactions({ reservedRoot, record })).rejects.toThrow(
+ 'skill-topology-changed'
+ )
+ await expect(readFile(join(outsideSkills, 'orca-cli', 'SKILL.md'))).rejects.toMatchObject({
+ code: 'ENOENT'
+ })
+ })
+
+ it('recovers the one marker-owned package-swap gap with an absent destination', async () => {
+ const { root, live, record, snapshot } = await fixture()
+ const reservedRoot = join(root, '.orca-skill-transactions')
+ const transaction = join(reservedRoot, 'swap-gap')
+ await writeSwapMarker({
+ transaction,
+ record,
+ snapshot,
+ stageSource: live,
+ step: 'moving-stage'
+ })
+ await rename(live, join(transaction, 'old-live'))
+
+ await recoverMarkedSkillTransactions({ reservedRoot, record })
+
+ expect((await observeSkillPackage(live)).observedDigest).toBe(snapshot.packageDigest)
+ await expect(stat(transaction)).rejects.toMatchObject({ code: 'ENOENT' })
+ })
+
+ it('rejects an absent destination outside the package-swap gap phase', async () => {
+ const { root, live, record, snapshot } = await fixture()
+ const reservedRoot = join(root, '.orca-skill-transactions')
+ const transaction = join(reservedRoot, 'invalid-swap-gap')
+ await writeSwapMarker({
+ transaction,
+ record,
+ snapshot,
+ stageSource: live,
+ step: 'complete'
+ })
+ await rename(live, join(transaction, 'old-live'))
+
+ await expect(recoverMarkedSkillTransactions({ reservedRoot, record })).rejects.toThrow(
+ 'skill-transaction-recovery-required'
+ )
+ expect(await stat(join(transaction, 'old-live'))).toBeTruthy()
+ })
+
+ it('retains the package-swap gap when old-live was edited in place', async () => {
+ const { root, live, record, snapshot } = await fixture()
+ const reservedRoot = join(root, '.orca-skill-transactions')
+ const transaction = join(reservedRoot, 'edited-old-live')
+ await writeSwapMarker({
+ transaction,
+ record,
+ snapshot,
+ stageSource: live,
+ step: 'moving-stage'
+ })
+ const oldLive = join(transaction, 'old-live')
+ await rename(live, oldLive)
+ await writeFile(join(oldLive, 'SKILL.md'), 'independent same-inode edit')
+
+ await expect(recoverMarkedSkillTransactions({ reservedRoot, record })).rejects.toThrow(
+ 'skill-transaction-recovery-required'
+ )
+ expect(await readFile(join(oldLive, 'SKILL.md'), 'utf8')).toBe('independent same-inode edit')
+ })
+
+ it('rejects an exact stage copy whose recorded physical identity was replaced', async () => {
+ const { root, live, record, snapshot } = await fixture()
+ const reservedRoot = join(root, '.orca-skill-transactions')
+ const transaction = join(reservedRoot, 'replaced-stage')
+ await writeSwapMarker({
+ transaction,
+ record,
+ snapshot,
+ stageSource: live,
+ step: 'moving-stage'
+ })
+ await rename(live, join(transaction, 'old-live'))
+ const stage = join(transaction, 'stage')
+ const replacement = join(transaction, 'replacement-stage')
+ await cp(stage, replacement, { recursive: true })
+ await rm(stage, { recursive: true })
+ await rename(replacement, stage)
+
+ await expect(recoverMarkedSkillTransactions({ reservedRoot, record })).rejects.toThrow(
+ 'skill-transaction-recovery-required'
+ )
+ await expect(stat(live)).rejects.toMatchObject({ code: 'ENOENT' })
+ })
+
+ it('rolls back a completed package swap without losing the original identity', async () => {
+ const { root, live, record, snapshot } = await fixture()
+ const reservedRoot = join(root, '.orca-skill-transactions')
+ const transaction = join(reservedRoot, 'completed-swap')
+ await writeSwapMarker({
+ transaction,
+ record,
+ snapshot,
+ stageSource: live,
+ step: 'complete'
+ })
+ await rename(live, join(transaction, 'old-live'))
+ await rename(join(transaction, 'stage'), live)
+
+ await recoverMarkedSkillTransactions({ reservedRoot, record })
+
+ expect(skillPhysicalIdentity(live, await stat(live))).toBe(record.physicalIdentity)
+ expect((await observeSkillPackage(live)).observedDigest).toBe(snapshot.packageDigest)
+ })
+
+ it('recovers when the stage rename completed before its journal advancement', async () => {
+ const { root, live, record, snapshot } = await fixture()
+ const reservedRoot = join(root, '.orca-skill-transactions')
+ const transaction = join(reservedRoot, 'stage-rename-boundary')
+ await writeSwapMarker({
+ transaction,
+ record,
+ snapshot,
+ stageSource: live,
+ step: 'moving-stage'
+ })
+ await rename(live, join(transaction, 'old-live'))
+ await rename(join(transaction, 'stage'), live)
+
+ await recoverMarkedSkillTransactions({ reservedRoot, record })
+
+ expect(skillPhysicalIdentity(live, await stat(live))).toBe(record.physicalIdentity)
+ expect((await observeSkillPackage(live)).observedDigest).toBe(snapshot.packageDigest)
+ })
+
+ it('reclaims only a marked lock whose owner process is gone', async () => {
+ const { root } = await fixture()
+ const lockRoot = join(root, 'lock')
+ await mkdir(lockRoot)
+ await writeFile(
+ join(lockRoot, 'owner.json'),
+ JSON.stringify({ schemaVersion: 1, pid: 2_147_483_647 })
+ )
+
+ const release = await acquireSkillDestinationLock(lockRoot)
+ expect(JSON.parse(await readFile(join(lockRoot, 'owner.json'), 'utf8')).pid).toBe(process.pid)
+ await release()
+ })
+
+ it('restores a self-contained orphan before inventory has loaded a ledger', async () => {
+ const { root, live, record, snapshot } = await fixture()
+ const reservedRoot = join(root, '.orca-skill-transactions')
+ const transaction = join(reservedRoot, 'orphan')
+ const unmarked = join(reservedRoot, 'keep-me')
+ await cp(live, join(transaction, 'backup'), { recursive: true })
+ await mkdir(unmarked, { recursive: true })
+ await writePublishingMarker({
+ transaction,
+ record,
+ prior: snapshot,
+ stageSource: live
+ })
+ await writeFile(join(live, 'SKILL.md'), 'interrupted publication')
+
+ await expect(
+ sweepOrphanedSkillTransactions(join(root, 'skills'), emptySkillManagementLedger())
+ ).rejects.toThrow('skill-transaction-recovery-required')
+
+ expect(await readFile(join(live, 'SKILL.md'), 'utf8')).toBe('interrupted publication')
+ expect(await readFile(join(transaction, 'transaction.json'), 'utf8')).toBeTruthy()
+ expect(await import('node:fs/promises').then(({ stat }) => stat(unmarked))).toBeTruthy()
+ })
+
+ it('leaves a transaction untouched while its destination lock is live', async () => {
+ const { root, record, snapshot } = await fixture()
+ const reservedRoot = join(root, '.orca-skill-transactions')
+ const transaction = join(reservedRoot, 'busy')
+ await mkdir(transaction, { recursive: true })
+ await writeSkillTransactionMarker(transaction, marker(record, snapshot, 'staging'))
+ await mkdir(join(reservedRoot, 'locks'), { recursive: true })
+ const release = await acquireSkillDestinationLock(join(reservedRoot, 'locks', record.id))
+
+ await sweepOrphanedSkillTransactions(join(root, 'skills'), emptySkillManagementLedger())
+
+ expect(
+ parseSkillTransactionMarker(await readFile(join(transaction, 'transaction.json'), 'utf8'))
+ ).not.toBeNull()
+ await release()
+ })
+
+ it('ignores a valid marker whose destination is outside the approved root', async () => {
+ const { root, snapshot } = await fixture()
+ const outside = join(root, 'outside', 'orca-cli')
+ await mkdir(outside, { recursive: true })
+ const outsideRecord = {
+ id: skillDestinationId('local', outside, 'orca-cli'),
+ hostId: 'local',
+ skillName: 'orca-cli',
+ resolvedPath: outside,
+ physicalIdentity: 'outside-identity'
+ } as ManagedSkillDestination
+ const transaction = join(root, '.orca-skill-transactions', 'outside-target')
+ await mkdir(transaction, { recursive: true })
+ await writeSkillTransactionMarker(transaction, marker(outsideRecord, snapshot, 'staging'))
+
+ await sweepOrphanedSkillTransactions(join(root, 'skills'), emptySkillManagementLedger())
+
+ expect(await readFile(join(transaction, 'transaction.json'), 'utf8')).toBeTruthy()
+ })
+})
diff --git a/src/main/skills/skill-transaction-recovery.ts b/src/main/skills/skill-transaction-recovery.ts
new file mode 100644
index 00000000000..a3c4e4f0199
--- /dev/null
+++ b/src/main/skills/skill-transaction-recovery.ts
@@ -0,0 +1,101 @@
+import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import type { ManagedSkillDestination } from '../../shared/skill-management'
+import {
+ assertManagedSkillParentTopology,
+ normalizedSkillIdentityPath
+} from './skill-installation-topology'
+import { parseSkillTransactionMarker } from './skill-transaction-marker'
+import { recoverTransactionToPrior } from './skill-transaction-restore'
+
+export async function recoverMarkedSkillTransactions(args: {
+ reservedRoot: string
+ record: ManagedSkillDestination
+}): Promise {
+ const entries = await readdir(args.reservedRoot, { withFileTypes: true }).catch(() => [])
+ for (const entry of entries) {
+ if (!entry.isDirectory() || entry.name === 'locks') {
+ continue
+ }
+ const transactionRoot = join(args.reservedRoot, entry.name)
+ const marker = await readFile(join(transactionRoot, 'transaction.json'), 'utf8')
+ .then(parseSkillTransactionMarker)
+ .catch(() => null)
+ const recordMatchesPrior = marker
+ ? marker.priorSnapshot.packageDigest === args.record.installedPackageDigest
+ : false
+ const recordMatchesCurrent = marker
+ ? marker.phase === 'verified' &&
+ marker.currentSnapshot.packageDigest === args.record.installedPackageDigest
+ : false
+ if (
+ !marker ||
+ marker.destinationId !== args.record.id ||
+ marker.hostId !== args.record.hostId ||
+ marker.skillName !== args.record.skillName ||
+ normalizedSkillIdentityPath(marker.destinationPath) !==
+ normalizedSkillIdentityPath(args.record.resolvedPath) ||
+ (!recordMatchesPrior && !recordMatchesCurrent)
+ ) {
+ continue
+ }
+ await recoverTransactionToPrior({
+ transactionRoot,
+ marker,
+ assertParentAuthority: () => assertManagedSkillParentTopology(args.record),
+ currentDisposition:
+ marker.phase === 'verified' && recordMatchesCurrent ? 'committed' : 'restore'
+ })
+ await rm(transactionRoot, { recursive: true, force: true })
+ }
+}
+
+function processIsAlive(pid: number): boolean {
+ try {
+ process.kill(pid, 0)
+ return true
+ } catch (error) {
+ return Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'EPERM')
+ }
+}
+
+export async function acquireSkillDestinationLock(
+ lockRoot: string,
+ beforeMutation: () => Promise = async () => undefined
+): Promise<() => Promise> {
+ const create = async (): Promise => {
+ await beforeMutation()
+ await mkdir(lockRoot)
+ await beforeMutation()
+ await writeFile(
+ join(lockRoot, 'owner.json'),
+ JSON.stringify({ schemaVersion: 1, pid: process.pid, createdAt: Date.now() })
+ )
+ }
+ try {
+ await create()
+ } catch (error) {
+ if (!error || typeof error !== 'object' || !('code' in error) || error.code !== 'EEXIST') {
+ throw error
+ }
+ const owner = await readFile(join(lockRoot, 'owner.json'), 'utf8')
+ .then((value) => JSON.parse(value) as unknown)
+ .catch(() => null)
+ if (
+ typeof owner !== 'object' ||
+ owner === null ||
+ !('schemaVersion' in owner) ||
+ owner.schemaVersion !== 1 ||
+ !('pid' in owner) ||
+ typeof owner.pid !== 'number' ||
+ processIsAlive(owner.pid)
+ ) {
+ throw new Error('skill-update-busy')
+ }
+ // Why: only an Orca-marked lock whose owner is gone is safe to reclaim.
+ await beforeMutation()
+ await rm(lockRoot, { recursive: true })
+ await create()
+ }
+ return () => rm(lockRoot, { recursive: true, force: true })
+}
diff --git a/src/main/skills/skill-transaction-rename.ts b/src/main/skills/skill-transaction-rename.ts
new file mode 100644
index 00000000000..92580c2d21f
--- /dev/null
+++ b/src/main/skills/skill-transaction-rename.ts
@@ -0,0 +1,35 @@
+import { rename } from 'node:fs/promises'
+
+export type SkillRenameRuntime = {
+ renameEntry?: typeof rename
+ wait?: (milliseconds: number) => Promise
+ windowsFallback?: boolean
+}
+
+export async function retrySkillRename(
+ source: string,
+ destination: string,
+ beforeAttempt: () => Promise,
+ renameEntry: typeof rename = rename,
+ wait: (milliseconds: number) => Promise = (milliseconds) =>
+ new Promise((resolve) => setTimeout(resolve, milliseconds))
+): Promise {
+ for (let attempt = 0; ; attempt += 1) {
+ // Why: another manager can edit during a Windows sharing-violation delay.
+ await beforeAttempt()
+ try {
+ await renameEntry(source, destination)
+ return
+ } catch (error) {
+ const retryable =
+ error &&
+ typeof error === 'object' &&
+ 'code' in error &&
+ (error.code === 'EPERM' || error.code === 'EBUSY')
+ if (!retryable || attempt === 3) {
+ throw error
+ }
+ await wait(25 * 2 ** attempt)
+ }
+ }
+}
diff --git a/src/main/skills/skill-transaction-restore.ts b/src/main/skills/skill-transaction-restore.ts
new file mode 100644
index 00000000000..3f4522f6199
--- /dev/null
+++ b/src/main/skills/skill-transaction-restore.ts
@@ -0,0 +1,136 @@
+import { lstat, stat } from 'node:fs/promises'
+import { join } from 'node:path'
+import type { ManagedSkillDestination } from '../../shared/skill-management'
+import { skillPhysicalIdentity } from './skill-installation-topology'
+import { observeSkillPackage } from './skill-package-identity'
+import { rollbackSkillPackageSwap, type PublicationCallbacks } from './skill-package-publication'
+import {
+ clearSkillPublicationTemporary,
+ rollbackSkillPackageInPlace
+} from './skill-package-rollback'
+import {
+ writeSkillTransactionMarker,
+ type SkillTransactionMarker
+} from './skill-transaction-marker'
+import { assertTransactionOwnedState } from './skill-transaction-owned-state'
+import { assertSkillTransactionPackageBuild } from './skill-transaction-package-build'
+import {
+ matchingInPlaceOperationIndex,
+ type SkillPublicationState
+} from './skill-transaction-publication-state'
+import { retrySkillRename } from './skill-transaction-rename'
+
+export async function recoverTransactionToPrior(args: {
+ transactionRoot: string
+ marker: SkillTransactionMarker
+ assertParentAuthority: () => Promise
+ currentDisposition: 'committed' | 'restore'
+}): Promise {
+ if (args.marker.phase === 'staging') {
+ await args.assertParentAuthority()
+ for (const [rootName, evidence, snapshot] of [
+ ['stage', args.marker.stageBuild, args.marker.currentSnapshot],
+ ['backup', args.marker.backupBuild, args.marker.priorSnapshot]
+ ] as const) {
+ const root = join(args.transactionRoot, rootName)
+ if (!evidence) {
+ if (await lstat(root).catch(() => null)) {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ continue
+ }
+ await assertSkillTransactionPackageBuild({ root, evidence, snapshot }).catch(() => {
+ throw new Error('skill-transaction-recovery-required')
+ })
+ }
+ return
+ }
+ const record = { resolvedPath: args.marker.destinationPath } as ManagedSkillDestination
+ let activeMarker = args.marker
+ const assertOwned = () =>
+ assertTransactionOwnedState({
+ transactionRoot: args.transactionRoot,
+ marker: activeMarker,
+ assertParent: args.assertParentAuthority,
+ allowAppliedOperation: true
+ })
+ const journal = async (publication: SkillPublicationState): Promise => {
+ activeMarker = { ...activeMarker, publication }
+ await writeSkillTransactionMarker(
+ args.transactionRoot,
+ activeMarker,
+ args.assertParentAuthority
+ )
+ }
+ const assertBoundary: PublicationCallbacks['assertBoundary'] = async (
+ publication,
+ expectedLiveState
+ ) => {
+ const liveState = await assertTransactionOwnedState({
+ transactionRoot: args.transactionRoot,
+ marker: { ...activeMarker, publication },
+ assertParent: args.assertParentAuthority,
+ allowAppliedOperation: true
+ })
+ if (expectedLiveState && liveState !== expectedLiveState) {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ }
+ const callbacks = { journal, assertBoundary }
+ const state = await assertOwned()
+ if (state === 'prior' || (state === 'current' && args.currentDisposition === 'committed')) {
+ return
+ }
+ if (state === 'current' && activeMarker.publication?.kind === 'package-swap') {
+ await rollbackSkillPackageSwap({
+ record,
+ transactionRoot: args.transactionRoot,
+ callbacks
+ })
+ } else if (state === 'swap-absent') {
+ const movingLive = { kind: 'package-swap', step: 'moving-live' } as const
+ await retrySkillRename(
+ join(args.transactionRoot, 'old-live'),
+ args.marker.destinationPath,
+ async () => {
+ if ((await assertOwned()) !== 'swap-absent') {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ }
+ )
+ // Why: recovery journals both sides of the ambiguous rename before cleanup.
+ await journal(movingLive)
+ } else {
+ if (activeMarker.publication?.kind === 'in-place') {
+ await clearSkillPublicationTemporary({
+ record,
+ publication: activeMarker.publication,
+ callbacks
+ })
+ }
+ const observed = await observeSkillPackage(args.marker.destinationPath)
+ const startingIndex = matchingInPlaceOperationIndex({
+ observed,
+ prior: args.marker.priorSnapshot,
+ current: args.marker.currentSnapshot
+ })
+ if (startingIndex === null) {
+ throw new Error('skill-transaction-recovery-required')
+ }
+ await rollbackSkillPackageInPlace({
+ record,
+ prior: args.marker.priorSnapshot,
+ current: args.marker.currentSnapshot,
+ backupRoot: join(args.transactionRoot, 'backup'),
+ startingIndex,
+ physicalIdentity: skillPhysicalIdentity(
+ args.marker.destinationPath,
+ await stat(args.marker.destinationPath)
+ ),
+ callbacks
+ })
+ }
+ if ((await assertOwned()) !== 'prior') {
+ throw new Error('skill-transaction-recovery-required')
+ }
+}
diff --git a/src/main/skills/skill-transaction-snapshot-validation.ts b/src/main/skills/skill-transaction-snapshot-validation.ts
new file mode 100644
index 00000000000..7724575e432
--- /dev/null
+++ b/src/main/skills/skill-transaction-snapshot-validation.ts
@@ -0,0 +1,62 @@
+import { posix } from 'node:path'
+import type { SkillBundleFileIdentity, SkillReleasedSnapshot } from '../../shared/skill-management'
+import { skillPackageDigest } from './skill-package-identity'
+
+const SHA256_PATTERN = /^[a-f0-9]{64}$/
+
+export function isSkillBundleFileIdentity(value: unknown): value is SkillBundleFileIdentity {
+ if (typeof value !== 'object' || value === null) {
+ return false
+ }
+ const file = value as Partial
+ return (
+ typeof file.path === 'string' &&
+ file.path.length > 0 &&
+ !file.path.includes('\\') &&
+ !posix.isAbsolute(file.path) &&
+ posix.normalize(file.path) === file.path &&
+ !file.path.split('/').includes('..') &&
+ typeof file.size === 'number' &&
+ Number.isSafeInteger(file.size) &&
+ file.size >= 0 &&
+ typeof file.executable === 'boolean' &&
+ (file.classification === 'text' || file.classification === 'binary') &&
+ typeof file.exactSha256 === 'string' &&
+ SHA256_PATTERN.test(file.exactSha256) &&
+ (file.textNormalizedSha256 === null ||
+ (typeof file.textNormalizedSha256 === 'string' &&
+ SHA256_PATTERN.test(file.textNormalizedSha256))) &&
+ typeof file.identitySha256 === 'string' &&
+ SHA256_PATTERN.test(file.identitySha256) &&
+ (file.classification !== 'text' || file.textNormalizedSha256 !== null) &&
+ file.identitySha256 ===
+ (file.classification === 'text' && !file.executable
+ ? file.textNormalizedSha256
+ : file.exactSha256)
+ )
+}
+
+export function isSkillReleasedSnapshot(value: unknown): value is SkillReleasedSnapshot {
+ if (typeof value !== 'object' || value === null) {
+ return false
+ }
+ const snapshot = value as Partial
+ const structurallyValid =
+ typeof snapshot.releaseRevision === 'number' &&
+ Number.isSafeInteger(snapshot.releaseRevision) &&
+ snapshot.releaseRevision >= 0 &&
+ typeof snapshot.packageDigest === 'string' &&
+ SHA256_PATTERN.test(snapshot.packageDigest) &&
+ typeof snapshot.gitTreeSha === 'string' &&
+ Array.isArray(snapshot.files) &&
+ snapshot.files.every(isSkillBundleFileIdentity)
+ if (!structurallyValid) {
+ return false
+ }
+ const files = snapshot.files as SkillBundleFileIdentity[]
+ const paths = files.map((file) => file.path)
+ return (
+ paths.every((path, index) => index === 0 || paths[index - 1]!.localeCompare(path, 'en') < 0) &&
+ snapshot.packageDigest === skillPackageDigest(files)
+ )
+}
diff --git a/src/main/skills/skill-transaction-workspace.test.ts b/src/main/skills/skill-transaction-workspace.test.ts
new file mode 100644
index 00000000000..60c52959d24
--- /dev/null
+++ b/src/main/skills/skill-transaction-workspace.test.ts
@@ -0,0 +1,32 @@
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { RESERVED_SKILL_TRANSACTION_DIRECTORY } from './skill-transaction-paths'
+import { skillTransactionReservedRoot } from './skill-transaction-workspace'
+
+describe('skill transaction workspace selection', () => {
+ it('uses the adjacent parent only when it shares the destination mount', () => {
+ const skillsRoot = join('root', 'skills')
+ const parentRoot = join('root')
+ expect(
+ skillTransactionReservedRoot({
+ skillsRoot,
+ skillsRootDevice: 1,
+ parentRoot,
+ parentRootDevice: 1
+ })
+ ).toBe(join(parentRoot, RESERVED_SKILL_TRANSACTION_DIRECTORY))
+ })
+
+ it('keeps the workspace inside the skills root across a mount boundary', () => {
+ const skillsRoot = join('mounted', 'skills')
+ const parentRoot = join('mounted')
+ expect(
+ skillTransactionReservedRoot({
+ skillsRoot,
+ skillsRootDevice: 2,
+ parentRoot,
+ parentRootDevice: 1
+ })
+ ).toBe(join(skillsRoot, RESERVED_SKILL_TRANSACTION_DIRECTORY))
+ })
+})
diff --git a/src/main/skills/skill-transaction-workspace.ts b/src/main/skills/skill-transaction-workspace.ts
new file mode 100644
index 00000000000..0291e0132ba
--- /dev/null
+++ b/src/main/skills/skill-transaction-workspace.ts
@@ -0,0 +1,90 @@
+import { lstat, realpath } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import type { ManagedSkillDestination } from '../../shared/skill-management'
+import {
+ assertManagedSkillParentTopology,
+ normalizedSkillIdentityPath,
+ skillPhysicalIdentity
+} from './skill-installation-topology'
+import { RESERVED_SKILL_TRANSACTION_DIRECTORY } from './skill-transaction-paths'
+
+export type SkillTransactionWorkspace = {
+ skillsRoot: string
+ skillsRootIdentity: string
+ skillsRootDevice: number
+ parentRoot: string
+ parentRootIdentity: string
+ parentRootDevice: number
+ reservedRoot: string
+}
+
+export function skillTransactionReservedRoot(args: {
+ skillsRoot: string
+ skillsRootDevice: number
+ parentRoot: string
+ parentRootDevice: number
+}): string {
+ // Why: an adjacent workspace is atomic only on the destination mount;
+ // a mount boundary requires the reserved child of the skills root.
+ return args.skillsRootDevice === args.parentRootDevice
+ ? join(args.parentRoot, RESERVED_SKILL_TRANSACTION_DIRECTORY)
+ : join(args.skillsRoot, RESERVED_SKILL_TRANSACTION_DIRECTORY)
+}
+
+async function inspectWorkspaceRoots(record: ManagedSkillDestination) {
+ await assertManagedSkillParentTopology(record)
+ const skillsRoot = dirname(record.resolvedPath)
+ const parentRoot = dirname(skillsRoot)
+ const [skillsRootEntry, parentRootEntry, resolvedSkillsRoot, resolvedParentRoot] =
+ await Promise.all([
+ lstat(skillsRoot),
+ lstat(parentRoot),
+ realpath(skillsRoot),
+ realpath(parentRoot)
+ ])
+ if (
+ !skillsRootEntry.isDirectory() ||
+ skillsRootEntry.isSymbolicLink() ||
+ !parentRootEntry.isDirectory() ||
+ parentRootEntry.isSymbolicLink() ||
+ normalizedSkillIdentityPath(resolvedSkillsRoot) !== normalizedSkillIdentityPath(skillsRoot) ||
+ normalizedSkillIdentityPath(resolvedParentRoot) !== normalizedSkillIdentityPath(parentRoot)
+ ) {
+ throw new Error('skill-topology-changed')
+ }
+ return {
+ skillsRoot,
+ skillsRootIdentity: skillPhysicalIdentity(skillsRoot, skillsRootEntry),
+ skillsRootDevice: skillsRootEntry.dev,
+ parentRoot,
+ parentRootIdentity: skillPhysicalIdentity(parentRoot, parentRootEntry),
+ parentRootDevice: parentRootEntry.dev
+ }
+}
+
+export async function resolveSkillTransactionWorkspace(
+ record: ManagedSkillDestination
+): Promise {
+ const roots = await inspectWorkspaceRoots(record)
+ return {
+ ...roots,
+ reservedRoot: skillTransactionReservedRoot(roots)
+ }
+}
+
+export async function assertSkillTransactionWorkspace(
+ record: ManagedSkillDestination,
+ expected: SkillTransactionWorkspace
+): Promise {
+ const actual = await inspectWorkspaceRoots(record)
+ if (
+ actual.skillsRoot !== expected.skillsRoot ||
+ actual.skillsRootIdentity !== expected.skillsRootIdentity ||
+ actual.skillsRootDevice !== expected.skillsRootDevice ||
+ actual.parentRoot !== expected.parentRoot ||
+ actual.parentRootIdentity !== expected.parentRootIdentity ||
+ actual.parentRootDevice !== expected.parentRootDevice
+ ) {
+ throw new Error('skill-topology-changed')
+ }
+}
diff --git a/src/main/skills/skills-cli-lock-provenance.test.ts b/src/main/skills/skills-cli-lock-provenance.test.ts
new file mode 100644
index 00000000000..50da459f4ac
--- /dev/null
+++ b/src/main/skills/skills-cli-lock-provenance.test.ts
@@ -0,0 +1,59 @@
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { readOfficialSkillsCliLockEntries, skillsCliLockPath } from './skills-cli-lock-provenance'
+
+const temporaryRoots: string[] = []
+
+async function createRoot(): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'orca-skills-lock-'))
+ temporaryRoots.push(root)
+ return root
+}
+
+afterEach(async () => {
+ await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true })))
+})
+
+describe('skills CLI lock provenance', () => {
+ it('uses XDG_STATE_HOME instead of the default lock location', async () => {
+ const homeDir = await createRoot()
+ const xdgStateHome = join(homeDir, 'state')
+ expect(skillsCliLockPath({ homeDir, xdgStateHome })).toBe(
+ join(xdgStateHome, 'skills', '.skill-lock.json')
+ )
+ })
+
+ it('accepts only official Orca source entries from supported schemas', async () => {
+ const homeDir = await createRoot()
+ const lockPath = skillsCliLockPath({ homeDir })
+ await mkdir(dirname(lockPath), { recursive: true })
+ await writeFile(
+ lockPath,
+ JSON.stringify({
+ version: 3,
+ skills: {
+ 'orca-cli': { source: 'stablyai/orca' },
+ orchestration: { sourceUrl: 'https://github.com/stablyai/orca.git' },
+ spoofed: { source: 'someone/orca' }
+ }
+ })
+ )
+
+ expect(await readOfficialSkillsCliLockEntries({ homeDir })).toEqual(
+ new Set(['orca-cli', 'orchestration'])
+ )
+ })
+
+ it('fails closed for missing, corrupt, and unsupported locks', async () => {
+ const homeDir = await createRoot()
+ expect(await readOfficialSkillsCliLockEntries({ homeDir })).toEqual(new Set())
+ const lockPath = skillsCliLockPath({ homeDir })
+ await mkdir(dirname(lockPath), { recursive: true })
+ await writeFile(lockPath, '{')
+ expect(await readOfficialSkillsCliLockEntries({ homeDir })).toEqual(new Set())
+ await writeFile(lockPath, JSON.stringify({ version: 99, skills: { 'orca-cli': {} } }))
+ expect(await readOfficialSkillsCliLockEntries({ homeDir })).toEqual(new Set())
+ })
+})
diff --git a/src/main/skills/skills-cli-lock-provenance.ts b/src/main/skills/skills-cli-lock-provenance.ts
new file mode 100644
index 00000000000..5f026093e7a
--- /dev/null
+++ b/src/main/skills/skills-cli-lock-provenance.ts
@@ -0,0 +1,53 @@
+import { readFile, stat } from 'node:fs/promises'
+import { join } from 'node:path'
+
+const MAX_LOCK_BYTES = 1024 * 1024
+const SUPPORTED_LOCK_VERSIONS = new Set([1, 2, 3])
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+function officialOrcaSource(entry: Record): boolean {
+ const source = typeof entry.source === 'string' ? entry.source : ''
+ const sourceUrl = typeof entry.sourceUrl === 'string' ? entry.sourceUrl : ''
+ return (
+ source === 'stablyai/orca' ||
+ source === 'https://github.com/stablyai/orca' ||
+ /^https:\/\/github\.com\/stablyai\/orca(?:\.git)?\/?$/.test(sourceUrl)
+ )
+}
+
+export function skillsCliLockPath(args: { homeDir: string; xdgStateHome?: string | null }): string {
+ return args.xdgStateHome
+ ? join(args.xdgStateHome, 'skills', '.skill-lock.json')
+ : join(args.homeDir, '.agents', '.skill-lock.json')
+}
+
+export async function readOfficialSkillsCliLockEntries(args: {
+ homeDir: string
+ xdgStateHome?: string | null
+}): Promise> {
+ const lockPath = skillsCliLockPath(args)
+ try {
+ if ((await stat(lockPath)).size > MAX_LOCK_BYTES) {
+ return new Set()
+ }
+ const parsed: unknown = JSON.parse(await readFile(lockPath, 'utf8'))
+ if (
+ !isRecord(parsed) ||
+ !SUPPORTED_LOCK_VERSIONS.has(Number(parsed.version)) ||
+ !isRecord(parsed.skills)
+ ) {
+ return new Set()
+ }
+ return new Set(
+ Object.entries(parsed.skills)
+ .filter((entry): entry is [string, Record] => isRecord(entry[1]))
+ .filter(([, entry]) => officialOrcaSource(entry))
+ .map(([name]) => name)
+ )
+ } catch {
+ return new Set()
+ }
+}
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index 4babcd587bb..657c2abffbd 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -314,6 +314,13 @@ 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 {
+ OrcaSkillInstallResultArgs,
+ SkillAdoptionDismissalArgs,
+ SkillManagementActionArgs,
+ SkillManagementInventory,
+ SkillReplacementPreview
+} from '../shared/skill-management'
import type {
CrashReportBreadcrumbData,
CrashReportCopyDiagnosticsArgs,
@@ -2201,6 +2208,13 @@ export type PreloadApi = {
}
skills: {
discover: (target?: SkillDiscoveryTarget) => Promise
+ managementInventory: (target?: SkillDiscoveryTarget) => Promise
+ adopt: (args: SkillManagementActionArgs) => Promise
+ dismissAdoption: (args: SkillAdoptionDismissalArgs) => Promise
+ previewReplacement: (args: SkillManagementActionArgs) => Promise
+ replace: (args: SkillManagementActionArgs) => Promise
+ updateManaged: (args: SkillManagementActionArgs) => Promise
+ recordOrcaInstall: (args: OrcaSkillInstallResultArgs) => Promise
}
pet: {
import: () => Promise
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 3c7af55f0af..711a428de8c 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -69,6 +69,13 @@ 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 {
+ OrcaSkillInstallResultArgs,
+ SkillAdoptionDismissalArgs,
+ SkillManagementActionArgs,
+ SkillManagementInventory,
+ SkillReplacementPreview
+} from '../shared/skill-management'
import type {
RuntimeBrowserDriverState,
RuntimeMobileSessionTabMove,
@@ -2166,7 +2173,21 @@ const api = {
skills: {
discover: (target?: SkillDiscoveryTarget): Promise =>
- ipcRenderer.invoke('skills:discover', target)
+ ipcRenderer.invoke('skills:discover', target),
+ managementInventory: (target?: SkillDiscoveryTarget): Promise =>
+ ipcRenderer.invoke('skills:managementInventory', target),
+ adopt: (args: SkillManagementActionArgs): Promise =>
+ ipcRenderer.invoke('skills:adopt', args),
+ dismissAdoption: (args: SkillAdoptionDismissalArgs): Promise =>
+ ipcRenderer.invoke('skills:dismissAdoption', args),
+ previewReplacement: (args: SkillManagementActionArgs): Promise =>
+ ipcRenderer.invoke('skills:previewReplacement', args),
+ replace: (args: SkillManagementActionArgs): Promise =>
+ ipcRenderer.invoke('skills:replace', args),
+ updateManaged: (args: SkillManagementActionArgs): Promise =>
+ ipcRenderer.invoke('skills:updateManaged', args),
+ recordOrcaInstall: (args: OrcaSkillInstallResultArgs): Promise =>
+ ipcRenderer.invoke('skills:recordOrcaInstall', args)
},
pet: {
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 9e111a6cb4d..32e9a0e3c0d 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -59,6 +59,7 @@ import RightSidebar from './components/right-sidebar'
import { StarNagCard } from './components/StarNagCard'
import { StarNagAgentValueMomentObserver } from './components/star-nag/StarNagAgentValueMomentObserver'
import { StarNagToastHost } from './components/star-nag/StarNagToastHost'
+import { ManagedSkillAdoptionNudge } from './components/managed-skills/ManagedSkillAdoptionNudge'
import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface'
import { ZoomOverlay } from './components/ZoomOverlay'
import { onOnboardingReopened } from './components/onboarding/show-onboarding-event'
@@ -2827,6 +2828,7 @@ function App(): React.JSX.Element {
+
{/* 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
diff --git a/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuideSteps.tsx b/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuideSteps.tsx
index f169591d5e1..5849f6289f9 100644
--- a/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuideSteps.tsx
+++ b/src/renderer/src/components/emulator-pane/MobileEmulatorAgentSetupGuideSteps.tsx
@@ -1,7 +1,10 @@
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/store'
-import { ORCA_CLI_SKILL_INSTALL_COMMAND } from '@/lib/agent-feature-install-commands'
+import {
+ ORCA_CLI_SKILL_INSTALL_COMMAND,
+ ORCA_CLI_SKILL_NAME
+} from '@/lib/agent-feature-install-commands'
import {
AGENT_SKILL_CLI_PREREQUISITE_NOTICE,
ensureOrcaCliAvailableForAgentSkillTerminal
@@ -138,6 +141,7 @@ export function MobileEmulatorAgentSetupGuideSteps({
)}
(null)
const handleCopySkillCommand = async (): Promise => {
try {
await window.api.ui.writeClipboardText(ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND)
@@ -76,6 +84,23 @@ export function CliSkillSetupTerminal(): React.JSX.Element {
descriptionPaddingClassName="px-4 py-2"
autoScrollIntoView={false}
worktreeId="feature-tip-cli-skills-terminal"
+ onOpened={() => {
+ installAttemptStartedAtRef.current ??= Date.now()
+ }}
+ onTerminalExit={(result) => {
+ notifyInstalledAgentSkillsChanged()
+ const startedAt = installAttemptStartedAtRef.current
+ if (!isSuccessfulSkillInstallExit(result, startedAt)) {
+ return
+ }
+ void window.api.skills
+ .recordOrcaInstall({
+ skillNames: [ORCA_CLI_SKILL_NAME, ORCHESTRATION_SKILL_NAME],
+ startedAt
+ })
+ .then(notifyInstalledAgentSkillsChanged)
+ .catch(() => undefined)
+ }}
/>
)
diff --git a/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx b/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx
index b8b1cd58306..90e64572e4a 100644
--- a/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx
+++ b/src/renderer/src/components/feature-wall/BrowserUseSkillSetupCard.tsx
@@ -1,6 +1,7 @@
import type { JSX } from 'react'
import {
ORCA_CLI_SKILL_INSTALL_COMMAND,
+ ORCA_CLI_SKILL_NAME,
ORCA_CLI_SKILL_UPDATE_COMMAND
} from '@/lib/agent-feature-install-commands'
import {
@@ -43,6 +44,8 @@ export function BrowserUseSkillSetupCard(props: {
const setupPanel = (
({
+ dismiss: vi.fn(async () => {}),
+ info: vi.fn(),
+ openSettingsPage: vi.fn(),
+ openSettingsTarget: vi.fn(),
+ inventory: {
+ schemaVersion: 1,
+ hostId: 'local',
+ installations: [
+ {
+ id: 'candidate-a',
+ hostId: 'local',
+ physicalIdentity: '1:1',
+ name: 'orca-cli',
+ installedPackageDigest: 'a'.repeat(64),
+ adoptionPromptEligible: true
+ },
+ {
+ id: 'candidate-b',
+ hostId: 'local',
+ physicalIdentity: '1:2',
+ name: 'orchestration',
+ installedPackageDigest: 'b'.repeat(64),
+ adoptionPromptEligible: false
+ }
+ ],
+ adoptionCandidateCount: 1,
+ scannedAt: 1
+ }
+}))
+
+vi.mock('sonner', () => ({ toast: { info: mocks.info } }))
+vi.mock('@/hooks/useManagedAgentSkills', () => ({
+ useManagedAgentSkills: () => ({ inventory: mocks.inventory, dismiss: mocks.dismiss })
+}))
+vi.mock('@/store', () => ({
+ useAppStore: (selector: (state: unknown) => unknown) =>
+ selector({
+ openSettingsPage: mocks.openSettingsPage,
+ openSettingsTarget: mocks.openSettingsTarget
+ })
+}))
+
+describe('ManagedSkillAdoptionNudge', () => {
+ beforeEach(() => {
+ mocks.dismiss.mockClear()
+ mocks.info.mockClear()
+ mocks.openSettingsPage.mockClear()
+ mocks.openSettingsTarget.mockClear()
+ mocks.inventory.installations[0]!.installedPackageDigest = 'a'.repeat(64)
+ mocks.inventory.installations[0]!.adoptionPromptEligible = true
+ })
+
+ afterEach(() => {
+ document.body.replaceChildren()
+ })
+
+ it('persists only displayed tuples once across review and close lifecycles', async () => {
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ await act(async () => root.render( ))
+
+ expect(mocks.info).toHaveBeenCalledTimes(1)
+ expect(mocks.dismiss).not.toHaveBeenCalled()
+
+ const options = mocks.info.mock.calls[0]?.[1] as {
+ action: { onClick: () => void }
+ onDismiss: () => void
+ onAutoClose: () => void
+ }
+ await act(async () => options.action.onClick())
+ await act(async () => options.onDismiss())
+ await act(async () => options.onAutoClose())
+ await vi.waitFor(() =>
+ expect(mocks.dismiss).toHaveBeenCalledWith(mocks.inventory.installations[0])
+ )
+ expect(mocks.dismiss).toHaveBeenCalledTimes(1)
+ expect(mocks.dismiss).not.toHaveBeenCalledWith(mocks.inventory.installations[1])
+ root.unmount()
+ })
+
+ it('stays suppressed after remount but prompts a different snapshot tuple', async () => {
+ const mount = async () => {
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ await act(async () => root.render( ))
+ return root
+ }
+ let root = await mount()
+ const options = mocks.info.mock.calls[0]?.[1] as { onAutoClose: () => void }
+ await act(async () => options.onAutoClose())
+ await vi.waitFor(() => expect(mocks.dismiss).toHaveBeenCalledTimes(1))
+ root.unmount()
+
+ mocks.info.mockClear()
+ mocks.inventory.installations[0]!.adoptionPromptEligible = false
+ root = await mount()
+ expect(mocks.info).not.toHaveBeenCalled()
+ root.unmount()
+
+ mocks.inventory.installations[0]!.installedPackageDigest = 'c'.repeat(64)
+ mocks.inventory.installations[0]!.adoptionPromptEligible = true
+ root = await mount()
+ expect(mocks.info).toHaveBeenCalledTimes(1)
+ root.unmount()
+ })
+})
diff --git a/src/renderer/src/components/managed-skills/ManagedSkillAdoptionNudge.tsx b/src/renderer/src/components/managed-skills/ManagedSkillAdoptionNudge.tsx
new file mode 100644
index 00000000000..b6ac3e6ae49
--- /dev/null
+++ b/src/renderer/src/components/managed-skills/ManagedSkillAdoptionNudge.tsx
@@ -0,0 +1,77 @@
+import { useEffect, useRef } from 'react'
+import { toast } from 'sonner'
+import { useManagedAgentSkills } from '@/hooks/useManagedAgentSkills'
+import { translate } from '@/i18n/i18n'
+import { useAppStore } from '@/store'
+
+export function ManagedSkillAdoptionNudge(): null {
+ const state = useManagedAgentSkills()
+ const shownKeys = useRef(new Set())
+ const persistedKeys = useRef(new Set())
+ const openSettingsPage = useAppStore((store) => store.openSettingsPage)
+ const openSettingsTarget = useAppStore((store) => store.openSettingsTarget)
+
+ useEffect(() => {
+ const candidates =
+ state.inventory?.installations.filter(
+ (installation) => installation.adoptionPromptEligible
+ ) ?? []
+ const candidateKey = (candidate: (typeof candidates)[number]): string =>
+ [
+ candidate.hostId,
+ candidate.physicalIdentity,
+ candidate.name,
+ candidate.installedPackageDigest
+ ].join('\0')
+ const unseen = candidates.filter((candidate) => !shownKeys.current.has(candidateKey(candidate)))
+ if (unseen.length === 0) {
+ return
+ }
+ for (const candidate of unseen) {
+ shownKeys.current.add(candidateKey(candidate))
+ }
+ const persistDismissal = (): void => {
+ const pending = unseen.filter((candidate) => {
+ const key = candidateKey(candidate)
+ if (persistedKeys.current.has(key)) {
+ return false
+ }
+ persistedKeys.current.add(key)
+ return true
+ })
+ // Why: Sonner may report action, dismiss, and auto-close for one toast;
+ // each exact tuple must reach the durable ledger only once.
+ void pending.reduce(
+ (settled, candidate) => settled.then(() => state.dismiss(candidate)),
+ Promise.resolve()
+ )
+ }
+ const count = unseen.length
+ toast.info(
+ translate(
+ 'auto.components.managedSkills.adoptionNudge.title',
+ '{{value0}} installed Orca skills can be kept up to date',
+ { value0: count }
+ ),
+ {
+ description: translate(
+ 'auto.components.managedSkills.adoptionNudge.description',
+ 'Review exact official copies before Orca manages them.'
+ ),
+ duration: 12_000,
+ onDismiss: persistDismissal,
+ onAutoClose: persistDismissal,
+ action: {
+ label: translate('auto.components.managedSkills.adoptionNudge.review', 'Review'),
+ onClick: () => {
+ openSettingsTarget({ pane: 'agents', repoId: null })
+ openSettingsPage()
+ persistDismissal()
+ }
+ }
+ }
+ )
+ }, [openSettingsPage, openSettingsTarget, state])
+
+ return null
+}
diff --git a/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx b/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx
index e15c796d4bf..249dc9d962d 100644
--- a/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx
+++ b/src/renderer/src/components/onboarding/FeatureSetupInlineTerminal.tsx
@@ -4,9 +4,11 @@ import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkil
import { OnboardingInlineCommandTerminal } from './OnboardingInlineCommandTerminal'
import {
onboardingFeatureSetupTelemetrySelection,
+ selectedOnboardingSkillNames,
type OnboardingFeatureSetupSelection
} from './onboarding-feature-setup'
import { translate } from '@/i18n/i18n'
+import { isSuccessfulSkillInstallExit } from '../settings/skill-install-terminal-exit'
type FeatureSetupInlineTerminalProps = {
command: string
@@ -18,6 +20,7 @@ export function FeatureSetupInlineTerminal({
selection
}: FeatureSetupInlineTerminalProps): React.JSX.Element {
const terminalOpenedTrackedRef = useRef(false)
+ const installAttemptStartedAtRef = useRef(null)
const terminalInteractedTrackedRef = useRef(false)
const selectionTelemetry = useMemo(
@@ -26,6 +29,7 @@ export function FeatureSetupInlineTerminal({
)
const trackTerminalOpened = useCallback(() => {
+ installAttemptStartedAtRef.current ??= Date.now()
if (terminalOpenedTrackedRef.current) {
return
}
@@ -74,7 +78,20 @@ export function FeatureSetupInlineTerminal({
autoScrollIntoView={false}
onOpened={trackTerminalOpened}
onInteracted={trackTerminalInteraction}
- onTerminalExit={notifyInstalledAgentSkillsChanged}
+ onTerminalExit={(result) => {
+ notifyInstalledAgentSkillsChanged()
+ const startedAt = installAttemptStartedAtRef.current
+ if (!isSuccessfulSkillInstallExit(result, startedAt)) {
+ return
+ }
+ void window.api.skills
+ .recordOrcaInstall({
+ skillNames: selectedOnboardingSkillNames(selection),
+ startedAt
+ })
+ .then(notifyInstalledAgentSkillsChanged)
+ .catch(() => undefined)
+ }}
/>
)
}
diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx
index 9f7c8da1398..3ef6f379460 100644
--- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx
+++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx
@@ -28,7 +28,7 @@ type OnboardingInlineCommandTerminalProps = {
shellOverride?: string
onOpened?: () => void
onInteracted?: (method: 'keyboard' | 'pointer', event?: KeyboardEvent) => void
- onTerminalExit?: () => void
+ onTerminalExit?: (result: { code: number | null }) => void
}
/**
@@ -294,8 +294,8 @@ export function OnboardingInlineCommandTerminal({
cwd={cwd}
isActive
isVisible
- onPtyExit={() => {
- onTerminalExit?.()
+ onPtyExit={(_ptyId, code) => {
+ onTerminalExit?.({ code: code ?? null })
closeTab(tabId, { recordInteraction: false })
}}
onCloseTab={() => closeTab(tabId, { recordInteraction: false })}
diff --git a/src/renderer/src/components/onboarding/onboarding-feature-setup.ts b/src/renderer/src/components/onboarding/onboarding-feature-setup.ts
index c16632d874b..e7cc532e742 100644
--- a/src/renderer/src/components/onboarding/onboarding-feature-setup.ts
+++ b/src/renderer/src/components/onboarding/onboarding-feature-setup.ts
@@ -55,6 +55,10 @@ const FEATURE_SKILL_NAMES: Record = {
linearTickets: ORCA_LINEAR_SKILL_NAME
}
+export function selectedOnboardingSkillNames(selection: OnboardingFeatureSetupSelection): string[] {
+ return selectedOnboardingFeatureSetupIds(selection).map((id) => FEATURE_SKILL_NAMES[id])
+}
+
const FEATURE_TELEMETRY_IDS: Record<
OnboardingFeatureSetupId,
EventProps<'onboarding_feature_setup_toggled'>['feature']
@@ -112,9 +116,7 @@ export function buildOnboardingFeatureSetupClipboardText(
export function buildOnboardingFeatureSetupSkillCommand(
selection: OnboardingFeatureSetupSelection
): string | null {
- const skillNames = selectedOnboardingFeatureSetupIds(selection).map(
- (id) => FEATURE_SKILL_NAMES[id]
- )
+ const skillNames = selectedOnboardingSkillNames(selection)
if (skillNames.length === 0) {
return null
}
diff --git a/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts
index 451d0fd807d..9cde55e3aaf 100644
--- a/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts
+++ b/src/renderer/src/components/right-sidebar/file-explorer-operation-owner.ts
@@ -140,6 +140,10 @@ function operationOwnerFromHostId(hostId: ExecutionHostId): FileExplorerOperatio
return { kind: 'ssh', connectionId: parsed.targetId }
case 'runtime':
return { kind: 'runtime', environmentId: parsed.environmentId }
+ case 'wsl':
+ // Why: WSL skill state needs a durable host key before general file
+ // operations gain an in-distro routing contract; destructive work fails closed.
+ return { kind: 'unresolved' }
case undefined:
return { kind: 'unresolved' }
}
diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx
index 69d2360b7e7..f72dccde491 100644
--- a/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx
+++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.test.tsx
@@ -157,6 +157,18 @@ describe('AgentSkillSetupPanel', () => {
expect(buttonLabels(html)).toContain('Re-check')
})
+ it('suppresses the raw updater while managed status is still loading', () => {
+ const html = renderPanel({
+ installed: true,
+ installedCommand: UPDATE_COMMAND,
+ managedSkillNames: ['orca-cli']
+ })
+
+ expect(buttonLabels(html)).toContain('Checking…')
+ expect(buttonLabels(html)).not.toContain('Update')
+ expect(html).not.toContain(UPDATE_COMMAND)
+ })
+
it('hides only re-check when installed re-checks are disabled', () => {
const html = renderPanel({ installed: true, showRecheckWhenInstalled: false })
diff --git a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx
index d7f4da5b619..c7f5b6ae7e3 100644
--- a/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx
+++ b/src/renderer/src/components/settings/AgentSkillSetupPanel.tsx
@@ -5,11 +5,14 @@ import { IntegrationStatusPill } from '../integration-status-pill'
import { OnboardingInlineCommandTerminal } from '../onboarding/OnboardingInlineCommandTerminal'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
-import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills'
import { useMountedRef } from '@/hooks/useMountedRef'
import { isOrcaCliAvailableOnPath } from '@/lib/agent-skill-cli-prerequisite'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
+import type { SkillDiscoveryTarget } from '../../../../shared/skills'
+import { useAgentSkillManagementActions } from './use-agent-skill-management-actions'
+
+const EMPTY_MANAGED_SKILL_NAMES: readonly string[] = []
type AgentSkillSetupPanelVariant = 'card' | 'inline'
type SkillPrerequisiteStatus = Awaited>
@@ -46,6 +49,8 @@ type AgentSkillSetupPanelProps = {
actionHint?: ReactNode
openingHint?: ReactNode
footer?: ReactNode
+ managedSkillNames?: readonly string[]
+ managementTarget?: SkillDiscoveryTarget
onRecheck: () => void | Promise
}
@@ -79,6 +84,8 @@ export function AgentSkillSetupPanel({
actionHint,
openingHint,
footer,
+ managedSkillNames = EMPTY_MANAGED_SKILL_NAMES,
+ managementTarget,
onRecheck
}: AgentSkillSetupPanelProps): React.JSX.Element {
const [terminalOpen, setTerminalOpen] = useState(false)
@@ -88,6 +95,11 @@ export function AgentSkillSetupPanel({
Boolean(preInstallNotice && !installed)
)
const mountedRef = useMountedRef()
+ const skillManagement = useAgentSkillManagementActions({
+ installed,
+ skillNames: managedSkillNames,
+ target: managementTarget
+ })
const readPrerequisiteStatus = useCallback(
() => (getPrerequisiteStatus ?? window.api.cli.getInstallStatus)(),
[getPrerequisiteStatus]
@@ -161,7 +173,25 @@ export function AgentSkillSetupPanel({
const actionRow = (
- {!installed || showInstallWhenInstalled ? (
+ {skillManagement.applicable ? (
+ skillManagement.actionLabel ? (
+ void skillManagement.run()}
+ >
+ {skillManagement.actionBusy ? : null}
+ {skillManagement.actionBusy && !skillManagement.installation
+ ? translate(
+ 'auto.components.settings.AgentSkillSetupPanel.checkingManagement',
+ 'Checking…'
+ )
+ : skillManagement.actionLabel}
+
+ ) : null
+ ) : !installed || showInstallWhenInstalled ? (
+ ) : installed && skillManagement.installation ? (
+
+ {skillManagement.statusLabel}
+
) : installed ? (
{translate(
@@ -350,7 +387,7 @@ export function AgentSkillSetupPanel({
terminalTopMarginPx={8}
descriptionPaddingClassName="px-4 py-2"
autoScrollIntoView={false}
- onTerminalExit={notifyInstalledAgentSkillsChanged}
+ onTerminalExit={skillManagement.recordTerminalInstall}
/>
) : null}
diff --git a/src/renderer/src/components/settings/AgentsPane.tsx b/src/renderer/src/components/settings/AgentsPane.tsx
index 99718de46fe..f7dca23ac63 100644
--- a/src/renderer/src/components/settings/AgentsPane.tsx
+++ b/src/renderer/src/components/settings/AgentsPane.tsx
@@ -48,6 +48,7 @@ import { getSettingOwnershipSummary } from './setting-ownership'
import { translate } from '@/i18n/i18n'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { parseAgentDefaultEnvDraft, stringifyAgentDefaultEnvDraft } from './agent-default-env-draft'
+import { ManagedOrcaSkillsSection } from './ManagedOrcaSkillsSection'
export { getAgentsPaneSearchEntries } from './agents-search'
@@ -829,6 +830,8 @@ export function AgentsPane({
wslCapabilitiesLoading={wslCapabilitiesLoading}
/>
+
+
diff --git a/src/renderer/src/components/settings/BrowserUsePane.tsx b/src/renderer/src/components/settings/BrowserUsePane.tsx
index 8f2dea14ee4..b52769a5d52 100644
--- a/src/renderer/src/components/settings/BrowserUsePane.tsx
+++ b/src/renderer/src/components/settings/BrowserUsePane.tsx
@@ -274,6 +274,7 @@ export function BrowserUseSetup({
)}
>
Promise>>
onBeforeOpenTerminal?: () => void | Promise
+ managementTarget?: SkillDiscoveryTarget
onRecheck: () => void | Promise
}
@@ -28,10 +31,13 @@ export function BrowserUseSkillStep({
preInstallNotice,
getPrerequisiteStatus,
onBeforeOpenTerminal,
+ managementTarget,
onRecheck
}: Props): React.JSX.Element {
return (
['tone'] {
+ switch (status) {
+ case 'managed-current':
+ return 'accent'
+ case 'known-current':
+ case 'known-update-available':
+ case 'managed-update-available':
+ return 'muted'
+ case 'newer-known':
+ case 'modified':
+ case 'unknown':
+ case 'externally-managed':
+ case 'inaccessible':
+ case 'update-failed':
+ return 'muted'
+ }
+}
+
+function diagnosticCopy(installation: SkillManagementInstallation): string {
+ const revision = installation.installedReleaseRevision
+ ? `r${installation.installedReleaseRevision}`
+ : translate('auto.components.settings.ManagedOrcaSkills.unverified', 'unverified')
+ const digest = installation.installedPackageDigest?.slice(0, 8) ?? '—'
+ return `${revision} · Orca ${installation.installedAppVersion ?? installation.currentAppVersion} · ${digest}`
+}
+
+function missingVisibilityCopy(installation: SkillManagementInstallation): string | null {
+ if (!installation.managed) {
+ return null
+ }
+ const missing = [
+ !installation.providers.includes('codex') ? 'Codex' : null,
+ !installation.providers.includes('claude') ? 'Claude' : null
+ ].filter((provider): provider is string => provider !== null)
+ return missing.length > 0
+ ? translate(
+ 'auto.components.settings.ManagedOrcaSkills.notVisible',
+ 'Managed but not visible in {{value0}}.',
+ { value0: missing.join(', ') }
+ )
+ : null
+}
+
+function ReplacementDialog({
+ preview,
+ busy,
+ onClose,
+ onConfirm
+}: {
+ preview: SkillReplacementPreview
+ busy: boolean
+ onClose: () => void
+ onConfirm: () => void
+}): React.JSX.Element {
+ return (
+ !open && onClose()}>
+
+
+
+ {translate(
+ 'auto.components.settings.ManagedOrcaSkills.replaceTitle',
+ 'Replace {{value0}}?',
+ { value0: preview.skillName }
+ )}
+
+
+ {translate(
+ 'auto.components.settings.ManagedOrcaSkills.replaceDescription',
+ 'This discards the local changes shown below. Orca keeps a rollback copy until the bundled skill is verified.'
+ )}
+
+
+
+ {preview.files.map((file) => (
+
+
+ {file.path}
+ {file.change}
+
+ {file.beforeText !== null || file.afterText !== null ? (
+
+
+ {file.beforeText ?? '—'}
+
+
+ {file.afterText ?? '—'}
+
+
+ ) : (
+
+ {translate(
+ 'auto.components.settings.ManagedOrcaSkills.binaryChange',
+ 'Binary file contents differ.'
+ )}
+
+ )}
+
+ ))}
+
+
+
+
+ {translate('auto.components.settings.ManagedOrcaSkills.cancel', 'Cancel')}
+
+
+
+ {busy ? : null}
+ {translate('auto.components.settings.ManagedOrcaSkills.replace', 'Replace skill')}
+
+
+
+
+ )
+}
+
+export function ManagedOrcaSkillsSection(): React.JSX.Element {
+ const state = useManagedAgentSkills()
+ const [preview, setPreview] = useState(null)
+
+ const reportFailure = (error: unknown): void => {
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : translate(
+ 'auto.components.settings.ManagedOrcaSkills.actionFailed',
+ 'Skill action failed.'
+ )
+ )
+ }
+
+ const reviewReplacement = async (installationId: string): Promise => {
+ try {
+ setPreview(await state.previewReplacement(installationId))
+ } catch (error) {
+ reportFailure(error)
+ }
+ }
+
+ const run = async (action: () => Promise, success: string): Promise => {
+ try {
+ await action()
+ toast.success(success)
+ return true
+ } catch (error) {
+ reportFailure(error)
+ return false
+ }
+ }
+
+ return (
+
+ void state.refresh()}
+ >
+
+ {translate('auto.components.settings.ManagedOrcaSkills.refresh', 'Refresh')}
+
+ }
+ />
+ {state.error ? {state.error}
: null}
+ {state.inventory?.installations.length ? (
+
+ {state.inventory.installations.map((installation) => {
+ const busy = state.busyInstallationId === installation.id
+ const visibility = missingVisibilityCopy(installation)
+ const replaceable =
+ installation.status === 'modified' || installation.status === 'unknown'
+ return (
+
+
+
+ {installation.name}
+
+ {managedSkillStatusCopy(installation.status)}
+
+
+
+ {diagnosticCopy(installation)}
+
+ {installation.description ? (
+
{installation.description}
+ ) : null}
+
+ {installation.unresolvedPath}
+
+ {visibility ? (
+
{visibility}
+ ) : null}
+ {!installation.actionsSupported ? (
+
+ {translate(
+ 'auto.components.settings.ManagedOrcaSkills.hostPending',
+ 'Detection only on this host; management actions are not available yet.'
+ )}
+
+ ) : null}
+
+ {!installation.actionsSupported ? null : installation.status === 'known-current' ||
+ installation.status === 'known-update-available' ? (
+
+ void run(
+ () => state.manage(installation.id),
+ translate(
+ 'auto.components.settings.ManagedOrcaSkills.managedToast',
+ 'Skill is now managed and current.'
+ )
+ )
+ }
+ >
+ {busy ? : null}
+ {installation.status === 'known-update-available'
+ ? translate(
+ 'auto.components.settings.ManagedOrcaSkills.manageUpdateAction',
+ 'Manage and update'
+ )
+ : translate(
+ 'auto.components.settings.ManagedOrcaSkills.manageAction',
+ 'Manage'
+ )}
+
+ ) : installation.status === 'managed-update-available' ||
+ installation.status === 'update-failed' ? (
+
+ void run(
+ () => state.update(installation.id),
+ translate(
+ 'auto.components.settings.ManagedOrcaSkills.updatedToast',
+ 'Skill updated.'
+ )
+ )
+ }
+ >
+ {busy ? : null}
+ {installation.status === 'update-failed'
+ ? translate('auto.components.settings.ManagedOrcaSkills.retryAction', 'Retry')
+ : translate(
+ 'auto.components.settings.ManagedOrcaSkills.updateAction',
+ 'Update'
+ )}
+
+ ) : replaceable ? (
+
void reviewReplacement(installation.id)}
+ >
+ {translate('auto.components.settings.ManagedOrcaSkills.reviewAction', 'Review')}
+
+ ) : null}
+
+ )
+ })}
+
+ ) : state.loading ? (
+
+ {translate(
+ 'auto.components.settings.ManagedOrcaSkills.checking',
+ 'Checking installed skills…'
+ )}
+
+ ) : (
+
+ {translate(
+ 'auto.components.settings.ManagedOrcaSkills.none',
+ 'No installed Orca skills found in global agent homes.'
+ )}
+
+ )}
+ {preview ? (
+ setPreview(null)}
+ onConfirm={() =>
+ void run(
+ () => state.replace(preview.installationId),
+ translate(
+ 'auto.components.settings.ManagedOrcaSkills.replacedToast',
+ 'Skill replaced with the bundled version.'
+ )
+ ).then((succeeded) => succeeded && setPreview(null))
+ }
+ />
+ ) : null}
+
+ )
+}
diff --git a/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx b/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx
index 17946134bc1..4ceaca1f757 100644
--- a/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx
+++ b/src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx
@@ -1,6 +1,7 @@
import { Import, Loader2 } from 'lucide-react'
import {
ORCA_CLI_SKILL_INSTALL_COMMAND,
+ ORCA_CLI_SKILL_NAME,
ORCA_CLI_SKILL_UPDATE_COMMAND
} from '@/lib/agent-feature-install-commands'
import {
@@ -143,6 +144,7 @@ export function MobileEmulatorAgentControlRow(): React.JSX.Element {
({
refresh: vi.fn(async () => true)
},
useInstalledAgentSkillNames: vi.fn(),
+ managementRun: vi.fn(async () => {}),
+ useAgentSkillManagementActions: vi.fn(),
clipboardWrite: vi.fn(async () => {}),
toastSuccess: vi.fn(),
toastError: vi.fn()
@@ -30,6 +32,10 @@ vi.mock('./CliSkillRuntimeSetup', () => ({
buildSkillCommandForRuntime: (command: string) => command
}))
+vi.mock('./use-agent-skill-management-actions', () => ({
+ useAgentSkillManagementActions: mocks.useAgentSkillManagementActions
+}))
+
vi.mock('sonner', () => ({
toast: { success: mocks.toastSuccess, error: mocks.toastError }
}))
@@ -86,6 +92,15 @@ describe('LinearAgentSkillInstallCta', () => {
mocks.skillState.refresh.mockClear()
mocks.useInstalledAgentSkillNames.mockReset()
mocks.useInstalledAgentSkillNames.mockReturnValue(mocks.skillState)
+ mocks.managementRun.mockClear()
+ mocks.useAgentSkillManagementActions.mockReset()
+ mocks.useAgentSkillManagementActions.mockReturnValue({
+ installation: null,
+ actionLabel: null,
+ statusLabel: null,
+ busy: false,
+ run: mocks.managementRun
+ })
mocks.clipboardWrite.mockClear()
mocks.toastSuccess.mockClear()
mocks.toastError.mockClear()
@@ -134,19 +149,33 @@ describe('LinearAgentSkillInstallCta', () => {
expect(mocks.toastSuccess).toHaveBeenCalled()
})
- it('shows a subtle confirmation and the update command when installed', async () => {
+ it('shows the ledger-backed status and action without a raw update command', async () => {
mocks.skillState.installed = true
mocks.skillState.skills = [discoveredSkill({ name: 'orca-linear' })]
+ mocks.useAgentSkillManagementActions.mockReturnValue({
+ installation: { managed: true },
+ actionLabel: 'Update',
+ statusLabel: 'Update available',
+ busy: false,
+ run: mocks.managementRun
+ })
const rendered = await renderCta()
- expect(rendered.textContent).toContain('Installed')
- expect(rendered.textContent).toContain('Agent skill installed. To update it, run:')
- expect(rendered.textContent).toContain('npx skills update orca-linear --global')
+ expect(rendered.textContent).toContain('Update available')
+ expect(rendered.textContent).toContain('Update')
+ expect(rendered.textContent).not.toContain('npx skills update')
expect(rendered.textContent).not.toContain('Not installed')
+
+ await act(async () => {
+ Array.from(rendered.querySelectorAll('button'))
+ .find((button) => button.textContent === 'Update')
+ ?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ })
+ expect(mocks.managementRun).toHaveBeenCalledTimes(1)
})
- it('updates through the legacy skill name when only linear-tickets is installed', async () => {
+ it('does not expose a legacy raw updater when only linear-tickets is installed', async () => {
mocks.skillState.installed = true
mocks.skillState.skills = [
discoveredSkill({
@@ -158,7 +187,8 @@ describe('LinearAgentSkillInstallCta', () => {
const rendered = await renderCta()
- expect(rendered.textContent).toContain('npx skills update linear-tickets --global')
+ expect(rendered.textContent).toContain('Installed')
+ expect(rendered.textContent).not.toContain('npx skills update')
})
it('notes that remote agent environments need their own setup', async () => {
diff --git a/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx b/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx
index 43e755dcbe2..5e63bc71c9d 100644
--- a/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx
+++ b/src/renderer/src/components/settings/linear-agent-skill-install-cta.tsx
@@ -13,7 +13,6 @@ import {
ORCA_LINEAR_SKILL_INSTALL_COMMAND,
ORCA_LINEAR_SKILL_NAME
} from '@/lib/agent-feature-install-commands'
-import { getLinearAgentSkillUpdateCommand } from '@/lib/linear-agent-skill-update-command'
import { cn } from '@/lib/utils'
import { getLinearAgentSkillSetupInlineRuntimeCopy } from '../sidebar/linear-agent-skill-setup-copy'
import {
@@ -28,6 +27,7 @@ import {
useIntegrationSubordinateRowClass
} from './integration-card-presentation'
import { translate } from '@/i18n/i18n'
+import { useAgentSkillManagementActions } from './use-agent-skill-management-actions'
type LinearAgentSkillInstallCtaProps = {
settings: LinearAgentSkillPromptSettings | null | undefined
@@ -55,15 +55,14 @@ export function LinearAgentSkillInstallCta({
sourceKinds: GLOBAL_AGENT_SKILL_SOURCE_KINDS
})
const command = useMemo(
- () =>
- buildSkillCommandForRuntime(
- skill.installed
- ? getLinearAgentSkillUpdateCommand(skill.skills, skill.installed)
- : ORCA_LINEAR_SKILL_INSTALL_COMMAND,
- agentRuntime
- ),
- [agentRuntime, skill.installed, skill.skills]
+ () => buildSkillCommandForRuntime(ORCA_LINEAR_SKILL_INSTALL_COMMAND, agentRuntime),
+ [agentRuntime]
)
+ const management = useAgentSkillManagementActions({
+ installed: skill.installed,
+ skillNames: LINEAR_AGENT_SKILL_NAMES,
+ target: skillDiscoveryTarget
+ })
const subordinateRowClass = useIntegrationSubordinateRowClass('space-y-1.5')
const commandRowClass = useIntegrationCommandRowClass()
@@ -106,11 +105,12 @@ export function LinearAgentSkillInstallCta({
)}
) : skill.installed ? (
-
- {translate(
- 'auto.components.settings.linear.agent.skill.install.cta.installed',
- 'Installed'
- )}
+
+ {management.statusLabel ??
+ translate(
+ 'auto.components.settings.linear.agent.skill.install.cta.installed',
+ 'Installed'
+ )}
) : (
@@ -136,44 +136,53 @@ export function LinearAgentSkillInstallCta({
{!skill.loading && (
<>
- {skill.installed
- ? translate(
- 'auto.components.settings.linear.agent.skill.install.cta.installedDescription',
- 'Agent skill installed. To update it, run:'
- )
- : translate(
- 'auto.components.settings.linear.agent.skill.install.cta.description',
- 'Let your agents read and edit Linear tasks.'
- )}
+ {translate(
+ 'auto.components.settings.linear.agent.skill.install.cta.description',
+ 'Let your agents read and edit Linear tasks.'
+ )}
-
-
- {command}
-
-
-
- void management.run()}
+ >
+ {management.actionLabel}
+
+ ) : null
+ ) : (
+
+
+ {command}
+
+
+
+ void copyCommand()}
+ >
+
+
+
+
+ {translate(
'auto.components.settings.linear.agent.skill.install.cta.copyCommand',
'Copy command'
)}
- onClick={() => void copyCommand()}
- >
-
-
-
-
- {translate(
- 'auto.components.settings.linear.agent.skill.install.cta.copyCommand',
- 'Copy command'
- )}
-
-
-
+
+
+
+ )}
{remote || agentRuntime.runtime === 'wsl' ? (
{getLinearAgentSkillSetupInlineRuntimeCopy(remote, agentRuntime)}
diff --git a/src/renderer/src/components/settings/managed-skill-status-copy.ts b/src/renderer/src/components/settings/managed-skill-status-copy.ts
new file mode 100644
index 00000000000..2343ea1102f
--- /dev/null
+++ b/src/renderer/src/components/settings/managed-skill-status-copy.ts
@@ -0,0 +1,36 @@
+import type { SkillManagementStatus } from '../../../../shared/skill-management'
+import { translate } from '@/i18n/i18n'
+
+export function managedSkillStatusCopy(status: SkillManagementStatus): string {
+ switch (status) {
+ case 'managed-current':
+ return translate('auto.components.settings.ManagedOrcaSkills.managedCurrent', 'Managed')
+ case 'managed-update-available':
+ return translate(
+ 'auto.components.settings.ManagedOrcaSkills.managedUpdate',
+ 'Update available'
+ )
+ case 'known-current':
+ return translate(
+ 'auto.components.settings.ManagedOrcaSkills.availableToManage',
+ 'Available to manage'
+ )
+ case 'known-update-available':
+ return translate(
+ 'auto.components.settings.ManagedOrcaSkills.manageAndUpdate',
+ 'Manage and update'
+ )
+ case 'newer-known':
+ return translate('auto.components.settings.ManagedOrcaSkills.newerKnown', 'Newer release')
+ case 'modified':
+ return translate('auto.components.settings.ManagedOrcaSkills.modified', 'Modified')
+ case 'unknown':
+ return translate('auto.components.settings.ManagedOrcaSkills.unknown', 'Unknown copy')
+ case 'externally-managed':
+ return translate('auto.components.settings.ManagedOrcaSkills.external', 'Externally managed')
+ case 'inaccessible':
+ return translate('auto.components.settings.ManagedOrcaSkills.inaccessible', 'Inaccessible')
+ case 'update-failed':
+ return translate('auto.components.settings.ManagedOrcaSkills.failed', 'Update failed')
+ }
+}
diff --git a/src/renderer/src/components/settings/skill-install-terminal-exit.test.ts b/src/renderer/src/components/settings/skill-install-terminal-exit.test.ts
new file mode 100644
index 00000000000..669cdfd9bac
--- /dev/null
+++ b/src/renderer/src/components/settings/skill-install-terminal-exit.test.ts
@@ -0,0 +1,11 @@
+import { describe, expect, it } from 'vitest'
+import { isSuccessfulSkillInstallExit } from './skill-install-terminal-exit'
+
+describe('skill install terminal exit', () => {
+ it('accepts only a successful process exit after the install window began', () => {
+ expect(isSuccessfulSkillInstallExit({ code: 0 }, 1)).toBe(true)
+ expect(isSuccessfulSkillInstallExit({ code: 1 }, 1)).toBe(false)
+ expect(isSuccessfulSkillInstallExit({ code: null }, 1)).toBe(false)
+ expect(isSuccessfulSkillInstallExit({ code: 0 }, null)).toBe(false)
+ })
+})
diff --git a/src/renderer/src/components/settings/skill-install-terminal-exit.ts b/src/renderer/src/components/settings/skill-install-terminal-exit.ts
new file mode 100644
index 00000000000..ff080e4b223
--- /dev/null
+++ b/src/renderer/src/components/settings/skill-install-terminal-exit.ts
@@ -0,0 +1,8 @@
+export type SkillInstallTerminalExit = { code: number | null }
+
+export function isSuccessfulSkillInstallExit(
+ result: SkillInstallTerminalExit,
+ startedAt: number | null
+): startedAt is number {
+ return result.code === 0 && startedAt !== null
+}
diff --git a/src/renderer/src/components/settings/use-agent-skill-management-actions.ts b/src/renderer/src/components/settings/use-agent-skill-management-actions.ts
new file mode 100644
index 00000000000..ca808d590f9
--- /dev/null
+++ b/src/renderer/src/components/settings/use-agent-skill-management-actions.ts
@@ -0,0 +1,133 @@
+import { useMemo, useRef } from 'react'
+import { toast } from 'sonner'
+import type { SkillDiscoveryTarget } from '../../../../shared/skills'
+import { useManagedAgentSkills } from '@/hooks/useManagedAgentSkills'
+import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills'
+import { translate } from '@/i18n/i18n'
+import { useAppStore } from '@/store'
+import { managedSkillStatusCopy } from './managed-skill-status-copy'
+import { isSuccessfulSkillInstallExit } from './skill-install-terminal-exit'
+
+export function useAgentSkillManagementActions(args: {
+ installed: boolean
+ skillNames: readonly string[]
+ target?: SkillDiscoveryTarget
+}) {
+ const state = useManagedAgentSkills(args.target)
+ const installAttemptStartedAtRef = useRef(null)
+ const openSettingsPage = useAppStore((store) => store.openSettingsPage)
+ const openSettingsTarget = useAppStore((store) => store.openSettingsTarget)
+ const skillNamesKey = args.skillNames.join('\0')
+ const installation = useMemo(() => {
+ const names = new Set(skillNamesKey.split('\0').filter(Boolean))
+ return state.inventory?.installations.find((entry) => names.has(entry.name))
+ }, [skillNamesKey, state.inventory])
+
+ const openSettings = (): void => {
+ openSettingsTarget({ pane: 'agents', repoId: null })
+ openSettingsPage()
+ }
+ const actionLabel = (() => {
+ if (installation && !installation.actionsSupported) {
+ return translate('auto.components.settings.AgentSkillSetupPanel.review', 'Review')
+ }
+ switch (installation?.status) {
+ case 'known-current':
+ return translate('auto.components.settings.AgentSkillSetupPanel.manage', 'Manage')
+ case 'known-update-available':
+ return translate(
+ 'auto.components.settings.AgentSkillSetupPanel.manageUpdate',
+ 'Manage and update'
+ )
+ case 'managed-update-available':
+ return translate('auto.components.settings.AgentSkillSetupPanel.update', 'Update')
+ case 'update-failed':
+ return translate(
+ 'auto.components.settings.AgentSkillSetupPanel.retryUpdate',
+ 'Retry update'
+ )
+ case 'managed-current':
+ return null
+ case 'newer-known':
+ case 'modified':
+ case 'unknown':
+ case 'externally-managed':
+ case 'inaccessible':
+ case undefined:
+ return translate('auto.components.settings.AgentSkillSetupPanel.review', 'Review')
+ }
+ })()
+
+ const run = async (): Promise => {
+ if (!installation || !installation.actionsSupported) {
+ openSettings()
+ return
+ }
+ try {
+ if (
+ installation.status === 'known-current' ||
+ installation.status === 'known-update-available'
+ ) {
+ await state.manage(installation.id)
+ } else if (
+ installation.status === 'managed-update-available' ||
+ installation.status === 'update-failed'
+ ) {
+ await state.update(installation.id)
+ } else {
+ openSettings()
+ return
+ }
+ toast.success(
+ translate(
+ 'auto.components.settings.AgentSkillSetupPanel.managedCurrent',
+ 'Skill is managed and current.'
+ )
+ )
+ notifyInstalledAgentSkillsChanged()
+ } catch (error) {
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : translate(
+ 'auto.components.settings.AgentSkillSetupPanel.actionFailed',
+ 'Skill action failed.'
+ )
+ )
+ }
+ }
+
+ const recordTerminalInstall = (result: { code: number | null }): void => {
+ notifyInstalledAgentSkillsChanged()
+ const startedAt = installAttemptStartedAtRef.current
+ if (
+ !isSuccessfulSkillInstallExit(result, startedAt) ||
+ args.installed ||
+ args.skillNames.length === 0
+ ) {
+ return
+ }
+ void window.api.skills
+ .recordOrcaInstall({
+ skillNames: [...args.skillNames],
+ startedAt,
+ target: args.target
+ })
+ .then(notifyInstalledAgentSkillsChanged)
+ .catch(() => undefined)
+ }
+
+ return {
+ applicable: args.installed && args.skillNames.length > 0,
+ actionLabel,
+ busy: state.loading || state.busyInstallationId !== null,
+ actionBusy: state.loading || state.busyInstallationId === installation?.id,
+ installation,
+ statusLabel: installation ? managedSkillStatusCopy(installation.status) : null,
+ run,
+ recordTerminalInstall,
+ beginInstallAttempt: () => {
+ installAttemptStartedAtRef.current = Date.now()
+ }
+ }
+}
diff --git a/src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx b/src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx
index 05f534f6953..f4fb516af18 100644
--- a/src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx
+++ b/src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx
@@ -16,6 +16,8 @@ import {
isOrcaCliAvailableOnPath
} from '@/lib/agent-skill-cli-prerequisite'
import { translate } from '@/i18n/i18n'
+import { LINEAR_AGENT_SKILL_NAMES } from '@/lib/agent-feature-install-commands'
+import type { SkillDiscoveryTarget } from '../../../../shared/skills'
type AgentSkillSetupPanelProps = ComponentProps
@@ -33,6 +35,7 @@ type LinearAgentSkillSetupDialogProps = {
getPrerequisiteStatus?: AgentSkillSetupPanelProps['getPrerequisiteStatus']
onBeforeOpenTerminal: AgentSkillSetupPanelProps['onBeforeOpenTerminal']
onRecheck: AgentSkillSetupPanelProps['onRecheck']
+ managementTarget?: SkillDiscoveryTarget
onOpenChange: (open: boolean) => void
onDismissPermanently: () => void
onSnoozeForSession: () => void
@@ -53,6 +56,7 @@ export function LinearAgentSkillSetupDialog({
getPrerequisiteStatus,
onBeforeOpenTerminal,
onRecheck,
+ managementTarget,
onOpenChange,
onDismissPermanently,
onSnoozeForSession,
@@ -117,6 +121,8 @@ export function LinearAgentSkillSetupDialog({
void
+ onPtyExit: (ptyId: string, code?: number | null) => void
onCloseTab: () => void
}
diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts
index 8e151c6ab5c..911cf62f8aa 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts
@@ -47,7 +47,7 @@ export type PtyConnectionDeps = {
restoredViewportBlankingPanesRef?: RestoredViewportBlankingPanesRef
isActiveRef: React.RefObject
isVisibleRef: React.RefObject
- onPtyExitRef: React.RefObject<(ptyId: string) => void>
+ onPtyExitRef: React.RefObject<(ptyId: string, code?: number | null) => void>
onPtyErrorRef?: React.RefObject<(paneId: number, message: string) => void>
clearTabPtyId: (tabId: string, ptyId: string) => void
consumeSuppressedPtyExit: (ptyId: string) => boolean
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts
index 8399d469d08..19e486b542c 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.ts
@@ -2351,7 +2351,7 @@ export function connectPanePty(
})
return claimKey
}
- const onExit = (ptyId: string): void => {
+ const onExit = (ptyId: string, code: number | null = null): void => {
if (handledExitPtyId === ptyId) {
return
}
@@ -2452,7 +2452,11 @@ export function connectPanePty(
if (spawnedFreshPtyId === ptyId && !Number.isFinite(lastTerminalInputAt)) {
return
}
- deps.onPtyExitRef.current(ptyId)
+ if (code === null) {
+ deps.onPtyExitRef.current(ptyId)
+ } else {
+ deps.onPtyExitRef.current(ptyId, code)
+ }
return
}
if (
diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts
index 7f0f4876eba..bb756c3039f 100644
--- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts
+++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts
@@ -145,7 +145,7 @@ export type IpcPtyTransportOptions = {
projectRuntime?: ProjectExecutionRuntimeResolution
terminalColorQueryReplies?: TerminalOscColorQueryReplyColors
telemetry?: EventProps<'agent_started'>
- onPtyExit?: (ptyId: string) => void
+ onPtyExit?: (ptyId: string, code?: number | null) => void
onTitleChange?: (title: string, rawTitle: string) => void
onPtySpawn?: (ptyId: string) => void
onBell?: () => void
diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts
index fd2663b5a33..4f8fe658f01 100644
--- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts
+++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts
@@ -132,7 +132,7 @@ describe('createIpcPtyTransport', () => {
onExit?.({ id: 'pty-new', code: 0 })
- expect(onPtyExit).toHaveBeenCalledWith('pty-new')
+ expect(onPtyExit).toHaveBeenCalledWith('pty-new', 0)
expect(transport.getPtyId()).toBeNull()
expect(transport.isConnected()).toBe(false)
})
@@ -1433,7 +1433,7 @@ describe('createIpcPtyTransport', () => {
// Exit handler should still work (exit handlers are kept alive)
onExit?.({ id: 'pty-1', code: -1 })
- expect(onPtyExit).toHaveBeenCalledWith('pty-1')
+ expect(onPtyExit).toHaveBeenCalledWith('pty-1', -1)
})
it('restores data handlers when an intentional shutdown fails before exit', async () => {
@@ -1747,7 +1747,7 @@ describe('createIpcPtyTransport', () => {
onExit?.({ id: 'pty-detached', code: 0 })
- expect(onPtyExit).toHaveBeenCalledWith('pty-detached')
+ expect(onPtyExit).toHaveBeenCalledWith('pty-detached', 0)
expect(transport.getPtyId()).toBeNull()
})
})
diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts
index 60656f2907f..c92e6e834b7 100644
--- a/src/renderer/src/components/terminal-pane/pty-transport.ts
+++ b/src/renderer/src/components/terminal-pane/pty-transport.ts
@@ -663,7 +663,7 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
unregisterPtyHandlers(id)
storedCallbacks.onExit?.(code)
storedCallbacks.onDisconnect?.()
- onPtyExit?.(id)
+ onPtyExit?.(id, code)
}
ptyExitHandlers.set(id, exitHandler)
ownedExitHandlers.set(id, exitHandler)
diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts
index 59d23ca43b3..85b075ef24a 100644
--- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts
+++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts
@@ -394,7 +394,7 @@ describe('createRemoteRuntimePtyTransport', () => {
// Why: no red xterm error — retire quietly and let the next session-tabs
// snapshot drive respawn/removal.
- await vi.waitFor(() => expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-1'))
+ await vi.waitFor(() => expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-1', null))
expect(transport.getPtyId()).toBeNull()
expect(onError).not.toHaveBeenCalled()
})
@@ -472,7 +472,7 @@ describe('createRemoteRuntimePtyTransport', () => {
})
expect(onError).not.toHaveBeenCalled()
- expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-stale')
+ expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-stale', null)
expect(transport.getPtyId()).toBeNull()
})
@@ -520,7 +520,7 @@ describe('createRemoteRuntimePtyTransport', () => {
result: { type: 'end', streamId: newStreamId }
})
- expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-new')
+ expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-new', null)
expect(transport.getPtyId()).toBeNull()
expect(transport.isConnected()).toBe(false)
})
diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
index 78d78dfefcb..727b54e2683 100644
--- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
+++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
@@ -410,7 +410,7 @@ export function createRemoteRuntimePtyTransport(
remotePtyId = null
closeMultiplexedStream()
if (stalePtyId) {
- onPtyExit?.(stalePtyId)
+ onPtyExit?.(stalePtyId, null)
}
}
@@ -542,10 +542,11 @@ export function createRemoteRuntimePtyTransport(
multiplexedStream = null
multiplexedStreamHandle = null
clearPendingViewportClaim()
- storedCallbacks.onExit?.(0)
storedCallbacks.onDisconnect?.()
if (subscribedPtyId) {
- onPtyExit?.(subscribedPtyId)
+ // Why: a remote stream ending proves disconnection, not the
+ // subprocess exit status required for install ownership.
+ onPtyExit?.(subscribedPtyId, null)
}
},
onError: (message) => {
@@ -741,7 +742,7 @@ export function createRemoteRuntimePtyTransport(
remotePtyId = null
storedCallbacks.onDisconnect?.()
if (id) {
- onPtyExit?.(id)
+ onPtyExit?.(id, null)
}
},
diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
index ccb21fac182..d9a1a6f10df 100644
--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
+++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
@@ -249,7 +249,7 @@ type UseTerminalPaneLifecycleDeps = {
replayingPanesRef: ReplayingPanesRef
isActiveRef: React.RefObject
isVisibleRef: React.RefObject
- onPtyExitRef: React.RefObject<(ptyId: string) => void>
+ onPtyExitRef: React.RefObject<(ptyId: string, code?: number | null) => void>
onPtyErrorRef?: React.RefObject<(paneId: number, message: string) => void>
clearTabPtyId: (tabId: string, ptyId: string) => void
consumeSuppressedPtyExit: (ptyId: string) => boolean
diff --git a/src/renderer/src/hooks/useManagedAgentSkills.ts b/src/renderer/src/hooks/useManagedAgentSkills.ts
new file mode 100644
index 00000000000..52511b5cb9c
--- /dev/null
+++ b/src/renderer/src/hooks/useManagedAgentSkills.ts
@@ -0,0 +1,189 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import type { SkillDiscoveryTarget } from '../../../shared/skills'
+import type {
+ SkillManagementInstallation,
+ SkillManagementInventory,
+ SkillReplacementPreview
+} from '../../../shared/skill-management'
+import { useMountedRef } from './useMountedRef'
+
+const INSTALLED_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed'
+const cachedInventory = new Map()
+const pendingInventory = new Map>()
+
+function targetKey(target: SkillDiscoveryTarget | undefined): string {
+ if (target?.projectRuntime) {
+ return target.projectRuntime.status === 'resolved'
+ ? target.projectRuntime.runtime.cacheKey
+ : target.projectRuntime.repair.cacheKey
+ }
+ return target?.runtime === 'wsl' ? `wsl:${target.wslDistro ?? ''}` : 'host'
+}
+
+async function loadInventory(
+ target: SkillDiscoveryTarget | undefined,
+ force: boolean
+): Promise {
+ const key = targetKey(target)
+ if (!force && cachedInventory.has(key)) {
+ return cachedInventory.get(key)!
+ }
+ const pending = pendingInventory.get(key)
+ if (pending) {
+ return pending
+ }
+ const request = window.api.skills
+ .managementInventory(target)
+ .then((inventory) => {
+ cachedInventory.set(key, inventory)
+ return inventory
+ })
+ .finally(() => pendingInventory.delete(key))
+ pendingInventory.set(key, request)
+ return request
+}
+
+export type ManagedAgentSkillsState = {
+ inventory: SkillManagementInventory | null
+ loading: boolean
+ busyInstallationId: string | null
+ error: string | null
+ refresh: () => Promise
+ manage: (installationId: string) => Promise
+ update: (installationId: string) => Promise
+ dismiss: (installation: SkillManagementInstallation) => Promise
+ previewReplacement: (installationId: string) => Promise
+ replace: (installationId: string) => Promise
+}
+
+export function useManagedAgentSkills(target?: SkillDiscoveryTarget): ManagedAgentSkillsState {
+ const key = targetKey(target)
+ const [inventory, setInventory] = useState(
+ cachedInventory.get(key) ?? null
+ )
+ const [loading, setLoading] = useState(!cachedInventory.has(key))
+ const [busyInstallationId, setBusyInstallationId] = useState(null)
+ const [error, setError] = useState(null)
+ const mountedRef = useMountedRef()
+ const currentKeyRef = useRef(key)
+ currentKeyRef.current = key
+
+ const accept = useCallback(
+ (requestKey: string, next: SkillManagementInventory): void => {
+ cachedInventory.set(requestKey, next)
+ if (mountedRef.current && currentKeyRef.current === requestKey) {
+ setInventory(next)
+ setError(null)
+ }
+ },
+ [mountedRef]
+ )
+
+ const refresh = useCallback(async (): Promise => {
+ const requestKey = key
+ setLoading(true)
+ try {
+ accept(requestKey, await loadInventory(target, true))
+ } catch (cause) {
+ if (mountedRef.current && currentKeyRef.current === requestKey) {
+ setError(cause instanceof Error ? cause.message : 'Failed to inspect agent skills.')
+ }
+ } finally {
+ if (mountedRef.current && currentKeyRef.current === requestKey) {
+ setLoading(false)
+ }
+ }
+ }, [accept, key, mountedRef, target])
+
+ useEffect(() => {
+ const requestKey = key
+ setInventory(cachedInventory.get(requestKey) ?? null)
+ setLoading(true)
+ void loadInventory(target, false)
+ .then((next) => accept(requestKey, next))
+ .catch((cause) => {
+ if (mountedRef.current && currentKeyRef.current === requestKey) {
+ setError(cause instanceof Error ? cause.message : 'Failed to inspect agent skills.')
+ }
+ })
+ .finally(() => {
+ if (mountedRef.current && currentKeyRef.current === requestKey) {
+ setLoading(false)
+ }
+ })
+ }, [accept, key, mountedRef, target])
+
+ useEffect(() => {
+ const invalidate = (): void => {
+ cachedInventory.delete(key)
+ void refresh()
+ }
+ window.addEventListener(INSTALLED_SKILLS_CHANGED_EVENT, invalidate)
+ window.addEventListener('focus', invalidate)
+ return () => {
+ window.removeEventListener(INSTALLED_SKILLS_CHANGED_EVENT, invalidate)
+ window.removeEventListener('focus', invalidate)
+ }
+ }, [key, refresh])
+
+ const runAction = useCallback(
+ async (
+ installationId: string,
+ action: () => Promise
+ ): Promise => {
+ setBusyInstallationId(installationId)
+ try {
+ accept(key, await action())
+ } catch (cause) {
+ if (mountedRef.current) {
+ setError(cause instanceof Error ? cause.message : 'Agent skill action failed.')
+ }
+ throw cause
+ } finally {
+ if (mountedRef.current) {
+ setBusyInstallationId(null)
+ }
+ }
+ },
+ [accept, key, mountedRef]
+ )
+
+ return useMemo(
+ () => ({
+ inventory,
+ loading,
+ busyInstallationId,
+ error,
+ refresh,
+ manage: (installationId: string) =>
+ runAction(installationId, () => window.api.skills.adopt({ installationId, target })),
+ update: (installationId: string) =>
+ runAction(installationId, () =>
+ window.api.skills.updateManaged({ installationId, target })
+ ),
+ dismiss: (installation: SkillManagementInstallation) => {
+ const physicalIdentity = installation.physicalIdentity
+ const snapshotDigest = installation.installedPackageDigest
+ if (!physicalIdentity || !snapshotDigest) {
+ return Promise.reject(new Error('skill-adoption-candidate-incomplete'))
+ }
+ return runAction(installation.id, () =>
+ window.api.skills.dismissAdoption({
+ candidate: {
+ hostId: installation.hostId,
+ physicalIdentity,
+ skillName: installation.name,
+ snapshotDigest
+ },
+ target
+ })
+ )
+ },
+ previewReplacement: (installationId: string) =>
+ window.api.skills.previewReplacement({ installationId, target }),
+ replace: (installationId: string) =>
+ runAction(installationId, () => window.api.skills.replace({ installationId, target }))
+ }),
+ [busyInstallationId, error, inventory, loading, refresh, runAction, target]
+ )
+}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index c89567567d0..fee6ca8290b 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -4893,7 +4893,15 @@
"copyCommandAria": "Copy command",
"runCommandDescription": "Press Enter to run the command.",
"5f818f12ab": "Preparing...",
- "4c05b9d7cb": "Preparing setup terminal."
+ "4c05b9d7cb": "Preparing setup terminal.",
+ "checkingManagement": "Checking…",
+ "review": "Review",
+ "manage": "Manage",
+ "manageUpdate": "Manage and update",
+ "update": "Update",
+ "managedCurrent": "Skill is managed and current.",
+ "retryUpdate": "Retry update",
+ "actionFailed": "Skill action failed."
},
"AgentsPane": {
"d83834f5e6": "Detecting installed agents…",
@@ -9021,6 +9029,40 @@
"TerminalInteractionSection": {
"567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.",
"c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu."
+ },
+ "ManagedOrcaSkills": {
+ "unverified": "unverified",
+ "notVisible": "Managed but not visible in {{value0}}.",
+ "replaceTitle": "Replace {{value0}}?",
+ "replaceDescription": "This discards the local changes shown below. Orca keeps a rollback copy until the bundled skill is verified.",
+ "binaryChange": "Binary file contents differ.",
+ "cancel": "Cancel",
+ "replace": "Replace skill",
+ "actionFailed": "Skill action failed.",
+ "title": "Orca agent skills",
+ "description": "Orca updates only exact official copies you explicitly choose to manage.",
+ "refresh": "Refresh",
+ "hostPending": "Detection only on this host; management actions are not available yet.",
+ "managedToast": "Skill is now managed and current.",
+ "manageUpdateAction": "Manage and update",
+ "manageAction": "Manage",
+ "updatedToast": "Skill updated.",
+ "updateAction": "Update",
+ "reviewAction": "Review",
+ "checking": "Checking installed skills…",
+ "none": "No installed Orca skills found in global agent homes.",
+ "replacedToast": "Skill replaced with the bundled version.",
+ "managedCurrent": "Managed",
+ "managedUpdate": "Update available",
+ "availableToManage": "Available to manage",
+ "manageAndUpdate": "Manage and update",
+ "newerKnown": "Newer release",
+ "modified": "Modified",
+ "unknown": "Unknown copy",
+ "external": "Externally managed",
+ "inaccessible": "Inaccessible",
+ "failed": "Update failed",
+ "retryAction": "Retry"
}
},
"right": {
@@ -12930,6 +12972,13 @@
"searchLabels": "Filter labels…",
"searchAssignee": "Filter assignee…",
"back": "Back"
+ },
+ "managedSkills": {
+ "adoptionNudge": {
+ "title": "{{value0}} installed Orca skills can be kept up to date",
+ "description": "Review exact official copies before Orca manages them.",
+ "review": "Review"
+ }
}
},
"i18n": {
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 11cf5339d6b..ef7e87b82fa 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -4893,7 +4893,15 @@
"copyCommandAria": "Copiar comando",
"runCommandDescription": "Presiona Enter para ejecutar el comando.",
"5f818f12ab": "Preparando...",
- "4c05b9d7cb": "Preparando el terminal de configuración."
+ "4c05b9d7cb": "Preparando el terminal de configuración.",
+ "checkingManagement": "Comprobando…",
+ "review": "Revisar",
+ "manage": "Gestionar",
+ "manageUpdate": "Gestionar y actualizar",
+ "update": "Actualizar",
+ "managedCurrent": "El skill está gestionado y actualizado.",
+ "retryUpdate": "Reintentar actualización",
+ "actionFailed": "La acción del skill falló."
},
"AgentsPane": {
"d83834f5e6": "Detectando agents instalados…",
@@ -9021,6 +9029,40 @@
"TerminalInteractionSection": {
"567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.",
"c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu."
+ },
+ "ManagedOrcaSkills": {
+ "unverified": "sin verificar",
+ "notVisible": "Gestionado, pero no visible en {{value0}}.",
+ "replaceTitle": "¿Reemplazar {{value0}}?",
+ "replaceDescription": "Esto descarta los cambios locales que se muestran abajo. Orca conserva una copia de reversión hasta verificar el skill incluido.",
+ "binaryChange": "El contenido del archivo binario es diferente.",
+ "cancel": "Cancelar",
+ "replace": "Reemplazar skill",
+ "actionFailed": "La acción del skill falló.",
+ "title": "Skills de agente de Orca",
+ "description": "Orca solo actualiza copias oficiales exactas que eliges gestionar explícitamente.",
+ "refresh": "Actualizar",
+ "hostPending": "Solo detección en este host; las acciones de gestión aún no están disponibles.",
+ "managedToast": "El skill ahora está gestionado y actualizado.",
+ "manageUpdateAction": "Gestionar y actualizar",
+ "manageAction": "Gestionar",
+ "updatedToast": "Skill actualizado.",
+ "updateAction": "Actualizar",
+ "reviewAction": "Revisar",
+ "checking": "Comprobando los skills instalados…",
+ "none": "No se encontraron skills de Orca instalados en los directorios globales de agentes.",
+ "replacedToast": "El skill se reemplazó con la versión incluida.",
+ "managedCurrent": "Gestionado",
+ "managedUpdate": "Actualización disponible",
+ "availableToManage": "Disponible para gestionar",
+ "manageAndUpdate": "Gestionar y actualizar",
+ "newerKnown": "Versión más reciente",
+ "modified": "Modificado",
+ "unknown": "Copia desconocida",
+ "external": "Gestionado externamente",
+ "inaccessible": "Inaccesible",
+ "failed": "Error de actualización",
+ "retryAction": "Reintentar"
}
},
"right": {
@@ -12930,6 +12972,13 @@
"searchLabels": "Filtrar etiquetas…",
"searchAssignee": "Filtrar asignatario…",
"back": "Atrás"
+ },
+ "managedSkills": {
+ "adoptionNudge": {
+ "title": "Se pueden mantener actualizados {{value0}} skills de Orca instalados",
+ "description": "Revisa las copias oficiales exactas antes de que Orca las gestione.",
+ "review": "Revisar"
+ }
}
},
"i18n": {
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 8cc54d3ac1f..e426b6df26c 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -4878,7 +4878,15 @@
"copyCommandAria": "コマンドをコピー",
"runCommandDescription": "Enter を押してコマンドを実行します。",
"5f818f12ab": "準備中...",
- "4c05b9d7cb": "セットアップ用ターミナルを準備しています。"
+ "4c05b9d7cb": "セットアップ用ターミナルを準備しています。",
+ "checkingManagement": "確認中…",
+ "review": "確認",
+ "manage": "管理",
+ "manageUpdate": "管理して更新",
+ "update": "更新",
+ "managedCurrent": "スキルは管理対象で、最新です。",
+ "retryUpdate": "更新を再試行",
+ "actionFailed": "スキル操作に失敗しました。"
},
"AgentsPane": {
"d83834f5e6": "インストールされている agents を検出しています…",
@@ -9021,6 +9029,40 @@
"TerminalInteractionSection": {
"567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.",
"c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu."
+ },
+ "ManagedOrcaSkills": {
+ "unverified": "未検証",
+ "notVisible": "管理対象ですが、{{value0}} には表示されません。",
+ "replaceTitle": "{{value0}} を置き換えますか?",
+ "replaceDescription": "以下のローカル変更は破棄されます。Orca は同梱スキルの検証が完了するまでロールバック用コピーを保持します。",
+ "binaryChange": "バイナリファイルの内容が異なります。",
+ "cancel": "キャンセル",
+ "replace": "スキルを置き換え",
+ "actionFailed": "スキル操作に失敗しました。",
+ "title": "Orca エージェントスキル",
+ "description": "Orca は、明示的に管理対象にした完全一致の公式コピーのみ更新します。",
+ "refresh": "更新",
+ "hostPending": "このホストでは検出のみ可能です。管理操作はまだ利用できません。",
+ "managedToast": "スキルを管理対象にし、最新版にしました。",
+ "manageUpdateAction": "管理して更新",
+ "manageAction": "管理",
+ "updatedToast": "スキルを更新しました。",
+ "updateAction": "更新",
+ "reviewAction": "確認",
+ "checking": "インストール済みスキルを確認中…",
+ "none": "グローバルエージェントホームにインストール済みの Orca スキルはありません。",
+ "replacedToast": "スキルを同梱版に置き換えました。",
+ "managedCurrent": "管理対象",
+ "managedUpdate": "更新あり",
+ "availableToManage": "管理可能",
+ "manageAndUpdate": "管理して更新",
+ "newerKnown": "新しいリリース",
+ "modified": "変更あり",
+ "unknown": "不明なコピー",
+ "external": "外部で管理",
+ "inaccessible": "アクセス不可",
+ "failed": "更新失敗",
+ "retryAction": "再試行"
}
},
"right": {
@@ -12930,6 +12972,13 @@
"searchLabels": "ラベルをフィルター…",
"searchAssignee": "担当者をフィルター…",
"back": "戻る"
+ },
+ "managedSkills": {
+ "adoptionNudge": {
+ "title": "インストール済みの Orca スキル {{value0}} 件を最新に保てます",
+ "description": "Orca が管理する前に、完全一致する公式コピーを確認してください。",
+ "review": "確認"
+ }
}
},
"i18n": {
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index e66b07cc5e0..c616c5ac106 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -4878,7 +4878,15 @@
"copyCommandAria": "명령 복사",
"runCommandDescription": "Enter를 눌러 명령을 실행하세요.",
"5f818f12ab": "준비 중...",
- "4c05b9d7cb": "설정 터미널을 준비하고 있습니다."
+ "4c05b9d7cb": "설정 터미널을 준비하고 있습니다.",
+ "checkingManagement": "확인 중…",
+ "review": "검토",
+ "manage": "관리",
+ "manageUpdate": "관리 및 업데이트",
+ "update": "업데이트",
+ "managedCurrent": "스킬이 관리되고 있으며 최신 상태입니다.",
+ "retryUpdate": "업데이트 재시도",
+ "actionFailed": "스킬 작업에 실패했습니다."
},
"AgentsPane": {
"d83834f5e6": "설치된 agents 감지 중…",
@@ -9021,6 +9029,40 @@
"TerminalInteractionSection": {
"567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.",
"c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu."
+ },
+ "ManagedOrcaSkills": {
+ "unverified": "검증되지 않음",
+ "notVisible": "관리 중이지만 {{value0}}에 표시되지 않습니다.",
+ "replaceTitle": "{{value0}}을(를) 교체할까요?",
+ "replaceDescription": "아래 로컬 변경 사항이 삭제됩니다. Orca는 번들 스킬이 검증될 때까지 롤백 복사본을 보관합니다.",
+ "binaryChange": "바이너리 파일 내용이 다릅니다.",
+ "cancel": "취소",
+ "replace": "스킬 교체",
+ "actionFailed": "스킬 작업에 실패했습니다.",
+ "title": "Orca 에이전트 스킬",
+ "description": "Orca는 사용자가 명시적으로 관리하도록 선택한 정확한 공식 복사본만 업데이트합니다.",
+ "refresh": "새로 고침",
+ "hostPending": "이 호스트에서는 감지만 가능하며 관리 작업은 아직 사용할 수 없습니다.",
+ "managedToast": "이제 스킬이 관리되며 최신 상태입니다.",
+ "manageUpdateAction": "관리 및 업데이트",
+ "manageAction": "관리",
+ "updatedToast": "스킬이 업데이트되었습니다.",
+ "updateAction": "업데이트",
+ "reviewAction": "검토",
+ "checking": "설치된 스킬 확인 중…",
+ "none": "전역 에이전트 홈에 설치된 Orca 스킬이 없습니다.",
+ "replacedToast": "스킬을 번들 버전으로 교체했습니다.",
+ "managedCurrent": "관리됨",
+ "managedUpdate": "업데이트 가능",
+ "availableToManage": "관리 가능",
+ "manageAndUpdate": "관리 및 업데이트",
+ "newerKnown": "최신 릴리스",
+ "modified": "수정됨",
+ "unknown": "알 수 없는 복사본",
+ "external": "외부에서 관리됨",
+ "inaccessible": "접근 불가",
+ "failed": "업데이트 실패",
+ "retryAction": "재시도"
}
},
"right": {
@@ -12930,6 +12972,13 @@
"searchLabels": "레이블 필터…",
"searchAssignee": "담당자 필터…",
"back": "뒤로"
+ },
+ "managedSkills": {
+ "adoptionNudge": {
+ "title": "설치된 Orca 스킬 {{value0}}개를 최신 상태로 유지할 수 있습니다",
+ "description": "Orca가 관리하기 전에 정확한 공식 복사본을 검토하세요.",
+ "review": "검토"
+ }
}
},
"i18n": {
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index e0d28d35be6..d330fe0a51a 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -4878,7 +4878,15 @@
"copyCommandAria": "复制命令",
"runCommandDescription": "按 Enter 运行命令。",
"5f818f12ab": "正在准备...",
- "4c05b9d7cb": "正在准备设置终端。"
+ "4c05b9d7cb": "正在准备设置终端。",
+ "checkingManagement": "正在检查…",
+ "review": "查看",
+ "manage": "管理",
+ "manageUpdate": "管理并更新",
+ "update": "更新",
+ "managedCurrent": "技能已纳入管理并且是最新版本。",
+ "retryUpdate": "重试更新",
+ "actionFailed": "技能操作失败。"
},
"AgentsPane": {
"d83834f5e6": "检测已安装的智能体...",
@@ -9021,6 +9029,40 @@
"TerminalInteractionSection": {
"567633ff50": "Right-click pastes the clipboard into the terminal. Control-click to open the context menu.",
"c64497148a": "Right-click pastes the clipboard. Control-click opens the context menu."
+ },
+ "ManagedOrcaSkills": {
+ "unverified": "未验证",
+ "notVisible": "已管理,但在 {{value0}} 中不可见。",
+ "replaceTitle": "替换 {{value0}}?",
+ "replaceDescription": "这会丢弃下面显示的本地更改。在验证捆绑技能之前,Orca 会保留回滚副本。",
+ "binaryChange": "二进制文件内容不同。",
+ "cancel": "取消",
+ "replace": "替换技能",
+ "actionFailed": "技能操作失败。",
+ "title": "Orca 代理技能",
+ "description": "Orca 只会更新你明确选择管理的完全一致的官方副本。",
+ "refresh": "刷新",
+ "hostPending": "此主机仅支持检测;管理操作尚不可用。",
+ "managedToast": "技能现已纳入管理并更新到最新版本。",
+ "manageUpdateAction": "管理并更新",
+ "manageAction": "管理",
+ "updatedToast": "技能已更新。",
+ "updateAction": "更新",
+ "reviewAction": "查看",
+ "checking": "正在检查已安装的技能…",
+ "none": "在全局代理主目录中未找到已安装的 Orca 技能。",
+ "replacedToast": "技能已替换为捆绑版本。",
+ "managedCurrent": "已管理",
+ "managedUpdate": "有可用更新",
+ "availableToManage": "可管理",
+ "manageAndUpdate": "管理并更新",
+ "newerKnown": "较新版本",
+ "modified": "已修改",
+ "unknown": "未知副本",
+ "external": "由外部管理",
+ "inaccessible": "无法访问",
+ "failed": "更新失败",
+ "retryAction": "重试"
}
},
"right": {
@@ -12930,6 +12972,13 @@
"searchLabels": "筛选标签…",
"searchAssignee": "筛选负责人…",
"back": "返回"
+ },
+ "managedSkills": {
+ "adoptionNudge": {
+ "title": "可将已安装的 {{value0}} 个 Orca 技能保持为最新版本",
+ "description": "在 Orca 管理之前,请检查完全一致的官方副本。",
+ "review": "查看"
+ }
}
},
"i18n": {
diff --git a/src/renderer/src/lib/ai-vault-resume-target.ts b/src/renderer/src/lib/ai-vault-resume-target.ts
index f52e26c62e8..63936f46042 100644
--- a/src/renderer/src/lib/ai-vault-resume-target.ts
+++ b/src/renderer/src/lib/ai-vault-resume-target.ts
@@ -13,7 +13,7 @@ import { isWslUncPath } from '../../../shared/wsl-paths'
import type { AppState } from '@/store/types'
import { getFolderWorkspaceCandidateRepos } from './folder-workspace-connection'
-export type AiVaultResumeTargetStatus = 'local' | 'ssh' | 'runtime' | 'unknown'
+export type AiVaultResumeTargetStatus = 'local' | 'ssh' | 'runtime' | 'wsl' | 'unknown'
type AiVaultResumeRepoOwner = Pick
@@ -35,7 +35,7 @@ export function isSupportedAiVaultResumeRepo(
}
export function isSupportedAiVaultResumeTargetStatus(status: AiVaultResumeTargetStatus): boolean {
- return status === 'local' || status === 'ssh' || status === 'runtime'
+ return status === 'local' || status === 'ssh' || status === 'runtime' || status === 'wsl'
}
export function isWslStoredAiVaultSessionFile(sessionFilePath: string | null | undefined): boolean {
diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts
index 4e4b5502051..961cdba113d 100644
--- a/src/renderer/src/web/web-preload-api.ts
+++ b/src/renderer/src/web/web-preload-api.ts
@@ -2578,7 +2578,25 @@ function createSkillsApi(): NonNullable['skills']> {
skills: [],
sources: [],
scannedAt: Date.now()
- }))
+ })),
+ managementInventory: () =>
+ Promise.resolve({
+ schemaVersion: 1,
+ hostId: LOCAL_EXECUTION_HOST_ID,
+ installations: [],
+ adoptionCandidateCount: 0,
+ scannedAt: Date.now()
+ }),
+ adopt: () => Promise.reject(new Error('Skill management is unavailable in the web client.')),
+ dismissAdoption: () =>
+ Promise.reject(new Error('Skill management is unavailable in the web client.')),
+ previewReplacement: () =>
+ Promise.reject(new Error('Skill management is unavailable in the web client.')),
+ replace: () => Promise.reject(new Error('Skill management is unavailable in the web client.')),
+ updateManaged: () =>
+ Promise.reject(new Error('Skill management is unavailable in the web client.')),
+ recordOrcaInstall: () =>
+ Promise.reject(new Error('Skill management is unavailable in the web client.'))
}
}
diff --git a/src/shared/constants.ts b/src/shared/constants.ts
index 44a195d7d1e..bab0da51a05 100644
--- a/src/shared/constants.ts
+++ b/src/shared/constants.ts
@@ -456,7 +456,12 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
automations: [],
automationRuns: [],
onboarding: getDefaultOnboardingState(),
- featureInteractionTelemetryBuckets: {}
+ featureInteractionTelemetryBuckets: {},
+ skillManagementLedger: {
+ schemaVersion: 1,
+ destinations: {},
+ dismissedAdoptionCandidates: []
+ }
}
}
diff --git a/src/shared/execution-host.test.ts b/src/shared/execution-host.test.ts
index 503962a1021..39f0dc6f41a 100644
--- a/src/shared/execution-host.test.ts
+++ b/src/shared/execution-host.test.ts
@@ -10,7 +10,8 @@ import {
normalizeVisibleExecutionHostIds,
parseExecutionHostId,
toRuntimeExecutionHostId,
- toSshExecutionHostId
+ toSshExecutionHostId,
+ toWslExecutionHostId
} from './execution-host'
describe('execution host identity', () => {
@@ -32,6 +33,11 @@ describe('execution host identity', () => {
id: 'runtime:prod%2Fserver',
environmentId: 'prod/server'
})
+ expect(parseExecutionHostId(toWslExecutionHostId('Ubuntu 24.04'))).toEqual({
+ kind: 'wsl',
+ id: 'wsl:Ubuntu%2024.04',
+ distro: 'Ubuntu 24.04'
+ })
})
it('labels the local host by platform and by navigator detection', () => {
diff --git a/src/shared/execution-host.ts b/src/shared/execution-host.ts
index a2b9ae69104..6a96062f5bb 100644
--- a/src/shared/execution-host.ts
+++ b/src/shared/execution-host.ts
@@ -3,8 +3,12 @@ import type { GlobalSettings, Repo } from './types'
export const LOCAL_EXECUTION_HOST_ID = 'local'
export const ALL_EXECUTION_HOSTS_SCOPE = 'all'
-export type ExecutionHostKind = 'local' | 'ssh' | 'runtime'
-export type ExecutionHostId = typeof LOCAL_EXECUTION_HOST_ID | `ssh:${string}` | `runtime:${string}`
+export type ExecutionHostKind = 'local' | 'ssh' | 'runtime' | 'wsl'
+export type ExecutionHostId =
+ | typeof LOCAL_EXECUTION_HOST_ID
+ | `ssh:${string}`
+ | `runtime:${string}`
+ | `wsl:${string}`
export type ExecutionHostScope = typeof ALL_EXECUTION_HOSTS_SCOPE | ExecutionHostId
@@ -12,6 +16,7 @@ export type ParsedExecutionHost =
| { kind: 'local'; id: typeof LOCAL_EXECUTION_HOST_ID }
| { kind: 'ssh'; id: `ssh:${string}`; targetId: string }
| { kind: 'runtime'; id: `runtime:${string}`; environmentId: string }
+ | { kind: 'wsl'; id: `wsl:${string}`; distro: string }
function getCurrentLocalPlatform(): NodeJS.Platform | null {
const globalNavigator = (globalThis as { navigator?: { userAgent?: string; platform?: string } })
@@ -56,6 +61,10 @@ export function toRuntimeExecutionHostId(environmentId: string): `runtime:${stri
return `runtime:${encodeURIComponent(environmentId)}`
}
+export function toWslExecutionHostId(distro: string): `wsl:${string}` {
+ return `wsl:${encodeURIComponent(distro)}`
+}
+
// Why: runtime-owned (ephemeral-VM) SSH targets are hidden from user-facing
// SSH/run-target surfaces. The renderer can't read the target.owner field, so it
// recognizes them by their deterministic id prefix. getRuntimeOwnedSshTargetId
@@ -98,6 +107,18 @@ export function parseExecutionHostId(value: string | null | undefined): ParsedEx
return null
}
}
+ if (normalized.startsWith('wsl:')) {
+ const encoded = normalized.slice('wsl:'.length)
+ if (!encoded) {
+ return null
+ }
+ try {
+ const distro = decodeURIComponent(encoded)
+ return distro ? { kind: 'wsl', id: `wsl:${encoded}`, distro } : null
+ } catch {
+ return null
+ }
+ }
return null
}
@@ -174,5 +195,7 @@ export function getExecutionHostLabel(id: ExecutionHostScope): string {
return parsed.targetId
case 'runtime':
return parsed.environmentId
+ case 'wsl':
+ return `WSL: ${parsed.distro}`
}
}
diff --git a/src/shared/skill-management.ts b/src/shared/skill-management.ts
new file mode 100644
index 00000000000..db63fa703d9
--- /dev/null
+++ b/src/shared/skill-management.ts
@@ -0,0 +1,164 @@
+import type { ExecutionHostId } from './execution-host'
+import type { SkillProvider } from './skills'
+import type { SkillDiscoveryTarget } from './skills'
+
+export type SkillBundleFileIdentity = {
+ path: string
+ size: number
+ executable: boolean
+ classification: 'text' | 'binary'
+ exactSha256: string
+ textNormalizedSha256: string | null
+ identitySha256: string
+}
+
+export type SkillReleasedSnapshot = {
+ releaseRevision: number
+ packageDigest: string
+ gitTreeSha: string
+ files: SkillBundleFileIdentity[]
+}
+
+export type SkillCurrentBundleEntry = SkillReleasedSnapshot & {
+ name: string
+ sourcePath: string
+ appVersion: string
+}
+
+export type SkillBundleManifest = {
+ schemaVersion: 1
+ appVersion: string
+ skills: SkillCurrentBundleEntry[]
+}
+
+export type SkillSnapshotRegistry = {
+ schemaVersion: 1
+ skills: Record
+}
+
+export type SkillReleaseMapping = {
+ schemaVersion: 1
+ releases: { appVersion: string; skills: Record }[]
+}
+
+export type SkillManagementStatus =
+ | 'managed-current'
+ | 'managed-update-available'
+ | 'known-current'
+ | 'known-update-available'
+ | 'newer-known'
+ | 'modified'
+ | 'unknown'
+ | 'externally-managed'
+ | 'inaccessible'
+ | 'update-failed'
+
+export type SkillInstallationTopology =
+ | 'canonical-copy'
+ | 'provider-alias'
+ | 'independent-copy'
+ | 'external-link'
+ | 'broken-link'
+ | 'read-only'
+
+export type ManagedSkillDestination = {
+ id: string
+ hostId: ExecutionHostId
+ homeIdentity: string
+ rootId: string
+ unresolvedPath: string
+ resolvedPath: string
+ physicalIdentity: string
+ entryType: 'directory' | 'symlink' | 'junction'
+ skillName: string
+ source: 'stablyai/orca'
+ sourcePath: string
+ sourceRef: string | null
+ installedReleaseRevision: number
+ installedPackageDigest: string
+ installedFiles: SkillBundleFileIdentity[]
+ lastWrittenPackageDigest: string | null
+ lastAttemptedBundleFingerprint: string | null
+ lastOutcome: 'adopted' | 'updated' | 'replaced' | 'failed'
+ lastErrorCategory: string | null
+ adoptedFrom: 'orca-install' | 'exact-snapshot' | 'explicit-replacement'
+ adoptedAt: number
+ updatedAt: number
+}
+
+export type DismissedSkillAdoptionCandidate = {
+ hostId: ExecutionHostId
+ physicalIdentity: string
+ skillName: string
+ snapshotDigest: string
+ dismissedAt: number
+}
+
+export type SkillManagementLedger = {
+ schemaVersion: 1
+ destinations: Record
+ dismissedAdoptionCandidates: DismissedSkillAdoptionCandidate[]
+}
+
+export type SkillManagementInstallation = {
+ id: string
+ hostId: ExecutionHostId
+ name: string
+ description: string | null
+ rootId: string
+ providers: SkillProvider[]
+ unresolvedPath: string
+ resolvedPath: string | null
+ physicalIdentity: string | null
+ topology: SkillInstallationTopology
+ status: SkillManagementStatus
+ eligible: boolean
+ adoptionPromptEligible: boolean
+ lockCorroborated: boolean
+ actionsSupported: boolean
+ managed: boolean
+ installedReleaseRevision: number | null
+ installedAppVersion: string | null
+ currentReleaseRevision: number
+ installedPackageDigest: string | null
+ currentPackageDigest: string
+ currentAppVersion: string
+ errorCategory: string | null
+}
+
+export type SkillManagementInventory = {
+ schemaVersion: 1
+ hostId: ExecutionHostId
+ installations: SkillManagementInstallation[]
+ adoptionCandidateCount: number
+ scannedAt: number
+}
+
+export type SkillManagementActionArgs = {
+ installationId: string
+ target?: SkillDiscoveryTarget
+}
+
+export type SkillAdoptionDismissalArgs = {
+ candidate: Omit
+ target?: SkillDiscoveryTarget
+}
+
+export type OrcaSkillInstallResultArgs = {
+ skillNames: string[]
+ startedAt: number
+ target?: SkillDiscoveryTarget
+}
+
+export type SkillReplacementFilePreview = {
+ path: string
+ change: 'added' | 'removed' | 'modified'
+ beforeText: string | null
+ afterText: string | null
+}
+
+export type SkillReplacementPreview = {
+ installationId: string
+ skillName: string
+ files: SkillReplacementFilePreview[]
+}
diff --git a/src/shared/skills.ts b/src/shared/skills.ts
index 0b386624cbf..68d8a038454 100644
--- a/src/shared/skills.ts
+++ b/src/shared/skills.ts
@@ -1,4 +1,5 @@
import type { ProjectExecutionRuntimeResolution } from './project-execution-runtime'
+import type { ExecutionHostId } from './execution-host'
export type SkillProvider = 'codex' | 'claude' | 'agent-skills'
@@ -36,6 +37,7 @@ export type SkillDiscoveryResult = {
}
export type SkillDiscoveryTarget = {
+ executionHostId?: ExecutionHostId
runtime?: 'host' | 'wsl'
wslDistro?: string | null
/** Workspace path whose local .agents/.claude skill roots should be scanned. */
diff --git a/src/shared/types.ts b/src/shared/types.ts
index b88fef653dd..069cbd22f9d 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -1,5 +1,6 @@
/* eslint-disable max-lines */
import type { ExecutionHostId } from './execution-host'
+import type { SkillManagementLedger } from './skill-management'
import type { RemovedSshTargetTombstone, SshRemotePtyLease, SshTarget } from './ssh-types'
import type { Automation, AutomationExecutionTargetType, AutomationRun } from './automations-types'
import type { WorkspaceSource } from './workspace-source'
@@ -251,7 +252,7 @@ export type Repo = {
* Explicit execution owner for this repo. Runtime-host repos need this
* because they otherwise look identical to local repos (`connectionId: null`).
*/
- executionHostId?: 'local' | `ssh:${string}` | `runtime:${string}` | null
+ executionHostId?: ExecutionHostId | null
/** Per-repo override for issue-source resolution. `undefined` is treated
* identically to `'auto'`; writers leave it undefined on creation so
* existing persisted records stay forward-compatible. */
@@ -3223,7 +3224,7 @@ export type ActiveRightSidebarTab = Exclude
export type RightSidebarExplorerView = 'files' | 'search'
export type ProjectOrderBy = 'manual' | 'recent'
-export type WorkspaceHostScope = 'all' | 'local' | `ssh:${string}` | `runtime:${string}`
+export type WorkspaceHostScope = 'all' | ExecutionHostId
export type VisibleWorkspaceHostIds = Exclude[] | null
export type WorkspaceHostOrder = Exclude[]
@@ -3604,6 +3605,8 @@ export type PersistedState = {
onboarding: OnboardingState
/** Main-owned telemetry de-dupe marker; never exposed through PersistedUIState. */
featureInteractionTelemetryBuckets?: FeatureInteractionTelemetryBucketState
+ /** Orca-owned write authority for exact physical skill destinations. */
+ skillManagementLedger: SkillManagementLedger
}
// ─── Filesystem ─────────────────────────────────────────────
diff --git a/tests/e2e/settings-skill-detection.spec.ts b/tests/e2e/settings-skill-detection.spec.ts
index 155247c28d2..984ddf3f3ef 100644
--- a/tests/e2e/settings-skill-detection.spec.ts
+++ b/tests/e2e/settings-skill-detection.spec.ts
@@ -7,9 +7,82 @@ import type {
SkillSourceKind
} from '../../src/shared/skills'
import { ORCHESTRATION_ENABLED_STORAGE_KEY } from '../../src/renderer/src/lib/orchestration-setup-state'
+import type {
+ SkillManagementInstallation,
+ SkillManagementInventory
+} from '../../src/shared/skill-management'
type MockSkillDiscoveryGlobal = typeof globalThis & {
__orcaSettingsSkillDiscoveryResult?: SkillDiscoveryResult
+ __orcaSettingsSkillManagementInventory?: SkillManagementInventory
+}
+
+function managedInstallation(
+ name: string,
+ status: SkillManagementInstallation['status']
+): SkillManagementInstallation {
+ const managed = status.startsWith('managed-') || status === 'update-failed'
+ return {
+ id: name,
+ hostId: 'local',
+ name,
+ description: null,
+ rootId: 'home-agents',
+ providers: ['agent-skills', 'codex'],
+ unresolvedPath: `/Users/test/.agents/skills/${name}`,
+ resolvedPath: `/Users/test/.agents/skills/${name}`,
+ physicalIdentity: `1:${name}`,
+ topology: 'canonical-copy',
+ status,
+ eligible: status !== 'modified',
+ adoptionPromptEligible: status === 'known-current',
+ lockCorroborated: true,
+ actionsSupported: true,
+ managed,
+ installedReleaseRevision: 1,
+ installedAppVersion: '1.2.3',
+ currentReleaseRevision: 2,
+ installedPackageDigest: 'a'.repeat(64),
+ currentPackageDigest: 'b'.repeat(64),
+ currentAppVersion: '2.0.0',
+ errorCategory: status === 'update-failed' ? 'filesystem-ebusy' : null
+ }
+}
+
+async function installMockSkillManagement(
+ app: ElectronApplication,
+ inventory: SkillManagementInventory
+): Promise {
+ await app.evaluate((electron, initialInventory) => {
+ ;(globalThis as MockSkillDiscoveryGlobal).__orcaSettingsSkillManagementInventory =
+ initialInventory
+ const current = () =>
+ (globalThis as MockSkillDiscoveryGlobal).__orcaSettingsSkillManagementInventory!
+ for (const channel of [
+ 'skills:managementInventory',
+ 'skills:adopt',
+ 'skills:updateManaged',
+ 'skills:dismissAdoption'
+ ]) {
+ electron.ipcMain.removeHandler(channel)
+ electron.ipcMain.handle(channel, current)
+ }
+ electron.ipcMain.removeHandler('skills:previewReplacement')
+ electron.ipcMain.handle(
+ 'skills:previewReplacement',
+ (_event, args: { installationId: string }) => ({
+ installationId: args.installationId,
+ skillName: args.installationId,
+ files: [
+ { path: 'SKILL.md', change: 'modified', beforeText: 'local', afterText: 'official' }
+ ]
+ })
+ )
+ electron.ipcMain.removeHandler('skills:replace')
+ electron.ipcMain.handle('skills:replace', () => {
+ throw new Error('injected rollback')
+ })
+ }, inventory)
}
function makeSkill(sourceKind: SkillSourceKind, directoryPath: string): DiscoveredSkill {
@@ -117,9 +190,45 @@ test.describe('Settings skill detection', () => {
)
await section.getByRole('button', { name: 'Re-check' }).click()
- await expect(section.getByText('Installed', { exact: true })).toBeVisible()
+ await expect(section.getByText('Manage and update', { exact: true }).first()).toBeVisible()
await expect(
section.getByText('Enables agents to hand off context and coordinate work through Orca.')
).toBeVisible()
})
+
+ test('shows managed adoption, update, conflict, retry, nudge, and retained failure review', async ({
+ electronApp,
+ orcaPage
+ }) => {
+ const installations = [
+ managedInstallation('orca-cli', 'known-current'),
+ managedInstallation('orchestration', 'managed-update-available'),
+ managedInstallation('computer-use', 'modified'),
+ managedInstallation('orca-linear', 'update-failed')
+ ]
+ await installMockSkillManagement(electronApp, {
+ schemaVersion: 1,
+ hostId: 'local',
+ installations,
+ adoptionCandidateCount: 1,
+ scannedAt: Date.now()
+ })
+ await orcaPage.evaluate(() => {
+ const state = window.__store!.getState()
+ state.openSettingsTarget({ pane: 'agents', repoId: null })
+ state.openSettingsPage()
+ window.dispatchEvent(new Event('orca:installed-agent-skills-changed'))
+ })
+
+ await expect(orcaPage.getByText('Orca agent skills', { exact: true })).toBeVisible()
+ await expect(orcaPage.getByRole('button', { name: 'Manage', exact: true })).toBeVisible()
+ await expect(orcaPage.getByRole('button', { name: 'Update', exact: true })).toBeVisible()
+ await expect(orcaPage.getByRole('button', { name: 'Retry', exact: true })).toBeVisible()
+ await expect(orcaPage.getByText('1 installed Orca skills can be kept up to date')).toBeVisible()
+ await orcaPage.locator('#agents').getByRole('button', { name: 'Review', exact: true }).click()
+ await expect(orcaPage.getByRole('heading', { name: 'Replace computer-use?' })).toBeVisible()
+ await orcaPage.getByRole('button', { name: 'Replace skill' }).click()
+ await expect(orcaPage.getByRole('heading', { name: 'Replace computer-use?' })).toBeVisible()
+ await expect(orcaPage.locator('#agents').getByText('injected rollback')).toBeVisible()
+ })
})
diff --git a/tests/e2e/settings-skill-management-production.spec.ts b/tests/e2e/settings-skill-management-production.spec.ts
new file mode 100644
index 00000000000..22d40072699
--- /dev/null
+++ b/tests/e2e/settings-skill-management-production.spec.ts
@@ -0,0 +1,47 @@
+import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { test, expect } from './helpers/orca-app'
+import { waitForSessionReady } from './helpers/store'
+
+const isolatedHome = mkdtempSync(join(tmpdir(), 'orca-e2e-skill-home-'))
+const installedRoot = join(isolatedHome, '.agents', 'skills', 'orca-cli')
+mkdirSync(installedRoot, { recursive: true })
+cpSync(resolve('skills/orca-cli'), installedRoot, { recursive: true })
+
+test.use({
+ orcaAppExtraEnv: {
+ HOME: isolatedHome,
+ USERPROFILE: isolatedHome,
+ ORCA_E2E_SKILL_HOME_DIR: isolatedHome
+ }
+})
+
+test.afterAll(() => {
+ rmSync(isolatedHome, { recursive: true, force: true })
+})
+
+test('real skill IPC inventories and adopts only inside isolated production roots', async ({
+ orcaPage
+}) => {
+ await waitForSessionReady(orcaPage)
+ const before = await orcaPage.evaluate(() => window.api.skills.managementInventory())
+ const candidate = before.installations.find((entry) => entry.name === 'orca-cli')
+ expect(candidate).toMatchObject({
+ status: 'known-current',
+ managed: false,
+ actionsSupported: true
+ })
+
+ const after = await orcaPage.evaluate(
+ (installationId) => window.api.skills.adopt({ installationId }),
+ candidate!.id
+ )
+ expect(after.installations.find((entry) => entry.id === candidate!.id)).toMatchObject({
+ status: 'managed-current',
+ managed: true
+ })
+ expect(readFileSync(join(installedRoot, 'SKILL.md'), 'utf8')).toBe(
+ readFileSync(resolve('skills/orca-cli/SKILL.md'), 'utf8')
+ )
+})