Keep Orca agent skills ready at workflow start

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-06-22 15:39:39 -07:00
co-authored by Orca
parent d063e8d5cb
commit c7d1479581
62 changed files with 3952 additions and 226 deletions
@@ -0,0 +1,258 @@
# Agent Skill Auto-Update Design
## Summary
Orca should keep Orca-managed agent skills current when a user or agent enters a workflow that needs the skill. The update should be automatic only when Orca can prove it is updating the same tracked install the user already has. When Orca cannot prove that but has a safe manual install/update command, the same workflow trigger should show a contextual setup/update modal instead.
Settings must not trigger background skill updates. Settings may continue to show status and manual controls.
Automatic managed-skill updates are enabled by default. Users can turn them off from the Agents settings pane; when disabled, Orca still uses the same workflow triggers, but stale global managed-skill updates fall back to the contextual manual update modal instead of running in the background.
## Goals
- Avoid sending users to Settings -> CLI for routine skill maintenance.
- Keep managed Orca skills current at the moment they are actually needed.
- Avoid surprising writes to user-managed, project-managed, remote, or ambiguous skill installs.
- Avoid repeated subprocess/network work when many agents attempt the same skill.
- Explain any modal in the context that caused it, especially when an agent triggered the need.
## Non-Goals
- Do not build a generic updater for every skill on the Skills page.
- Do not silently update bundled, plugin, hand-copied, local-path, or user-authored skills.
- Do not run broad skill checks at app startup.
- Do not infer update scope from Orca's current working directory.
## Managed Skills
Initial support is limited to Orca-owned feature skills:
- `linear-tickets`
- `orchestration`
- `computer-use`
- `orca-cli`
## Core UX Rule
Auto-update and ask prompts have the same trigger.
```text
User or agent enters a workflow that needs a managed skill
-> coordinator checks whether background update is safe
-> if safe, update in the background
-> if unsafe or update fails and Orca has a safe manual command, show the contextual modal
-> otherwise return fallback state without opening an unactionable modal
```
There is no separate "ask" trigger. Asking is the fallback path for the same intent boundary.
The automatic-updates setting does not create a new trigger. It only controls whether an eligible stale global install may be updated quietly. If the setting is off, that same trigger shows the update modal each time an update is needed and Orca has the manual update command.
## Intent Boundary Triggers
| Skill | Trigger |
| --- | --- |
| `linear-tickets` | User starts a new worktree from a Linear task, or launches/continues an agent workflow attached to a Linear issue where agents are expected to read or update the issue. |
| `orchestration` | User starts an orchestration workflow, such as `/orchestration`, handoff, child-agent coordination, or an explicit orchestration enable/use action. |
| `computer-use` | User starts a Computer Use workflow, or an agent first attempts to invoke computer-use for the active task. |
| `orca-cli` | A workflow that depends on the Orca CLI skill starts, such as Browser Use, mobile emulator agent workflows, or another feature that explicitly needs Orca CLI affordances. |
Settings pages, setup guide status cards, and generic skill browsing should not trigger automatic updates. They can display status and manual actions.
The Agents settings pane exposes the default-on preference as "Allow verified Orca skill updates" with copy explaining that Orca can try managed agent skill updates in the background only when it has verified install metadata and a safe update path. Turning it off means updates are reviewed manually at the same workflow boundaries.
## Modal Copy Requirements
When the fallback modal is shown because an agent attempted a skill, the modal must say that.
Examples:
- "An agent just tried to use Orca orchestration. Orca needs to update the orchestration skill before agents can coordinate reliably."
- "An agent tried to use Orca's CLI skill for this workflow. Orca can update it now, or you can run the command manually."
- "This Linear task workflow needs the Linear agent skill. Orca could not update it automatically."
The modal should show the exact command Orca would run or wanted the user to run.
## Safety Model
Orca must not choose scope from preference or cwd. It must choose scope from the existing install.
Auto-update is eligible only when all checks pass:
1. The skill is one of the managed Orca skills.
2. The workflow trigger requires that skill.
3. Discovery finds an existing install for the current runtime.
4. Discovery identifies a concrete runtime target: host or a specific WSL distro.
5. Discovery identifies a concrete scope:
- `home` means global/user scope.
- `repo` means project scope.
6. The matching lock file contains an entry for the skill.
7. The lock entry has enough update metadata for the `skills` CLI to update non-interactively.
8. The source is expected to be updateable without interactive auth.
9. The active project runtime is not repair-required.
10. The coordinator has not recorded a recent failed attempt for the same skill/runtime/scope.
For the first version, auto-update should be limited to tracked global installs. Project-scoped installs should use the same trigger and return a non-emitted fallback until project-scope behavior and a safe manual command are proven.
## Scope Resolution
Do not run:
```bash
npx skills update -y
```
Plain `-y` skips prompts and lets the `skills` CLI infer project vs global scope from cwd. That is not safe for background maintenance.
For global installs, run an explicit global command:
```bash
npx --yes skills update <skill> --global --yes
```
For future project support, run an explicit project command from the project root:
```bash
npx --yes skills update <skill> --project --yes
```
For WSL, wrap the explicit command with the selected WSL distro command path used by the existing setup UI.
Symlinks should be resolved with `realpath` for dedupe, but symlink targets must not override visible scope. A skill visible under a global home root remains global. A skill visible under a repo root remains project-scoped.
## Coordinator Design
Add one central managed-skill update coordinator in the main process.
The coordinator key is:
```text
skillName + runtime + distro + scope
```
Responsibilities:
- Read discovery results and lock metadata.
- Decide whether auto-update is eligible.
- Serialize update attempts per key.
- Deduplicate in-flight checks and updates.
- Cache success for the current app version/session.
- Store failure cooldowns.
- Refresh skill discovery after success.
- Return fallback information when auto-update is unsafe or failed.
Renderer surfaces should not independently spawn update commands. They should call the coordinator and then either continue the workflow, show an actionable modal response that includes a manual command, or avoid interrupting for non-actionable fallback state.
## Performance Guardrails
The update path must be cheap when many agents are running.
- Do not check on every tool call or every render.
- Do not run one subprocess per agent.
- Do not run network/npm work repeatedly for the same skill/runtime/scope.
- Share one in-flight promise across all callers.
- Cache success for the current app version/session.
- Back off after failure.
- Prefer invalidating "needs check" after an Orca app update, then checking only at the next real workflow trigger.
Example:
```text
50 agents attempt orchestration
-> first attempt asks coordinator for orchestration:host:global
-> coordinator starts one update/check
-> other 49 attempts join the same in-flight result or use cached success/failure
```
## Invalidation
The default invalidation should be app-version based.
```text
Orca app updates
-> managed skill check state is invalidated
-> next workflow trigger for each skill/runtime/scope checks once
-> success is cached for the session/app version
```
This matches the likely reason managed Orca skills need updating: Orca changed what agents need.
Optional future invalidation:
- Manual "Re-check" or "Update" from Settings.
- Lockfile mtime changes.
- Skill directory mtime changes.
- A long TTL if managed skills are expected to change independently of app releases.
## Risks And Mitigations
| Risk | Mitigation |
| --- | --- |
| Wrong scope gets updated | Never use cwd inference; require discovery scope plus matching lockfile; use explicit `--global` or `--project`. |
| User has both global and project installs | Treat as ambiguous unless the active workflow has a single proven scope; return non-emitted fallback until there is an actionable command. |
| User has custom/local/hand-copied skill | Require lock metadata; return fallback if missing or legacy and only emit UI when there is a safe manual command. |
| Copy-mode installs are recreated as symlinks by upstream CLI | Avoid auto-update unless install metadata is trusted; call this out only when the fallback/manual path is actionable. |
| Private repo or auth-gated source needs credentials | Do not auto-update when source/auth cannot be proven non-interactive; return non-emitted fallback unless Orca has a safe manual command. |
| WSL distro unavailable or project runtime repair-required | Return fallback state without opening a setup modal until WSL-specific guidance or commands are actionable. |
| SSH/remote filesystem update writes somewhere unexpected | Do not auto-update remote/SSH skills in v1. |
| Modal appears unexpectedly because an agent triggered it | Modal copy must name the triggering context: "An agent just tried to..." |
| 50 agents cause 50 checks | Central coordinator with in-flight dedupe, success cache, and failure cooldown. |
| User turns automatic updates off | Keep the same triggers, but return the manual update modal for stale global installs without cooling that disabled-path modal away. |
| Update command hangs or npm/network is slow | Add timeout, cancellation on app shutdown where possible, and emit a modal only when a manual command is available. |
| Upstream `skills` CLI lacks reliable dry-run status | Do not show stale status unless backed by coordinator evidence; use update command only after eligibility passes. |
| Supply-chain/source surprise | Limit to Orca-managed skill names and tracked lock entries; do not update arbitrary Skills page entries. |
| Update succeeds but running agents keep old skill context | Treat update as best effort for future invocations; do not restart or mutate already-running agent prompts. |
## User Flow
1. User starts a workflow that needs a managed skill.
2. Renderer calls the coordinator with skill name and workflow context.
3. Coordinator returns one of:
- `ready`: installed and recently checked.
- `updated`: reserved for a verified background update path once the CLI contract ships.
- `fallback`: return reason and optional command.
4. Workflow continues for `ready` and `updated`.
5. For `fallback`, show the contextual modal only when the fallback includes a manual command; otherwise avoid an interrupting dead-end prompt.
## Implementation Steps
1. Extend discovery with canonical `realpath` fields while preserving visible root and source kind.
2. Add lockfile readers for global and project `skills` CLI metadata.
3. Add a managed-skill coordinator in main:
- eligibility evaluation
- explicit-scope command execution once the verified CLI contract is available
- host/WSL routing
- in-flight dedupe
- app-version/session success cache
- failure cooldowns
4. Add IPC:
- `skills:ensureManagedReady`
5. Wire the intent boundary triggers:
- Linear worktree-from-task and linked Linear agent workflow.
- Orchestration workflow start.
- Computer Use workflow start or first runtime invocation.
- Browser Use/mobile emulator workflow start for `orca-cli`.
6. Add contextual modal copy and fallback reason handling.
7. Add the default-on automatic managed-skill updates setting in Agents settings.
8. Keep Settings manual update/status behavior separate from automatic update triggers.
## Tests
- Global tracked skill update eligibility without running blind `npx`.
- Project tracked skill falls back in v1.
- Same skill in global and project scope falls back as ambiguous.
- Symlinked visible global install remains global after `realpath`.
- Missing lock entry falls back.
- Legacy or local lock entry falls back.
- WSL runtime fallbacks do not open dead-end setup modals until WSL commands are supported.
- 50 concurrent requests for one skill produce one update attempt.
- Failure cooldown prevents repeated attempts.
- User-disabled automatic updates show the manual update modal on each stale-update trigger.
- App-version invalidation re-enables a single check after update.
- Agent-triggered fallback modal includes triggering context.
## Recommended First Version
Ship the coordinator and intent-boundary triggers, but do not run background `npx` until the verified CLI/ref/hash contract is available. Tracked global Orca-managed installs are the only future auto-update target for this version line. Everything else uses the same trigger and returns fallback state; only fallback state with a safe manual command opens a contextual modal.
This covers the common setup path from Orca's own install buttons while protecting custom, project, remote, and ambiguous installs.
+18 -1
View File
@@ -21,6 +21,7 @@ import { registerCoreHandlers } from './ipc/register-core-handlers'
import { initObservability, shutdownObservability } from './observability'
import { startSpan } from './observability/tracer'
import { registerMobileHandlers } from './ipc/mobile'
import { sendManagedSkillFallback, sendManagedSkillUpdated } from './ipc/skills'
import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce } from './telemetry/client'
import { runManagedHookInstallers } from './agent-hooks/install-telemetry'
import {
@@ -34,6 +35,7 @@ import { resolveConsent } from './telemetry/consent'
import { triggerStartupNotificationRegistration } from './ipc/notifications'
import { OrcaRuntimeService } from './runtime/orca-runtime'
import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc'
import { getManagedSkillUpdateCoordinator } from './skills/managed-skill-updates'
import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files'
import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata'
import { ensureMainI18n, setMainUiLanguage } from './i18n/main-i18n'
@@ -1622,7 +1624,22 @@ app.whenReady().then(async () => {
...(isE2E ? { wsPort: 0 } : {}),
...(devWsPort !== undefined ? { wsPort: devWsPort } : {}),
...(serveOptions?.wsPort !== undefined ? { wsPort: serveOptions.wsPort } : {}),
webClientRoot: getBundledWebClientRoot()
webClientRoot: getBundledWebClientRoot(),
managedSkillDiscoveryTarget: { runtime: 'host' },
managedSkillRemoteRuntime: isServeMode,
nudgeManagedSkill: async ({ skillName, context, remoteRuntime, discoveryTarget }) => {
const result = await getManagedSkillUpdateCoordinator(store!).ensureManagedReady({
skillName,
context,
...(remoteRuntime ? { remoteRuntime } : {}),
...(!remoteRuntime && discoveryTarget ? { discoveryTarget } : {})
})
if (result.status === 'fallback') {
sendManagedSkillFallback(result)
} else if (result.status === 'updated') {
sendManagedSkillUpdated(result)
}
}
})
registerMobileHandlers(runtimeRpc)
+2 -2
View File
@@ -412,8 +412,8 @@ describe('registerCoreHandlers', () => {
expect(registerUIHandlersMock).toHaveBeenCalledWith(store)
expect(registerEmulatorFrameStreamHandlersMock).toHaveBeenCalled()
expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store)
expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime)
expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalled()
expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime, store)
expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalledWith(store)
expect(registerAiVaultHandlersMock).toHaveBeenCalledWith({
getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths
})
+2 -2
View File
@@ -159,8 +159,8 @@ export function registerCoreHandlers(
registerFilesystemHandlers(store)
}
registerFilesystemWatcherHandlers()
registerRuntimeHandlers(runtime)
registerRuntimeEnvironmentHandlers()
registerRuntimeHandlers(runtime, store)
registerRuntimeEnvironmentHandlers(store)
registerAiVaultHandlers({
getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths
})
+68 -2
View File
@@ -20,7 +20,10 @@ const {
sendRemoteRuntimeSharedControlRequestMock,
subscribeRemoteRuntimeSharedControlRequestMock,
getRemoteRuntimeSharedControlDiagnosticsMock,
closeRemoteRuntimeRequestConnectionMock
closeRemoteRuntimeRequestConnectionMock,
ensureManagedReadyMock,
sendManagedSkillFallbackMock,
sendManagedSkillUpdatedMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
onMock: vi.fn(),
@@ -33,7 +36,10 @@ const {
sendRemoteRuntimeSharedControlRequestMock: vi.fn(),
subscribeRemoteRuntimeSharedControlRequestMock: vi.fn(),
getRemoteRuntimeSharedControlDiagnosticsMock: vi.fn(),
closeRemoteRuntimeRequestConnectionMock: vi.fn()
closeRemoteRuntimeRequestConnectionMock: vi.fn(),
ensureManagedReadyMock: vi.fn(),
sendManagedSkillFallbackMock: vi.fn(),
sendManagedSkillUpdatedMock: vi.fn()
}))
vi.mock('electron', () => ({
@@ -59,6 +65,17 @@ vi.mock('./runtime-environment-request-connections', () => ({
closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
}))
vi.mock('../skills/managed-skill-updates', () => ({
getManagedSkillUpdateCoordinator: () => ({
ensureManagedReady: ensureManagedReadyMock
})
}))
vi.mock('./skills', () => ({
sendManagedSkillFallback: sendManagedSkillFallbackMock,
sendManagedSkillUpdated: sendManagedSkillUpdatedMock
}))
import { registerRuntimeEnvironmentHandlers } from './runtime-environments'
function pairingCode(endpoint = 'ws://127.0.0.1:6768'): string {
@@ -97,6 +114,10 @@ describe('registerRuntimeEnvironmentHandlers', () => {
getRemoteRuntimeSharedControlDiagnosticsMock.mockReset()
getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue(null)
closeRemoteRuntimeRequestConnectionMock.mockReset()
ensureManagedReadyMock.mockReset()
ensureManagedReadyMock.mockResolvedValue({ status: 'ready' })
sendManagedSkillFallbackMock.mockReset()
sendManagedSkillUpdatedMock.mockReset()
})
afterEach(() => {
@@ -379,6 +400,51 @@ describe('registerRuntimeEnvironmentHandlers', () => {
expect(sendRemoteRuntimeConnectionRequestMock).not.toHaveBeenCalled()
})
it('nudges managed skill readiness after remote runtime feature RPCs without opening setup', async () => {
registerRuntimeEnvironmentHandlers({} as never)
ensureManagedReadyMock.mockResolvedValue({
status: 'fallback',
skillName: 'orchestration',
context: 'agent-orchestration',
runtime: 'host',
scope: 'missing',
reason: 'remote-runtime'
})
sendRemoteRuntimeRequestMock.mockResolvedValue({
id: 'rpc-orchestration',
ok: true,
result: { accepted: true },
_meta: { runtimeId: 'runtime-remote' }
})
const add = handler<
{ name: string; pairingCode: string },
{ environment: { id: string; name: string } }
>('runtimeEnvironments:addFromPairingCode')
await add(null, { name: 'desk', pairingCode: pairingCode() })
const call = handler<
{ selector: string; method: string; params?: unknown; timeoutMs?: number },
{ ok: true; result: unknown }
>('runtimeEnvironments:call')
await expect(
call(null, {
selector: 'desk',
method: 'orchestration.send',
params: { message: 'coordinate' }
})
).resolves.toMatchObject({ ok: true })
await vi.waitFor(() => {
expect(ensureManagedReadyMock).toHaveBeenCalledWith({
skillName: 'orchestration',
context: 'agent-orchestration',
remoteRuntime: true
})
})
expect(sendManagedSkillFallbackMock).not.toHaveBeenCalled()
})
it('falls back to one-shot RPC when the saved runtime lacks shared-control support', async () => {
registerRuntimeEnvironmentHandlers()
sendRemoteRuntimeRequestMock.mockImplementation(async (_pairing, method) => {
+30 -2
View File
@@ -13,6 +13,7 @@ import {
import type { RuntimeStatus } from '../../shared/runtime-types'
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client'
import type { Store } from '../persistence'
import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections'
import {
callRuntimeEnvironment,
@@ -21,6 +22,9 @@ import {
resetSharedControlSupport,
subscribeRuntimeEnvironment
} from './runtime-environment-transport-routing'
import { getRuntimeFeatureInteractionId } from '../runtime/rpc/runtime-feature-interaction-id'
import { getManagedSkillNudgeForFeatureInteraction } from '../runtime/rpc/managed-skill-nudge'
import { createRuntimeManagedSkillNudge } from './runtime-managed-skill-nudge'
const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [
'runtimeEnvironments:list',
@@ -57,7 +61,7 @@ function closeSubscriptionsForEnvironment(environmentId: string): void {
}
}
export function registerRuntimeEnvironmentHandlers(): void {
export function registerRuntimeEnvironmentHandlers(store?: Store): void {
// Why: keep direct re-registration safe even though register-core-handlers
// normally guards this path; otherwise the binary send listener can stack.
resetSharedControlSupport()
@@ -128,13 +132,15 @@ export function registerRuntimeEnvironmentHandlers(): void {
_event,
args: { selector: string; method: string; params?: unknown; timeoutMs?: number }
): Promise<RuntimeRpcResponse<unknown>> => {
return callRuntimeEnvironment(
const response = await callRuntimeEnvironment(
getUserDataPath(),
args.selector,
args.method,
args.params,
args.timeoutMs
)
nudgeManagedSkillForRemoteRuntimeCall(store, args, response)
return response
}
)
ipcMain.handle(
@@ -258,6 +264,28 @@ export function registerRuntimeEnvironmentHandlers(): void {
)
}
function nudgeManagedSkillForRemoteRuntimeCall(
store: Store | undefined,
args: { method: string; params?: unknown },
response: RuntimeRpcResponse<unknown>
): void {
if (!store || !response.ok) {
return
}
const id = getRuntimeFeatureInteractionId(args.method, response.result, args.params)
const nudge = id ? getManagedSkillNudgeForFeatureInteraction(id) : null
if (!nudge) {
return
}
try {
void Promise.resolve(
createRuntimeManagedSkillNudge(store)({ ...nudge, remoteRuntime: true })
).catch(() => {})
} catch {
// Best-effort managed-skill maintenance must not break remote runtime tools.
}
}
function toBinaryPayload(value: unknown): Uint8Array<ArrayBufferLike> | null {
if (value instanceof Uint8Array) {
return value
@@ -0,0 +1,21 @@
import type { Store } from '../persistence'
import { shouldEmitManagedAgentSkillFallback } from '../../shared/skills'
import type { RuntimeManagedSkillNudgeHandler } from '../runtime/rpc/managed-skill-nudge'
import { getManagedSkillUpdateCoordinator } from '../skills/managed-skill-updates'
import { sendManagedSkillFallback, sendManagedSkillUpdated } from './skills'
export function createRuntimeManagedSkillNudge(store: Store): RuntimeManagedSkillNudgeHandler {
return async ({ skillName, context, remoteRuntime, discoveryTarget }) => {
const result = await getManagedSkillUpdateCoordinator(store).ensureManagedReady({
skillName,
context,
...(remoteRuntime ? { remoteRuntime } : {}),
...(!remoteRuntime && discoveryTarget ? { discoveryTarget } : {})
})
if (shouldEmitManagedAgentSkillFallback(result)) {
sendManagedSkillFallback(result)
} else if (result.status === 'updated') {
sendManagedSkillUpdated(result)
}
}
}
+8 -2
View File
@@ -1,5 +1,6 @@
import { BrowserWindow, ipcMain } from 'electron'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type { Store } from '../persistence'
import type {
RuntimeBrowserDriverState,
RuntimeStatus,
@@ -9,8 +10,9 @@ import type {
} from '../../shared/runtime-types'
import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
import { RpcDispatcher } from '../runtime/rpc/dispatcher'
import { createRuntimeManagedSkillNudge } from './runtime-managed-skill-nudge'
export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
export function registerRuntimeHandlers(runtime: OrcaRuntimeService, store?: Store): void {
ipcMain.removeHandler('runtime:syncWindowGraph')
ipcMain.removeHandler('runtime:getStatus')
ipcMain.removeHandler('runtime:call')
@@ -36,7 +38,11 @@ export function registerRuntimeHandlers(runtime: OrcaRuntimeService): void {
_event,
args: { method: string; params?: unknown }
): Promise<RuntimeRpcResponse<unknown>> => {
return (await new RpcDispatcher({ runtime }).dispatch({
return (await new RpcDispatcher({
runtime,
nudgeManagedSkill: store ? createRuntimeManagedSkillNudge(store) : undefined,
managedSkillDiscoveryTarget: { runtime: 'host' }
}).dispatch({
id: 'desktop-ipc',
authToken: 'desktop-ipc',
method: args.method,
+152 -1
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../shared/constants'
const { handleMock, discoverSkillsMock, getDefaultWslDistroMock, getWslHomeMock } = vi.hoisted(
() => ({
@@ -10,6 +11,9 @@ const { handleMock, discoverSkillsMock, getDefaultWslDistroMock, getWslHomeMock
)
vi.mock('electron', () => ({
BrowserWindow: {
getAllWindows: vi.fn(() => [])
},
ipcMain: {
handle: handleMock
}
@@ -30,7 +34,8 @@ 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),
getSettings: vi.fn(() => getDefaultSettings('C:\\Users\\alice'))
}
beforeEach(() => {
@@ -38,6 +43,8 @@ describe('registerSkillsHandlers', () => {
discoverSkillsMock.mockReset()
getDefaultWslDistroMock.mockReset()
getWslHomeMock.mockReset()
store.getRepos.mockClear()
store.getSettings.mockClear()
discoverSkillsMock.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 })
getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\alice')
Object.defineProperty(process, 'platform', {
@@ -61,6 +68,20 @@ describe('registerSkillsHandlers', () => {
return call[1] as (_event: unknown, target?: unknown) => Promise<unknown>
}
function getEnsureHandler() {
registerSkillsHandlers(store as never)
const call = handleMock.mock.calls.find(
(entry: unknown[]) => entry[0] === 'skills:ensureManagedReady'
)
if (!call) {
throw new Error('skills:ensureManagedReady handler was not registered')
}
return call[1] as (
event: { sender: { send: ReturnType<typeof vi.fn> } },
request: unknown
) => Promise<unknown>
}
it('uses host skill discovery when resolved project runtime overrides stale WSL target state', async () => {
const handler = getDiscoverHandler()
@@ -128,4 +149,134 @@ describe('registerSkillsHandlers', () => {
).rejects.toThrow('Project runtime requires repair before skill discovery')
expect(discoverSkillsMock).not.toHaveBeenCalled()
})
it('registers managed ensure and returns repair-required fallback without emitting setup', async () => {
const handler = getEnsureHandler()
const send = vi.fn()
const result = await handler(
{ sender: { send } },
{
skillName: 'linear-tickets',
context: 'linear-worktree',
discoveryTarget: {
projectRuntime: {
status: 'repair-required',
repair: {
projectId: 'repo-1',
preferredRuntime: { kind: 'wsl', distro: 'Ubuntu' },
reason: 'wsl-distro-missing',
source: 'project-override',
cacheKey: 'repo-1:repair:wsl-distro-missing:Ubuntu'
}
}
}
}
)
expect(result).toMatchObject({
status: 'fallback',
reason: 'repair-required-runtime',
skillName: 'linear-tickets'
})
expect(send).not.toHaveBeenCalled()
expect(discoverSkillsMock).not.toHaveBeenCalled()
})
it('does not emit managed fallback events for cooldown results', async () => {
const handler = getEnsureHandler()
const send = vi.fn()
const request = {
skillName: 'orchestration',
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
}
await handler({ sender: { send } }, request)
send.mockClear()
const result = await handler({ sender: { send } }, request)
expect(result).toMatchObject({
status: 'fallback',
reason: 'cooldown'
})
expect(send).not.toHaveBeenCalled()
})
it('does not emit managed fallback events for remote runtime results', async () => {
const handler = getEnsureHandler()
const send = vi.fn()
const result = await handler(
{ sender: { send } },
{
skillName: 'orchestration',
context: 'agent-orchestration',
remoteRuntime: true
}
)
expect(result).toMatchObject({
status: 'fallback',
reason: 'remote-runtime'
})
expect(send).not.toHaveBeenCalled()
})
it('does not emit managed fallback events for WSL runtime results', async () => {
const handler = getEnsureHandler()
const send = vi.fn()
const result = await handler(
{ sender: { send } },
{
skillName: 'linear-tickets',
context: 'linear-worktree',
discoveryTarget: { runtime: 'wsl', wslDistro: 'Ubuntu' }
}
)
expect(result).toMatchObject({
status: 'fallback',
reason: 'wsl-runtime'
})
expect(send).not.toHaveBeenCalled()
})
it('does not emit managed fallback events without a manual command', async () => {
const handler = getEnsureHandler()
const send = vi.fn()
discoverSkillsMock.mockResolvedValue({
skills: [
{
id: 'repo-linear-tickets',
name: 'linear-tickets',
description: null,
providers: ['agent-skills'],
sourceKind: 'repo',
sourceLabel: 'repo',
rootPath: '/workspace/current/.agents/skills',
directoryPath: '/workspace/current/.agents/skills/linear-tickets',
skillFilePath: '/workspace/current/.agents/skills/linear-tickets/SKILL.md',
installed: true,
fileCount: 1,
updatedAt: 1
}
],
sources: [],
scannedAt: 1
})
const result = await handler(
{ sender: { send } },
{
skillName: 'linear-tickets',
context: 'linear-worktree',
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
}
)
expect(result).toMatchObject({
status: 'fallback',
reason: 'project-install'
})
expect(send).not.toHaveBeenCalled()
})
})
+44 -2
View File
@@ -1,8 +1,15 @@
import { ipcMain } from 'electron'
import { BrowserWindow, ipcMain } from 'electron'
import type { Store } from '../persistence'
import { discoverSkills } from '../skills/discovery'
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills'
import type {
ManagedAgentSkillEnsureRequest,
ManagedAgentSkillEnsureResult,
SkillDiscoveryResult,
SkillDiscoveryTarget
} from '../../shared/skills'
import { shouldEmitManagedAgentSkillFallback } from '../../shared/skills'
import { getDefaultWslDistro, getWslHome } from '../wsl'
import { getManagedSkillUpdateCoordinator } from '../skills/managed-skill-updates'
type SkillDiscoveryRuntimeTarget =
| { runtime: 'host' }
@@ -54,4 +61,39 @@ export function registerSkillsHandlers(store: Store): void {
return discoverSkills({ repos: store.getRepos() })
}
)
ipcMain.handle(
'skills:ensureManagedReady',
async (
event,
request: ManagedAgentSkillEnsureRequest
): Promise<ManagedAgentSkillEnsureResult> => {
const result = await getManagedSkillUpdateCoordinator(store).ensureManagedReady(request)
if (shouldEmitManagedAgentSkillFallback(result)) {
event.sender.send('skills:managedFallback', result)
}
if (result.status === 'updated') {
sendManagedSkillUpdated(result)
}
return result
}
)
}
export function sendManagedSkillFallback(result: ManagedAgentSkillEnsureResult): void {
if (!shouldEmitManagedAgentSkillFallback(result)) {
return
}
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send('skills:managedFallback', result)
}
}
export function sendManagedSkillUpdated(result: ManagedAgentSkillEnsureResult): void {
if (result.status !== 'updated') {
return
}
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send('skills:managedUpdated', result)
}
}
@@ -143,6 +143,64 @@ describe('RpcDispatcher feature interactions', () => {
expect(runtime.recordFeatureInteraction).toHaveBeenCalledWith('agent-orchestration')
})
it('nudges managed skills for runtime feature use without blocking responses', async () => {
const runtime = makeRuntime()
const nudgeManagedSkill = vi.fn(async () => {
throw new Error('setup check failed')
})
const dispatcher = new RpcDispatcher({ runtime, methods: METHODS, nudgeManagedSkill })
const response = await dispatcher.dispatch(makeRequest('computer.click'))
await Promise.resolve()
expect('error' in response ? response.error : undefined).toBeUndefined()
expect(nudgeManagedSkill).toHaveBeenCalledWith({
skillName: 'computer-use',
context: 'agent-computer-use'
})
})
it('passes the configured managed-skill discovery target to runtime nudges', async () => {
const runtime = makeRuntime()
const nudgeManagedSkill = vi.fn()
const dispatcher = new RpcDispatcher({
runtime,
methods: METHODS,
nudgeManagedSkill,
managedSkillDiscoveryTarget: { runtime: 'wsl', wslDistro: 'Ubuntu' }
})
await dispatcher.dispatch(makeRequest('orchestration.send'))
await Promise.resolve()
expect(nudgeManagedSkill).toHaveBeenCalledWith({
skillName: 'orchestration',
context: 'agent-orchestration',
discoveryTarget: { runtime: 'wsl', wslDistro: 'Ubuntu' }
})
})
it('marks runtime managed-skill nudges remote without passing host discovery targets', async () => {
const runtime = makeRuntime()
const nudgeManagedSkill = vi.fn()
const dispatcher = new RpcDispatcher({
runtime,
methods: METHODS,
nudgeManagedSkill,
managedSkillDiscoveryTarget: { runtime: 'host' },
managedSkillRemoteRuntime: true
})
await dispatcher.dispatch(makeRequest('orchestration.send'))
await Promise.resolve()
expect(nudgeManagedSkill).toHaveBeenCalledWith({
skillName: 'orchestration',
context: 'agent-orchestration',
remoteRuntime: true
})
})
it('keeps setup and cookie import separate from actual runtime use', async () => {
const runtime = makeRuntime()
const dispatcher = new RpcDispatcher({ runtime, methods: METHODS })
+43 -47
View File
@@ -16,7 +16,7 @@ import {
} from './core'
import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol'
import type { FeatureInteractionId } from '../../../shared/feature-interactions'
import { isBrowserPaneUiRuntimeRpcParams } from '../../../shared/runtime-rpc-feature-interaction-source'
import type { SkillDiscoveryTarget } from '../../../shared/skills'
import {
computerErrorData,
errorResponse,
@@ -27,19 +27,39 @@ import {
} from './errors'
import { ALL_RPC_METHODS } from './methods'
import type { OrcaRuntimeService } from '../orca-runtime'
import {
getManagedSkillNudgeForFeatureInteraction,
type RuntimeManagedSkillNudgeHandler
} from './managed-skill-nudge'
import { getRuntimeFeatureInteractionId } from './runtime-feature-interaction-id'
export type DispatcherOptions = {
runtime: OrcaRuntimeService
methods?: readonly RpcAnyMethod[]
nudgeManagedSkill?: RuntimeManagedSkillNudgeHandler
managedSkillDiscoveryTarget?: SkillDiscoveryTarget
managedSkillRemoteRuntime?: boolean
}
export class RpcDispatcher {
private readonly runtime: OrcaRuntimeService
private readonly registry: RpcRegistry
private readonly nudgeManagedSkill: RuntimeManagedSkillNudgeHandler | undefined
private readonly managedSkillDiscoveryTarget: SkillDiscoveryTarget | undefined
private readonly managedSkillRemoteRuntime: boolean
constructor({ runtime, methods = ALL_RPC_METHODS }: DispatcherOptions) {
constructor({
runtime,
methods = ALL_RPC_METHODS,
nudgeManagedSkill,
managedSkillDiscoveryTarget,
managedSkillRemoteRuntime = false
}: DispatcherOptions) {
this.runtime = runtime
this.registry = buildRegistry(methods)
this.nudgeManagedSkill = nudgeManagedSkill
this.managedSkillDiscoveryTarget = managedSkillDiscoveryTarget
this.managedSkillRemoteRuntime = managedSkillRemoteRuntime
}
async dispatch(request: RpcRequest, options?: { signal?: AbortSignal }): Promise<RpcResponse> {
@@ -246,56 +266,32 @@ export class RpcDispatcher {
}
try {
this.runtime.recordFeatureInteraction(id)
this.nudgeManagedSkillForFeatureInteraction(id)
alreadyRecorded?.add(id)
} catch {
// Best-effort education state must not break runtime tools.
}
}
}
function getRuntimeFeatureInteractionId(
method: string,
result: unknown,
rawParams?: unknown
): FeatureInteractionId | null {
if (method === 'browser.profileImportFromBrowser') {
return hasBooleanResult(result, 'ok') ? 'cookie-import' : null
private nudgeManagedSkillForFeatureInteraction(id: FeatureInteractionId): void {
const nudge = getManagedSkillNudgeForFeatureInteraction(id)
if (!nudge || !this.nudgeManagedSkill) {
return
}
try {
void Promise.resolve(
this.nudgeManagedSkill({
...nudge,
// Why: remote runtimes must not reuse the desktop host skill target;
// V1 cannot safely inspect or update those installs.
...(this.managedSkillRemoteRuntime ? { remoteRuntime: true } : {}),
...(!this.managedSkillRemoteRuntime && this.managedSkillDiscoveryTarget
? { discoveryTarget: this.managedSkillDiscoveryTarget }
: {})
})
).catch(() => {})
} catch {
// Best-effort managed-skill maintenance must not break runtime tools.
}
}
if (method === 'browser.profileClearDefaultCookies') {
return hasBooleanResult(result, 'cleared') ? 'cookie-import' : null
}
if (method === 'browser.screencast.unsubscribe') {
return null
}
if (method.startsWith('browser.') && isBrowserPaneUiRuntimeRpcParams(rawParams)) {
return null
}
if (method.startsWith('browser.') && !method.startsWith('browser.profile')) {
return 'agent-browser-use'
}
if (method.startsWith('emulator.')) {
// Emulator commands are allowed from terminal/CLI (workspace-scoped, like other automation).
// Return null to indicate no special feature-interaction restriction (or add 'emulator-use' later).
return null
}
if (method === 'computer.permissions') {
return 'computer-use-setup'
}
if (
method.startsWith('computer.') &&
method !== 'computer.capabilities' &&
method !== 'computer.permissionsStatus'
) {
return 'computer-use'
}
if (method.startsWith('orchestration.')) {
return 'agent-orchestration'
}
return null
}
function hasBooleanResult(value: unknown, key: string): boolean {
return (
value !== null && typeof value === 'object' && (value as Record<string, unknown>)[key] === true
)
}
@@ -0,0 +1,37 @@
import {
COMPUTER_USE_SKILL_NAME,
ORCA_CLI_SKILL_NAME,
ORCHESTRATION_SKILL_NAME
} from '../../../shared/agent-feature-install-commands'
import type { FeatureInteractionId } from '../../../shared/feature-interactions'
import type {
ManagedAgentSkillContext,
ManagedAgentSkillName,
SkillDiscoveryTarget
} from '../../../shared/skills'
export type RuntimeManagedSkillNudge = {
skillName: ManagedAgentSkillName
context: ManagedAgentSkillContext
remoteRuntime?: boolean
discoveryTarget?: SkillDiscoveryTarget
}
export type RuntimeManagedSkillNudgeHandler = (
args: RuntimeManagedSkillNudge
) => void | Promise<void>
export function getManagedSkillNudgeForFeatureInteraction(
id: FeatureInteractionId
): RuntimeManagedSkillNudge | null {
if (id === 'agent-orchestration') {
return { skillName: ORCHESTRATION_SKILL_NAME, context: 'agent-orchestration' }
}
if (id === 'computer-use') {
return { skillName: COMPUTER_USE_SKILL_NAME, context: 'agent-computer-use' }
}
if (id === 'agent-browser-use') {
return { skillName: ORCA_CLI_SKILL_NAME, context: 'agent-orca-cli' }
}
return null
}
@@ -0,0 +1,47 @@
import type { FeatureInteractionId } from '../../../shared/feature-interactions'
import { isBrowserPaneUiRuntimeRpcParams } from '../../../shared/runtime-rpc-feature-interaction-source'
export function getRuntimeFeatureInteractionId(
method: string,
result: unknown,
rawParams?: unknown
): FeatureInteractionId | null {
if (method === 'browser.profileImportFromBrowser') {
return hasBooleanResult(result, 'ok') ? 'cookie-import' : null
}
if (method === 'browser.profileClearDefaultCookies') {
return hasBooleanResult(result, 'cleared') ? 'cookie-import' : null
}
if (method === 'browser.screencast.unsubscribe') {
return null
}
if (method.startsWith('browser.') && isBrowserPaneUiRuntimeRpcParams(rawParams)) {
return null
}
if (method.startsWith('browser.') && !method.startsWith('browser.profile')) {
return 'agent-browser-use'
}
if (method.startsWith('emulator.')) {
return null
}
if (method === 'computer.permissions') {
return 'computer-use-setup'
}
if (
method.startsWith('computer.') &&
method !== 'computer.capabilities' &&
method !== 'computer.permissionsStatus'
) {
return 'computer-use'
}
if (method.startsWith('orchestration.')) {
return 'agent-orchestration'
}
return null
}
function hasBooleanResult(value: unknown, key: string): boolean {
return (
value !== null && typeof value === 'object' && (value as Record<string, unknown>)[key] === true
)
}
+23 -2
View File
@@ -12,6 +12,11 @@ import type { OrcaRuntimeService } from './orca-runtime'
import { writeRuntimeMetadata } from './runtime-metadata'
import { RpcDispatcher } from './rpc/dispatcher'
import type { RpcRequest, RpcResponse } from './rpc/core'
import type {
ManagedAgentSkillContext,
ManagedAgentSkillName,
SkillDiscoveryTarget
} from '../../shared/skills'
import { errorResponse } from './rpc/errors'
import type { RpcMessageContext, RpcTransport } from './rpc/transport'
import { UnixSocketTransport } from './rpc/unix-socket-transport'
@@ -42,6 +47,14 @@ type OrcaRuntimeRpcServerOptions = {
// fence or flood the socket with keepalive frames.
keepaliveIntervalMs?: number
longPollCap?: number
nudgeManagedSkill?: (args: {
skillName: ManagedAgentSkillName
context: ManagedAgentSkillContext
remoteRuntime?: boolean
discoveryTarget?: SkillDiscoveryTarget
}) => void | Promise<void>
managedSkillDiscoveryTarget?: SkillDiscoveryTarget
managedSkillRemoteRuntime?: boolean
}
// Why: after 10 s of a pending dispatch we emit a tiny `{"_keepalive":true}`
@@ -407,10 +420,18 @@ export class OrcaRuntimeRpcServer {
wsPort = DEFAULT_WS_PORT,
webClientRoot,
keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS,
longPollCap = LONG_POLL_CAP
longPollCap = LONG_POLL_CAP,
nudgeManagedSkill,
managedSkillDiscoveryTarget,
managedSkillRemoteRuntime
}: OrcaRuntimeRpcServerOptions) {
this.runtime = runtime
this.dispatcher = new RpcDispatcher({ runtime })
this.dispatcher = new RpcDispatcher({
runtime,
nudgeManagedSkill,
managedSkillDiscoveryTarget,
managedSkillRemoteRuntime
})
this.userDataPath = userDataPath
this.pid = pid
this.platform = platform
+36 -2
View File
@@ -1,5 +1,5 @@
import type { Dirent } from 'node:fs'
import { open, readdir, realpath, stat } from 'node:fs/promises'
import { lstat, open, readdir, realpath, stat } from 'node:fs/promises'
import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path'
import { summarizeSkillMarkdown } from '../../shared/skill-metadata'
import type { Repo } from '../../shared/types'
@@ -203,13 +203,43 @@ async function readSkillSummary(skillFilePath: string): Promise<{
}
}
async function readSkillPathMetadata(
directoryPath: string,
skillFilePath: string
): Promise<{
realDirectoryPath: string | null
directoryIsSymlink: boolean
realSkillFilePath: string | null
skillFileIsSymlink: boolean
}> {
const [directoryLink, skillFileLink, realDirectoryPath, realSkillFilePath] = await Promise.all([
lstat(directoryPath)
.then((pathStat) => pathStat.isSymbolicLink())
.catch(() => false),
lstat(skillFilePath)
.then((pathStat) => pathStat.isSymbolicLink())
.catch(() => false),
realpath(directoryPath).catch(() => null),
realpath(skillFilePath).catch(() => null)
])
return {
realDirectoryPath,
directoryIsSymlink: directoryLink,
realSkillFilePath,
skillFileIsSymlink: skillFileLink
}
}
async function scanRoot(root: SkillScanRoot): Promise<DiscoveredSkill[]> {
const maxDepth = root.sourceKind === 'plugin' ? 9 : 4
const skillFiles = await findSkillFiles(root.path, maxDepth)
const skills = await Promise.all(
skillFiles.map(async (skillFilePath) => {
const directoryPath = dirname(skillFilePath)
const summary = await readSkillSummary(skillFilePath)
const [summary, pathMetadata] = await Promise.all([
readSkillSummary(skillFilePath),
readSkillPathMetadata(directoryPath, skillFilePath)
])
const sourceKind = sourceKindForSkill(root, skillFilePath)
return {
id: stablePathId(skillFilePath),
@@ -220,7 +250,11 @@ async function scanRoot(root: SkillScanRoot): Promise<DiscoveredSkill[]> {
sourceLabel: sourceLabelForSkill(root, sourceKind),
rootPath: root.path,
directoryPath,
realDirectoryPath: pathMetadata.realDirectoryPath,
directoryIsSymlink: pathMetadata.directoryIsSymlink,
skillFilePath,
realSkillFilePath: pathMetadata.realSkillFilePath,
skillFileIsSymlink: pathMetadata.skillFileIsSymlink,
installed: true,
fileCount: await countFiles(directoryPath),
updatedAt: summary.updatedAt
@@ -0,0 +1,17 @@
import { basename } from 'node:path'
import type { DiscoveredSkill, ManagedAgentSkillName } from '../../shared/skills'
export function isDiscoveredManagedSkill(
skill: DiscoveredSkill,
skillName: ManagedAgentSkillName
): boolean {
const expected = normalizeSkillName(skillName)
return (
normalizeSkillName(skill.name) === expected ||
normalizeSkillName(basename(skill.directoryPath)) === expected
)
}
function normalizeSkillName(value: string): string {
return value.trim().toLowerCase()
}
@@ -0,0 +1,50 @@
import { join } from 'node:path'
import type { ManagedAgentSkillName, SkillDiscoveryResult } from '../../shared/skills'
import { isDiscoveredManagedSkill } from './managed-skill-discovery-candidate'
import {
isRelevantManagedProjectCandidate,
normalizePathForManagedSkillKey
} from './managed-skill-update-cache-key'
type ManagedSkillDiscoveryCandidate = SkillDiscoveryResult['skills'][number]
export type ManagedSkillDiscoverySelection = {
homeCandidates: ManagedSkillDiscoveryCandidate[]
allRepoCandidates: ManagedSkillDiscoveryCandidate[]
repoCandidates: ManagedSkillDiscoveryCandidate[]
bundledOrPluginCandidates: ManagedSkillDiscoveryCandidate[]
}
export function selectManagedSkillDiscoveryCandidates(args: {
discovery: SkillDiscoveryResult
homeDir: string
projectRootPath?: string | null
skillName: ManagedAgentSkillName
}): ManagedSkillDiscoverySelection {
const candidates = args.discovery.skills.filter((skill) =>
isDiscoveredManagedSkill(skill, args.skillName)
)
const allRepoCandidates = candidates.filter((skill) => skill.sourceKind === 'repo')
return {
homeCandidates: candidates.filter((skill) => isManagedGlobalHomeCandidate(skill, args.homeDir)),
allRepoCandidates,
repoCandidates: allRepoCandidates.filter((skill) =>
isRelevantManagedProjectCandidate(skill.rootPath, args.projectRootPath)
),
bundledOrPluginCandidates: candidates.filter(
(skill) => skill.sourceKind === 'bundled' || skill.sourceKind === 'plugin'
)
}
}
function isManagedGlobalHomeCandidate(
skill: ManagedSkillDiscoveryCandidate,
homeDir: string
): boolean {
return (
skill.sourceKind === 'home' &&
skill.providers.includes('agent-skills') &&
normalizePathForManagedSkillKey(skill.rootPath) ===
normalizePathForManagedSkillKey(join(homeDir, '.agents', 'skills'))
)
}
@@ -0,0 +1,51 @@
import type {
ManagedAgentSkillEnsureRequest,
ManagedAgentSkillEnsureResult,
ManagedAgentSkillFallback,
ManagedAgentSkillFallbackReason,
ManagedAgentSkillManualCommand,
ManagedAgentSkillRuntime,
ManagedAgentSkillScope
} from '../../shared/skills'
import { getManagedSkillFallbackMessage } from './managed-skill-fallback-message'
export function buildManagedSkillReadyResult(
request: ManagedAgentSkillEnsureRequest
): ManagedAgentSkillEnsureResult {
return {
status: 'ready',
skillName: request.skillName,
context: request.context,
runtime: 'host',
scope: 'global'
}
}
export function buildManagedSkillFallback(args: {
request: ManagedAgentSkillEnsureRequest
reason: ManagedAgentSkillFallbackReason
runtime: ManagedAgentSkillRuntime
distro?: string | null
scope: ManagedAgentSkillScope
manualCommand?: ManagedAgentSkillManualCommand
}): ManagedAgentSkillFallback {
return {
status: 'fallback',
skillName: args.request.skillName,
context: args.request.context,
runtime: args.runtime,
distro: args.distro,
scope: args.scope,
reason: args.reason,
uiKey: [
args.runtime,
args.distro ?? '',
args.request.skillName,
args.request.context,
args.request.discoveryTarget?.projectRootPath ?? ''
].join(':'),
message: getManagedSkillFallbackMessage(args.reason),
manualCommand: args.manualCommand,
request: args.request
}
}
@@ -0,0 +1,48 @@
import type { ManagedAgentSkillFallbackReason } from '../../shared/skills'
export function getManagedSkillFallbackMessage(reason: ManagedAgentSkillFallbackReason): string {
switch (reason) {
case 'target-required':
return 'Orca could not resolve the runtime that should read this skill.'
case 'unsupported-skill':
return 'This is not an Orca-managed agent skill.'
case 'repair-required-runtime':
return 'The selected runtime needs repair before Orca can inspect its skills.'
case 'remote-runtime':
return 'Remote runtimes are not updated in the background.'
case 'wsl-runtime':
return 'WSL skill updates are manual in this version.'
case 'missing-install':
return 'The managed skill is not installed in the selected runtime.'
case 'project-install':
return 'Project-scoped skills are not updated in the background.'
case 'ambiguous-install':
return 'Orca found both global and project-scoped copies of this skill.'
case 'bundled-or-plugin-install':
return 'Bundled and plugin-cache skills are not mutated by Orca.'
case 'symlinked-global-install':
return 'Symlinked global skills are not safe for background updates yet.'
case 'unsupported-cli-contract':
return 'This build does not include the verified skills CLI update contract.'
case 'expected-hash-missing':
return 'This build does not include an expected hash for this managed skill.'
case 'lockfile-missing':
return 'Orca could not find the global skills lockfile.'
case 'lockfile-malformed':
return 'The global skills lockfile could not be parsed.'
case 'lockfile-unsupported-schema':
return 'The global skills lockfile uses an unsupported schema version.'
case 'lock-entry-missing':
return 'The global skills lockfile does not track this skill.'
case 'lock-entry-unmanaged-source':
return 'The installed skill is not tracked as Orca-managed source.'
case 'background-update-disabled':
return 'Automatic skill updates are turned off, so this update needs manual review.'
case 'cooldown':
return 'Orca recently tried this managed-skill check and is cooling down.'
case 'update-failed':
return 'The managed-skill update command failed.'
case 'update-timeout':
return 'The managed-skill update command timed out.'
}
}
@@ -0,0 +1,94 @@
import type {
ManagedAgentSkillManualCommand,
ManagedAgentSkillName,
ManagedAgentSkillScope,
SkillDiscoveryResult
} from '../../shared/skills'
import { selectManagedSkillDiscoveryCandidates } from './managed-skill-discovery-selection'
import { buildManagedSkillManualCommand } from './managed-skill-update-contract'
type ManagedSkillGlobalCandidate = SkillDiscoveryResult['skills'][number]
type ManagedSkillCandidateFallback = {
reason:
| 'ambiguous-install'
| 'bundled-or-plugin-install'
| 'missing-install'
| 'project-install'
| 'symlinked-global-install'
scope: ManagedAgentSkillScope
manualCommand?: ManagedAgentSkillManualCommand
}
export type ManagedSkillGlobalCandidateDecision =
| { status: 'candidate'; candidate: ManagedSkillGlobalCandidate }
| { status: 'fallback'; fallback: ManagedSkillCandidateFallback }
export function selectSingleGlobalManagedSkillCandidate(args: {
discovery: SkillDiscoveryResult
homeDir: string
projectRootPath?: string | null
skillName: ManagedAgentSkillName
}): ManagedSkillGlobalCandidateDecision {
const { homeCandidates, allRepoCandidates, repoCandidates, bundledOrPluginCandidates } =
selectManagedSkillDiscoveryCandidates(args)
if (
homeCandidates.length === 0 &&
allRepoCandidates.length === 0 &&
bundledOrPluginCandidates.length === 0
) {
return {
status: 'fallback',
fallback: {
reason: 'missing-install',
scope: 'missing',
manualCommand: buildManagedSkillManualCommand('install', args.skillName)
}
}
}
if (repoCandidates.length > 0 && homeCandidates.length > 0) {
return {
status: 'fallback',
fallback: { reason: 'ambiguous-install', scope: 'project' }
}
}
if (!args.projectRootPath && allRepoCandidates.length > 0) {
return {
status: 'fallback',
fallback: {
reason: homeCandidates.length > 0 ? 'ambiguous-install' : 'project-install',
scope: 'project'
}
}
}
if (repoCandidates.length > 0) {
return {
status: 'fallback',
fallback: { reason: 'project-install', scope: 'project' }
}
}
if (bundledOrPluginCandidates.length > 0 || homeCandidates.length !== 1) {
return {
status: 'fallback',
fallback: {
reason: 'bundled-or-plugin-install',
scope:
bundledOrPluginCandidates[0]?.sourceKind === 'plugin'
? 'plugin'
: bundledOrPluginCandidates[0]?.sourceKind === 'bundled'
? 'bundled'
: 'global'
}
}
}
const candidate = homeCandidates[0]
if (candidate.directoryIsSymlink || candidate.skillFileIsSymlink) {
return {
status: 'fallback',
fallback: { reason: 'symlinked-global-install', scope: 'global' }
}
}
return { status: 'candidate', candidate }
}
+70
View File
@@ -0,0 +1,70 @@
import { join } from 'node:path'
import { ORCA_SKILLS_REPOSITORY_URL } from '../../shared/agent-feature-install-commands'
import type { ManagedAgentSkillFallbackReason, ManagedAgentSkillName } from '../../shared/skills'
type ManagedSkillLockEntry = {
source?: unknown
sourceType?: unknown
sourceUrl?: unknown
skillPath?: unknown
skillFolderHash?: unknown
}
const SUPPORTED_LOCKFILE_SCHEMA_VERSION = 3
export async function readManagedSkillLockEntry(args: {
homeDir: string
readTextFile: (path: string) => Promise<string>
skillName: ManagedAgentSkillName
}): Promise<
| { ok: true; entry: { skillFolderHash: string } }
| { ok: false; reason: ManagedAgentSkillFallbackReason }
> {
let parsed: unknown
try {
parsed = JSON.parse(await args.readTextFile(join(args.homeDir, '.agents', '.skill-lock.json')))
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { ok: false, reason: 'lockfile-missing' }
}
return { ok: false, reason: 'lockfile-malformed' }
}
if (!parsed || typeof parsed !== 'object') {
return { ok: false, reason: 'lockfile-malformed' }
}
const lockfile = parsed as { version?: unknown; skills?: unknown }
if (lockfile.version !== SUPPORTED_LOCKFILE_SCHEMA_VERSION) {
return { ok: false, reason: 'lockfile-unsupported-schema' }
}
if (!lockfile.skills || typeof lockfile.skills !== 'object') {
return { ok: false, reason: 'lockfile-malformed' }
}
const entry = (lockfile.skills as Record<string, ManagedSkillLockEntry>)[args.skillName]
if (!entry) {
return { ok: false, reason: 'lock-entry-missing' }
}
if (!isManagedLockEntryForSkill(entry, args.skillName)) {
return { ok: false, reason: 'lock-entry-unmanaged-source' }
}
return { ok: true, entry: { skillFolderHash: entry.skillFolderHash } }
}
function isManagedLockEntryForSkill(
entry: ManagedSkillLockEntry,
skillName: ManagedAgentSkillName
): entry is ManagedSkillLockEntry & { skillFolderHash: string } {
return (
entry.source === 'stablyai/orca' &&
entry.sourceType === 'github' &&
typeof entry.sourceUrl === 'string' &&
normalizeRepositoryUrl(entry.sourceUrl) ===
normalizeRepositoryUrl(ORCA_SKILLS_REPOSITORY_URL) &&
entry.skillPath === `skills/${skillName}/SKILL.md` &&
typeof entry.skillFolderHash === 'string' &&
entry.skillFolderHash.length > 0
)
}
function normalizeRepositoryUrl(value: string): string {
return value.replace(/\.git$/, '')
}
+47
View File
@@ -0,0 +1,47 @@
import type {
ManagedAgentSkillEnsureRequest,
ManagedAgentSkillFallbackReason,
ManagedAgentSkillRuntime
} from '../../shared/skills'
export type ResolvedManagedSkillTarget =
| {
ok: true
runtime: Extract<ManagedAgentSkillRuntime, 'host' | 'wsl'>
distro?: string | null
}
| {
ok: false
runtime: ManagedAgentSkillRuntime
distro?: string | null
reason: ManagedAgentSkillFallbackReason
}
export function resolveManagedSkillTarget(
request: ManagedAgentSkillEnsureRequest
): ResolvedManagedSkillTarget {
if (request.remoteRuntime) {
return { ok: false, runtime: 'remote', reason: 'remote-runtime' }
}
const target = request.discoveryTarget
if (!target) {
return { ok: false, runtime: 'unknown', reason: 'target-required' }
}
if (target.projectRuntime?.status === 'repair-required') {
return { ok: false, runtime: 'wsl', reason: 'repair-required-runtime' }
}
if (target.projectRuntime?.status === 'resolved') {
const runtime = target.projectRuntime.runtime
if (runtime.kind === 'wsl') {
return { ok: true, runtime: 'wsl', distro: runtime.distro }
}
return { ok: true, runtime: 'host' }
}
if (target.runtime === 'wsl') {
return { ok: true, runtime: 'wsl', distro: target.wslDistro ?? null }
}
if (target.runtime === 'host') {
return { ok: true, runtime: 'host' }
}
return { ok: false, runtime: 'unknown', reason: 'target-required' }
}
@@ -0,0 +1,56 @@
import { isAbsolute, normalize, relative, sep } from 'node:path'
import type {
ManagedAgentSkillEnsureRequest,
ManagedAgentSkillEnsureResult
} from '../../shared/skills'
import type { ExpectedManagedSkillRevision } from './managed-skill-update-contract'
export function shouldCooldownFallback(result: ManagedAgentSkillEnsureResult): boolean {
return (
result.status === 'fallback' &&
result.reason !== 'cooldown' &&
result.reason !== 'background-update-disabled'
)
}
export function makeManagedSkillSuccessCacheKey(args: {
appVersion: string
request: ManagedAgentSkillEnsureRequest
expected: ExpectedManagedSkillRevision | undefined
}): string {
return [
args.appVersion,
'host',
'',
'global',
normalizeManagedSkillKeyPart(args.request.context),
normalizeManagedSkillKeyPart(args.request.discoveryTarget?.projectRootPath),
args.request.skillName,
args.expected?.expectedHash ?? 'missing-expected-hash',
args.expected?.expectedSourceRef ?? 'missing-source-ref'
].join(':')
}
export function normalizeManagedSkillKeyPart(value: string | null | undefined): string {
return value ? normalizePathForManagedSkillKey(value) : ''
}
export function normalizePathForManagedSkillKey(value: string): string {
const normalized = normalize(value)
const stripped = normalized.endsWith(sep) ? normalized.slice(0, -1) : normalized
return process.platform === 'win32' ? stripped.toLowerCase() : stripped
}
export function isRelevantManagedProjectCandidate(
rootPath: string,
projectRootPath: string | null | undefined
): boolean {
if (!projectRootPath) {
return false
}
const rel = relative(projectRootPath, rootPath)
if (!rel) {
return true
}
return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)
}
@@ -0,0 +1,49 @@
import {
buildAgentFeatureSkillInstallCommand,
buildAgentFeatureSkillUpdateCommand,
COMPUTER_USE_SKILL_NAME,
LINEAR_TICKETS_SKILL_NAME,
ORCA_CLI_SKILL_NAME,
ORCHESTRATION_SKILL_NAME
} from '../../shared/agent-feature-install-commands'
import type { ManagedAgentSkillManualCommand, ManagedAgentSkillName } from '../../shared/skills'
export type ExpectedManagedSkillRevision = {
expectedHash: string
expectedSourceRef: string
skillsPackageVersion: string
}
export const EXPECTED_MANAGED_SKILL_REVISIONS: Partial<
Record<ManagedAgentSkillName, ExpectedManagedSkillRevision>
> = {}
const MANAGED_SKILL_NAMES = new Set<string>([
COMPUTER_USE_SKILL_NAME,
LINEAR_TICKETS_SKILL_NAME,
ORCA_CLI_SKILL_NAME,
ORCHESTRATION_SKILL_NAME
])
export function isManagedAgentSkillName(value: string): value is ManagedAgentSkillName {
return MANAGED_SKILL_NAMES.has(value)
}
export function isImmutableSourceRef(value: string): boolean {
return /^[a-f0-9]{40}$/i.test(value)
}
export function buildManagedSkillManualCommand(
kind: ManagedAgentSkillManualCommand['kind'],
skillName: ManagedAgentSkillName
): ManagedAgentSkillManualCommand {
return {
kind,
runtime: 'host',
scope: 'global',
command:
kind === 'install'
? buildAgentFeatureSkillInstallCommand([skillName])
: buildAgentFeatureSkillUpdateCommand(skillName)
}
}
@@ -0,0 +1,638 @@
import { describe, expect, it, vi } from 'vitest'
import { homedir } from 'node:os'
import { join } from 'node:path'
import {
LINEAR_TICKETS_SKILL_NAME,
ORCHESTRATION_SKILL_NAME
} from '../../shared/agent-feature-install-commands'
import type { DiscoveredSkill, SkillDiscoveryResult } from '../../shared/skills'
import { ManagedSkillUpdateCoordinator } from './managed-skill-updates'
const TEST_MANAGED_HOME_ROOT = join(homedir(), '.agents', 'skills')
function discoveredSkill(
patch: Partial<DiscoveredSkill> & Pick<DiscoveredSkill, 'name' | 'sourceKind'>
): DiscoveredSkill {
const rootPath = patch.rootPath ?? TEST_MANAGED_HOME_ROOT
const directoryPath = patch.directoryPath ?? join(rootPath, patch.name)
return {
id: patch.id ?? `${patch.sourceKind}-${patch.name}`,
name: patch.name,
description: patch.description ?? null,
providers: patch.providers ?? ['agent-skills'],
sourceKind: patch.sourceKind,
sourceLabel: patch.sourceLabel ?? patch.sourceKind,
rootPath,
directoryPath,
realDirectoryPath: patch.realDirectoryPath ?? directoryPath,
directoryIsSymlink: patch.directoryIsSymlink ?? false,
skillFilePath: patch.skillFilePath ?? join(directoryPath, 'SKILL.md'),
realSkillFilePath: patch.realSkillFilePath ?? join(directoryPath, 'SKILL.md'),
skillFileIsSymlink: patch.skillFileIsSymlink ?? false,
installed: patch.installed ?? true,
fileCount: patch.fileCount ?? 1,
updatedAt: patch.updatedAt ?? 1
}
}
function discovery(skills: DiscoveredSkill[]): SkillDiscoveryResult {
return { skills, sources: [], scannedAt: 1 }
}
function lockfile(skillFolderHash: string): string {
return JSON.stringify({
version: 3,
skills: {
[ORCHESTRATION_SKILL_NAME]: {
source: 'stablyai/orca',
sourceType: 'github',
sourceUrl: 'https://github.com/stablyai/orca.git',
skillPath: `skills/${ORCHESTRATION_SKILL_NAME}/SKILL.md`,
skillFolderHash
}
}
})
}
describe('ManagedSkillUpdateCoordinator', () => {
it('dedupes concurrent missing-install checks before discovery', async () => {
const discoverHostSkills = vi.fn(async () => discovery([]))
const coordinator = new ManagedSkillUpdateCoordinator({ discoverHostSkills })
const [first, second] = await Promise.all([
coordinator.ensureManagedReady({
skillName: LINEAR_TICKETS_SKILL_NAME,
context: 'linear-worktree',
discoveryTarget: { runtime: 'host' }
}),
coordinator.ensureManagedReady({
skillName: LINEAR_TICKETS_SKILL_NAME,
context: 'linear-worktree',
discoveryTarget: { runtime: 'host' }
})
])
expect(discoverHostSkills).toHaveBeenCalledTimes(1)
expect(first.status).toBe('fallback')
expect(second.status).toBe('fallback')
expect(first.status === 'fallback' ? first.reason : null).toBe('missing-install')
expect(first.status === 'fallback' ? first.manualCommand?.kind : null).toBe('install')
})
it('cooldowns repeated missing-install checks after the first fallback', async () => {
let now = 100
const discoverHostSkills = vi.fn(async () => discovery([]))
const coordinator = new ManagedSkillUpdateCoordinator({
cooldownMs: 1_000,
now: () => now,
discoverHostSkills
})
const first = await coordinator.ensureManagedReady({
skillName: LINEAR_TICKETS_SKILL_NAME,
context: 'linear-worktree',
discoveryTarget: { runtime: 'host' }
})
now = 200
const second = await coordinator.ensureManagedReady({
skillName: LINEAR_TICKETS_SKILL_NAME,
context: 'linear-worktree',
discoveryTarget: { runtime: 'host' }
})
expect(first.status === 'fallback' ? first.reason : null).toBe('missing-install')
expect(second.status === 'fallback' ? second.reason : null).toBe('cooldown')
expect(discoverHostSkills).toHaveBeenCalledTimes(1)
})
it('lets explicit re-checks bypass fallback cooldown', async () => {
let now = 100
const discoverHostSkills = vi.fn(async () => discovery([]))
const coordinator = new ManagedSkillUpdateCoordinator({
cooldownMs: 1_000,
now: () => now,
discoverHostSkills
})
await coordinator.ensureManagedReady({
skillName: LINEAR_TICKETS_SKILL_NAME,
context: 'linear-worktree',
discoveryTarget: { runtime: 'host' }
})
now = 200
const forced = await coordinator.ensureManagedReady({
skillName: LINEAR_TICKETS_SKILL_NAME,
context: 'linear-worktree',
discoveryTarget: { runtime: 'host' },
force: true
})
expect(forced.status === 'fallback' ? forced.reason : null).toBe('missing-install')
expect(discoverHostSkills).toHaveBeenCalledTimes(2)
})
it('cooldowns repeated remote-runtime target fallbacks', async () => {
let now = 100
const coordinator = new ManagedSkillUpdateCoordinator({
cooldownMs: 1_000,
now: () => now
})
const request = {
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration' as const,
remoteRuntime: true
} as const
const first = await coordinator.ensureManagedReady(request)
now = 200
const second = await coordinator.ensureManagedReady(request)
expect(first.status === 'fallback' ? first.reason : null).toBe('remote-runtime')
expect(second.status === 'fallback' ? second.reason : null).toBe('cooldown')
})
it('cooldowns repeated target-required fallbacks', async () => {
let now = 100
const coordinator = new ManagedSkillUpdateCoordinator({
cooldownMs: 1_000,
now: () => now
})
const request = {
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration' as const
} as const
const first = await coordinator.ensureManagedReady(request)
now = 200
const second = await coordinator.ensureManagedReady(request)
expect(first.status === 'fallback' ? first.reason : null).toBe('target-required')
expect(second.status === 'fallback' ? second.reason : null).toBe('cooldown')
})
it('falls back for project and symlinked global installs', async () => {
const projectCoordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () =>
discovery([
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'repo',
rootPath: '/workspace/current/.agents/skills',
directoryPath: '/workspace/current/.agents/skills/orchestration'
})
])
})
const symlinkCoordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () =>
discovery([
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'home',
directoryIsSymlink: true
})
])
})
const project = await projectCoordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
})
const symlink = await symlinkCoordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
expect(project.status === 'fallback' ? project.reason : null).toBe('project-install')
expect(symlink.status === 'fallback' ? symlink.reason : null).toBe('symlinked-global-install')
})
it('ignores project installs from unrelated repo roots', async () => {
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () =>
discovery([
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'home',
rootPath: TEST_MANAGED_HOME_ROOT
}),
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'repo',
rootPath: '/workspace/unrelated/.agents/skills',
directoryPath: '/workspace/unrelated/.agents/skills/orchestration'
})
])
})
const result = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
})
expect(result.status).toBe('ready')
})
it('falls back when the current project root has a project-scoped install', async () => {
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () =>
discovery([
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'home',
rootPath: TEST_MANAGED_HOME_ROOT
}),
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'repo',
rootPath: '/workspace/current/.agents/skills',
directoryPath: '/workspace/current/.agents/skills/orchestration'
})
])
})
const result = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
})
expect(result.status === 'fallback' ? result.reason : null).toBe('ambiguous-install')
})
it('does not emit a global install command when project scope exists but target is unknown', async () => {
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () =>
discovery([
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'repo',
rootPath: '/workspace/current/.agents/skills',
directoryPath: '/workspace/current/.agents/skills/orchestration'
})
])
})
const result = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
expect(result.status === 'fallback' ? result.reason : null).toBe('project-install')
expect(result.status === 'fallback' ? result.manualCommand : undefined).toBeUndefined()
})
it('passes the project root into host discovery', async () => {
const discoverHostSkills = vi.fn(async () => discovery([]))
const coordinator = new ManagedSkillUpdateCoordinator({ discoverHostSkills })
await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
})
expect(discoverHostSkills).toHaveBeenCalledWith('/workspace/current')
})
it('treats installed global skills as ready when the expected hash manifest is missing', async () => {
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () =>
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })])
})
const result = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
expect(result.status).toBe('ready')
})
it('does not treat Codex or Claude home skills as managed global installs', async () => {
const readTextFile = vi.fn(async () => lockfile('hash-1'))
const coordinator = new ManagedSkillUpdateCoordinator({
homeDir: () => '/home/alice',
discoverHostSkills: async () =>
discovery([
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'home',
providers: ['codex'],
rootPath: '/home/alice/.codex/skills',
directoryPath: '/home/alice/.codex/skills/orchestration'
})
]),
expectedRevisions: {
[ORCHESTRATION_SKILL_NAME]: {
expectedHash: 'hash-1',
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
skillsPackageVersion: '1.2.3'
}
},
readTextFile
})
const result = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
expect(result.status === 'fallback' ? result.reason : null).toBe('missing-install')
expect(result.status === 'fallback' ? result.manualCommand?.kind : null).toBe('install')
expect(readTextFile).not.toHaveBeenCalled()
})
it('returns ready when a verified manifest and lock hash already match', async () => {
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () =>
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })]),
expectedRevisions: {
[ORCHESTRATION_SKILL_NAME]: {
expectedHash: 'hash-1',
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
skillsPackageVersion: '1.2.3'
}
},
readTextFile: async () => lockfile('hash-1')
})
const result = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
expect(result.status).toBe('ready')
})
it('serves verified ready checks from the success cache without rediscovery', async () => {
const discoverHostSkills = vi.fn(async () =>
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })])
)
const readTextFile = vi.fn(async () => lockfile('hash-1'))
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills,
expectedRevisions: {
[ORCHESTRATION_SKILL_NAME]: {
expectedHash: 'hash-1',
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
skillsPackageVersion: '1.2.3'
}
},
readTextFile
})
const request = {
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration' as const,
discoveryTarget: { runtime: 'host' as const, projectRootPath: '/workspace/current' }
} as const
const first = await coordinator.ensureManagedReady(request)
const second = await coordinator.ensureManagedReady(request)
expect(first.status).toBe('ready')
expect(second.status).toBe('ready')
expect(discoverHostSkills).toHaveBeenCalledTimes(1)
expect(readTextFile).toHaveBeenCalledTimes(1)
})
it('keeps concurrent checks for different project roots independent', async () => {
const discoverHostSkills = vi.fn(async () =>
discovery([
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'home',
rootPath: TEST_MANAGED_HOME_ROOT
}),
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'repo',
rootPath: '/workspace/current/.agents/skills',
directoryPath: '/workspace/current/.agents/skills/orchestration'
})
])
)
const coordinator = new ManagedSkillUpdateCoordinator({ discoverHostSkills })
const [currentProject, otherProject] = await Promise.all([
coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
}),
coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/other' }
})
])
expect(currentProject.status === 'fallback' ? currentProject.reason : null).toBe(
'ambiguous-install'
)
expect(otherProject.status).toBe('ready')
expect(discoverHostSkills).toHaveBeenCalledTimes(2)
})
it('keys project fallback UI identity by project root', async () => {
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () =>
discovery([
discoveredSkill({
name: ORCHESTRATION_SKILL_NAME,
sourceKind: 'repo',
rootPath: '/workspace/current/.agents/skills',
directoryPath: '/workspace/current/.agents/skills/orchestration'
})
])
})
const first = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
})
const second = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/other' }
})
expect(first.status === 'fallback' ? first.uiKey : null).toContain('/workspace/current')
expect(second.status === 'fallback' ? second.uiKey : null).toContain('/workspace/other')
expect(
first.status === 'fallback' && second.status === 'fallback' ? first.uiKey : null
).not.toBe(second.status === 'fallback' ? second.uiKey : null)
})
it('reads the current global lockfile version field', async () => {
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () =>
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })]),
expectedRevisions: {
[ORCHESTRATION_SKILL_NAME]: {
expectedHash: 'hash-1',
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
skillsPackageVersion: '1.2.3'
}
},
readTextFile: async () =>
JSON.stringify({
version: 3,
skills: {
[ORCHESTRATION_SKILL_NAME]: {
source: 'stablyai/orca',
sourceType: 'github',
sourceUrl: 'https://github.com/stablyai/orca.git',
skillPath: `skills/${ORCHESTRATION_SKILL_NAME}/SKILL.md`,
skillFolderHash: 'hash-1'
}
}
})
})
const result = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
expect(result.status).toBe('ready')
})
it('returns a malformed fallback for non-object lockfile JSON', async () => {
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () =>
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })]),
expectedRevisions: {
[ORCHESTRATION_SKILL_NAME]: {
expectedHash: 'hash-1',
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
skillsPackageVersion: '1.2.3'
}
},
readTextFile: async () => 'null'
})
const result = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
expect(result.status === 'fallback' ? result.reason : null).toBe('lockfile-malformed')
})
it('falls back without cooldown when automatic updates are off', async () => {
let now = 100
const readTextFile = vi.fn(async () => lockfile('old-hash'))
const coordinator = new ManagedSkillUpdateCoordinator({
backgroundUpdatesEnabled: () => false,
cooldownMs: 1_000,
now: () => now,
discoverHostSkills: async () =>
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })]),
expectedRevisions: {
[ORCHESTRATION_SKILL_NAME]: {
expectedHash: 'hash-2',
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
skillsPackageVersion: '1.2.3'
}
},
readTextFile
})
const first = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
now = 200
const second = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
expect(first.status === 'fallback' ? first.reason : null).toBe('background-update-disabled')
expect(first.status === 'fallback' ? first.manualCommand?.kind : null).toBe('update')
expect(second.status === 'fallback' ? second.reason : null).toBe('background-update-disabled')
expect(second.status === 'fallback' ? second.manualCommand?.kind : null).toBe('update')
expect(readTextFile).toHaveBeenCalledTimes(2)
})
it('shows manual update after automatic update cooldown when the setting is turned off', async () => {
let now = 100
let backgroundUpdatesEnabled = true
const readTextFile = vi.fn(async () => lockfile('old-hash'))
const coordinator = new ManagedSkillUpdateCoordinator({
backgroundUpdatesEnabled: () => backgroundUpdatesEnabled,
cooldownMs: 1_000,
now: () => now,
discoverHostSkills: async () =>
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })]),
expectedRevisions: {
[ORCHESTRATION_SKILL_NAME]: {
expectedHash: 'hash-2',
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
skillsPackageVersion: '1.2.3'
}
},
readTextFile
})
const automaticAttempt = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
backgroundUpdatesEnabled = false
now = 200
const manualAttempt = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
expect(automaticAttempt.status === 'fallback' ? automaticAttempt.reason : null).toBe(
'unsupported-cli-contract'
)
expect(manualAttempt.status === 'fallback' ? manualAttempt.reason : null).toBe(
'background-update-disabled'
)
expect(manualAttempt.status === 'fallback' ? manualAttempt.manualCommand?.kind : null).toBe(
'update'
)
expect(readTextFile).toHaveBeenCalledTimes(2)
})
it('does not show the manual-update modal while automatic updates are enabled', async () => {
const coordinator = new ManagedSkillUpdateCoordinator({
discoverHostSkills: async () =>
discovery([discoveredSkill({ name: ORCHESTRATION_SKILL_NAME, sourceKind: 'home' })]),
expectedRevisions: {
[ORCHESTRATION_SKILL_NAME]: {
expectedHash: 'hash-2',
expectedSourceRef: '0123456789abcdef0123456789abcdef01234567',
skillsPackageVersion: '1.2.3'
}
},
readTextFile: async () => lockfile('old-hash')
})
const result = await coordinator.ensureManagedReady({
skillName: ORCHESTRATION_SKILL_NAME,
context: 'agent-orchestration',
discoveryTarget: { runtime: 'host' }
})
expect(result.status === 'fallback' ? result.reason : null).toBe('unsupported-cli-contract')
expect(result.status === 'fallback' ? result.manualCommand : undefined).toBeUndefined()
})
})
+313
View File
@@ -0,0 +1,313 @@
import { readFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import type {
ManagedAgentSkillEnsureRequest,
ManagedAgentSkillEnsureResult,
ManagedAgentSkillName,
SkillDiscoveryResult
} from '../../shared/skills'
import type { Store } from '../persistence'
import { discoverSkills } from './discovery'
import {
buildManagedSkillFallback,
buildManagedSkillReadyResult
} from './managed-skill-ensure-result'
import { selectSingleGlobalManagedSkillCandidate } from './managed-skill-global-candidate'
import { readManagedSkillLockEntry } from './managed-skill-lockfile'
import {
makeManagedSkillSuccessCacheKey,
normalizeManagedSkillKeyPart,
shouldCooldownFallback
} from './managed-skill-update-cache-key'
import {
buildManagedSkillManualCommand,
EXPECTED_MANAGED_SKILL_REVISIONS,
isImmutableSourceRef,
isManagedAgentSkillName,
type ExpectedManagedSkillRevision
} from './managed-skill-update-contract'
import { resolveManagedSkillTarget, type ResolvedManagedSkillTarget } from './managed-skill-target'
type ManagedSkillCoordinatorDeps = {
appVersion?: string
backgroundUpdatesEnabled?: () => boolean
cooldownMs?: number
discoverHostSkills?: (projectRootPath?: string | null) => Promise<SkillDiscoveryResult>
homeDir?: () => string
now?: () => number
readTextFile?: (path: string) => Promise<string>
expectedRevisions?: Partial<Record<ManagedAgentSkillName, ExpectedManagedSkillRevision>>
}
const DEFAULT_COOLDOWN_MS = 60_000
const coordinatorByStore = new WeakMap<Store, ManagedSkillUpdateCoordinator>()
export function getManagedSkillUpdateCoordinator(store: Store): ManagedSkillUpdateCoordinator {
const existing = coordinatorByStore.get(store)
if (existing) {
return existing
}
const coordinator = new ManagedSkillUpdateCoordinator({
appVersion: process.env.npm_package_version,
backgroundUpdatesEnabled: () =>
store.getSettings().managedAgentSkillBackgroundUpdatesEnabled !== false,
discoverHostSkills: (projectRootPath) =>
discoverSkills({
repos: store.getRepos(),
...(projectRootPath ? { cwd: projectRootPath } : {})
})
})
coordinatorByStore.set(store, coordinator)
return coordinator
}
export class ManagedSkillUpdateCoordinator {
private readonly appVersion: string
private readonly backgroundUpdatesEnabled: () => boolean
private readonly cooldownMs: number
private readonly discoverHostSkills: (
projectRootPath?: string | null
) => Promise<SkillDiscoveryResult>
private readonly homeDir: () => string
private readonly now: () => number
private readonly readTextFile: (path: string) => Promise<string>
private readonly expectedRevisions: Partial<
Record<ManagedAgentSkillName, ExpectedManagedSkillRevision>
>
private readonly inFlightByPreDiscoveryKey = new Map<
string,
Promise<ManagedAgentSkillEnsureResult>
>()
private readonly cooldownUntilByKey = new Map<string, number>()
private readonly successCache = new Set<string>()
constructor(deps: ManagedSkillCoordinatorDeps = {}) {
this.appVersion = deps.appVersion ?? 'unknown'
this.backgroundUpdatesEnabled = deps.backgroundUpdatesEnabled ?? (() => true)
this.cooldownMs = deps.cooldownMs ?? DEFAULT_COOLDOWN_MS
this.discoverHostSkills = deps.discoverHostSkills ?? (() => discoverSkills({ repos: [] }))
this.homeDir = deps.homeDir ?? homedir
this.now = deps.now ?? Date.now
this.readTextFile = deps.readTextFile ?? readFileUtf8
this.expectedRevisions = deps.expectedRevisions ?? EXPECTED_MANAGED_SKILL_REVISIONS
}
ensureManagedReady(
request: ManagedAgentSkillEnsureRequest
): Promise<ManagedAgentSkillEnsureResult> {
if (!isManagedAgentSkillName(request.skillName)) {
return Promise.resolve(
buildManagedSkillFallback({
request,
reason: 'unsupported-skill',
runtime: 'unknown',
scope: 'missing'
})
)
}
const target = resolveManagedSkillTarget(request)
if (!target.ok) {
const targetFallbackKey = [
this.appVersion,
target.runtime,
target.distro ?? '',
'target-fallback',
normalizeManagedSkillKeyPart(request.context),
normalizeManagedSkillKeyPart(request.discoveryTarget?.projectRootPath),
request.skillName,
target.reason
].join(':')
const cooldownUntil = this.cooldownUntilByKey.get(targetFallbackKey)
if (!request.force && cooldownUntil && cooldownUntil > this.now()) {
return Promise.resolve(
buildManagedSkillFallback({
request,
reason: 'cooldown',
runtime: target.runtime,
distro: target.distro,
scope: 'missing'
})
)
}
this.cooldownUntilByKey.set(targetFallbackKey, this.now() + this.cooldownMs)
return Promise.resolve(
buildManagedSkillFallback({
request,
reason: target.reason,
runtime: target.runtime,
distro: target.distro,
scope: 'missing'
})
)
}
const expected = this.expectedRevisions[request.skillName]
const successLookupKey = makeManagedSkillSuccessCacheKey({
appVersion: this.appVersion,
request,
expected
})
if (target.runtime === 'host' && this.successCache.has(successLookupKey)) {
return Promise.resolve(buildManagedSkillReadyResult(request))
}
const preDiscoveryKey = [
this.appVersion,
target.runtime,
target.distro ?? '',
'pre-discovery',
this.backgroundUpdatesEnabled() ? 'background-updates-on' : 'background-updates-off',
normalizeManagedSkillKeyPart(request.context),
normalizeManagedSkillKeyPart(request.discoveryTarget?.projectRootPath),
request.skillName,
expected?.expectedHash ?? 'missing-expected-hash',
expected?.expectedSourceRef ?? 'missing-source-ref'
].join(':')
const existing = this.inFlightByPreDiscoveryKey.get(preDiscoveryKey)
if (existing) {
return existing
}
const preDiscoveryCooldownUntil = this.cooldownUntilByKey.get(preDiscoveryKey)
if (!request.force && preDiscoveryCooldownUntil && preDiscoveryCooldownUntil > this.now()) {
return Promise.resolve(
buildManagedSkillFallback({
request,
reason: 'cooldown',
runtime: target.runtime,
distro: target.distro,
scope: 'missing'
})
)
}
const promise = this.evaluate(request, target, expected)
.then((result) => {
if (shouldCooldownFallback(result)) {
this.cooldownUntilByKey.set(preDiscoveryKey, this.now() + this.cooldownMs)
}
return result
})
.finally(() => {
if (this.inFlightByPreDiscoveryKey.get(preDiscoveryKey) === promise) {
this.inFlightByPreDiscoveryKey.delete(preDiscoveryKey)
}
})
this.inFlightByPreDiscoveryKey.set(preDiscoveryKey, promise)
return promise
}
private async evaluate(
request: ManagedAgentSkillEnsureRequest,
target: Extract<ResolvedManagedSkillTarget, { ok: true }>,
expected: ExpectedManagedSkillRevision | undefined
): Promise<ManagedAgentSkillEnsureResult> {
if (target.runtime === 'wsl') {
return buildManagedSkillFallback({
request,
reason: 'wsl-runtime',
runtime: 'wsl',
distro: target.distro,
scope: 'missing'
})
}
const discovery = await this.discoverHostSkills(request.discoveryTarget?.projectRootPath)
const globalCandidateDecision = selectSingleGlobalManagedSkillCandidate({
discovery,
homeDir: this.homeDir(),
projectRootPath: request.discoveryTarget?.projectRootPath,
skillName: request.skillName
})
if (globalCandidateDecision.status === 'fallback') {
return buildManagedSkillFallback({
request,
reason: globalCandidateDecision.fallback.reason,
runtime: 'host',
scope: globalCandidateDecision.fallback.scope,
manualCommand: globalCandidateDecision.fallback.manualCommand
})
}
if (!expected) {
// Why: without a pinned/hash manifest, V1 cannot prove staleness or offer a
// real update path, so installed global managed skills should not prompt.
const cacheKey = makeManagedSkillSuccessCacheKey({
appVersion: this.appVersion,
request,
expected
})
this.successCache.add(cacheKey)
return buildManagedSkillReadyResult(request)
}
if (!isImmutableSourceRef(expected.expectedSourceRef)) {
return buildManagedSkillFallback({
request,
reason: 'unsupported-cli-contract',
runtime: 'host',
scope: 'global'
})
}
const cacheKey = makeManagedSkillSuccessCacheKey({
appVersion: this.appVersion,
request,
expected
})
if (this.successCache.has(cacheKey)) {
return buildManagedSkillReadyResult(request)
}
const backgroundUpdatesEnabled = this.backgroundUpdatesEnabled()
const cooldownUntil = this.cooldownUntilByKey.get(cacheKey)
if (backgroundUpdatesEnabled && !request.force && cooldownUntil && cooldownUntil > this.now()) {
return buildManagedSkillFallback({
request,
reason: 'cooldown',
runtime: 'host',
scope: 'global'
})
}
const lockEntryResult = await readManagedSkillLockEntry({
homeDir: this.homeDir(),
readTextFile: this.readTextFile,
skillName: request.skillName
})
if (!lockEntryResult.ok) {
return buildManagedSkillFallback({
request,
reason: lockEntryResult.reason,
runtime: 'host',
scope: 'global'
})
}
if (lockEntryResult.entry.skillFolderHash === expected.expectedHash) {
this.successCache.add(cacheKey)
return buildManagedSkillReadyResult(request)
}
if (!backgroundUpdatesEnabled) {
return buildManagedSkillFallback({
request,
reason: 'background-update-disabled',
runtime: 'host',
scope: 'global',
manualCommand: buildManagedSkillManualCommand('update', request.skillName)
})
}
// Why: this tree does not ship the verified CLI hash/ref update contract
// yet, so stale installs must fall back instead of running blind `npx`.
this.cooldownUntilByKey.set(cacheKey, this.now() + this.cooldownMs)
return buildManagedSkillFallback({
request,
reason: 'unsupported-cli-contract',
runtime: 'host',
scope: 'global'
})
}
}
function readFileUtf8(path: string): Promise<string> {
return readFile(path, 'utf8')
}
+13 -1
View File
@@ -265,7 +265,14 @@ import type {
import type { ResolvedSourceControlAiGenerationParams } from '../shared/source-control-ai'
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 {
ManagedAgentSkillEnsureRequest,
ManagedAgentSkillEnsureResult,
ManagedAgentSkillFallback,
ManagedAgentSkillUpdated,
SkillDiscoveryResult,
SkillDiscoveryTarget
} from '../shared/skills'
import type {
CrashReportBreadcrumbData,
CrashReportRecord,
@@ -1903,6 +1910,11 @@ export type PreloadApi = {
}
skills: {
discover: (target?: SkillDiscoveryTarget) => Promise<SkillDiscoveryResult>
ensureManagedReady: (
request: ManagedAgentSkillEnsureRequest
) => Promise<ManagedAgentSkillEnsureResult>
onManagedFallback: (callback: (event: ManagedAgentSkillFallback) => void) => () => void
onManagedUpdated: (callback: (event: ManagedAgentSkillUpdated) => void) => () => void
}
pet: {
import: () => Promise<CustomPet | null>
+40 -2
View File
@@ -55,7 +55,14 @@ import type {
} from '../shared/terminal-custom-themes'
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 {
ManagedAgentSkillEnsureRequest,
ManagedAgentSkillEnsureResult,
ManagedAgentSkillFallback,
ManagedAgentSkillUpdated,
SkillDiscoveryResult,
SkillDiscoveryTarget
} from '../shared/skills'
import type {
RuntimeBrowserDriverState,
RuntimeMobileSessionTabMove,
@@ -1895,7 +1902,38 @@ const api = {
skills: {
discover: (target?: SkillDiscoveryTarget): Promise<SkillDiscoveryResult> =>
ipcRenderer.invoke('skills:discover', target)
ipcRenderer.invoke('skills:discover', target),
ensureManagedReady: (
request: ManagedAgentSkillEnsureRequest
): Promise<ManagedAgentSkillEnsureResult> =>
ipcRenderer.invoke('skills:ensureManagedReady', request),
onManagedFallback: (callback: (event: ManagedAgentSkillFallback) => void): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
event: ManagedAgentSkillFallback
): void => {
callback(event)
}
ipcRenderer.on('skills:managedFallback', listener)
return () => {
ipcRenderer.removeListener('skills:managedFallback', listener)
}
},
onManagedUpdated: (callback: (event: ManagedAgentSkillUpdated) => void): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
event: ManagedAgentSkillUpdated
): void => {
callback(event)
}
ipcRenderer.on('skills:managedUpdated', listener)
return () => {
ipcRenderer.removeListener('skills:managedUpdated', listener)
}
}
},
pet: {
+9
View File
@@ -55,6 +55,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 { ManagedAgentSkillSetupDialogHost } from './components/skills/ManagedAgentSkillSetupDialogHost'
import { TelemetryFirstLaunchSurface } from './components/TelemetryFirstLaunchSurface'
import { ZoomOverlay } from './components/ZoomOverlay'
import { onOnboardingReopened } from './components/onboarding/show-onboarding-event'
@@ -2596,6 +2597,14 @@ function App(): React.JSX.Element {
>
<RecentTabSwitcher />
</RecoverableRenderErrorBoundary>
<RecoverableRenderErrorBoundary
boundaryId="modal.managed-agent-skill-setup"
surface="modal"
resetKey={activeView}
compact
>
<ManagedAgentSkillSetupDialogHost />
</RecoverableRenderErrorBoundary>
</LinkRoutingPreferenceDialogProvider>
</ConfirmationDialogProvider>
</TooltipProvider>
@@ -119,7 +119,7 @@ describe('onboarding feature setup runner', () => {
expect(text).toBe(ALL_SKILL_INSTALL_COMMAND)
expect(text).toBe(
'npx skills add https://github.com/stablyai/orca --skill orca-cli computer-use orchestration linear-tickets --global'
'npx --yes skills add https://github.com/stablyai/orca --skill orca-cli computer-use orchestration linear-tickets --global --yes'
)
})
@@ -7,8 +7,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentSkillSetupPanel } from './AgentSkillSetupPanel'
import { TooltipProvider } from '../ui/tooltip'
const INSTALL_COMMAND = 'npx skills add https://github.com/stablyai/orca --skill orca-cli --global'
const UPDATE_COMMAND = 'npx skills update orca-cli --global'
const INSTALL_COMMAND =
'npx --yes skills add https://github.com/stablyai/orca --skill orca-cli --global --yes'
const UPDATE_COMMAND = 'npx --yes skills update orca-cli --global --yes'
const mocks = vi.hoisted(() => ({
clipboardWrite: vi.fn(),
@@ -10,6 +10,7 @@ import { useAppStore } from '../../store'
import { getAgentGeneratedTabTitlesTitle } from './agent-generated-tab-title-copy'
import { getAgentStatusHooksTitle } from './agent-status-hooks-copy'
import { getAgentAwakeDescription, getAgentAwakeTitle } from './agent-awake-copy'
import { getManagedAgentSkillBackgroundUpdatesTitle } from './managed-agent-skill-background-updates-copy'
import { AgentAwakeSetting } from './AgentAwakeSetting'
import {
AgentAvailabilityControl,
@@ -17,6 +18,7 @@ import {
AgentGeneratedTabTitlesSetting,
AgentStatusHooksSetting,
AgentsPane,
ManagedAgentSkillBackgroundUpdatesSetting,
getAgentsPaneSearchEntries,
buildAgentAvailabilitySettingsUpdate,
createAgentAvailabilityUpdateQueue
@@ -144,6 +146,10 @@ describe('AgentsPane', () => {
expect(markup).toContain(
'Keeps this computer and display awake while agents are working. Orca also asks this device to stay awake when the lid is closed, subject to its power policy.'
)
expect(markup).toContain('Allow verified Orca skill updates')
expect(markup).toContain(
'When Orca has verified install metadata and a safe update path, it can try managed agent skill updates in the background. Turn this off to review updates manually.'
)
expect(markup).toContain('aria-checked="false"')
})
@@ -223,6 +229,27 @@ describe('AgentsPane', () => {
})
})
it('toggles managed skill background updates with the next value', () => {
const updateSettings = vi.fn()
const element = ManagedAgentSkillBackgroundUpdatesSetting({
settings: {
...getDefaultSettings('/tmp'),
managedAgentSkillBackgroundUpdatesEnabled: true
},
updateSettings
})
const updatesSwitch = findSwitchRow(element, getManagedAgentSkillBackgroundUpdatesTitle())
expect(updatesSwitch.props.checked).toBe(true)
const onChange = updatesSwitch.props.onChange as () => void
onChange()
expect(updateSettings).toHaveBeenCalledWith({
managedAgentSkillBackgroundUpdatesEnabled: false
})
})
it('toggles generated tab titles with the next value', () => {
const updateSettings = vi.fn()
const element = AgentGeneratedTabTitlesSetting({
@@ -256,6 +283,12 @@ describe('AgentsPane', () => {
expect(matchesSettingsSearch('codex', getAgentsPaneSearchEntries())).toBe(true)
})
it('includes managed skill update search metadata', () => {
expect(matchesSettingsSearch('automatic', getAgentsPaneSearchEntries())).toBe(true)
expect(matchesSettingsSearch('manual', getAgentsPaneSearchEntries())).toBe(true)
expect(matchesSettingsSearch('skills', getAgentsPaneSearchEntries())).toBe(true)
})
it('includes generated title search metadata', () => {
expect(matchesSettingsSearch('generated title', getAgentsPaneSearchEntries())).toBe(true)
expect(matchesSettingsSearch('stable session', getAgentsPaneSearchEntries())).toBe(true)
@@ -17,6 +17,10 @@ import {
getAgentGeneratedTabTitlesTitle
} from './agent-generated-tab-title-copy'
import { getAgentStatusHooksDescription, getAgentStatusHooksTitle } from './agent-status-hooks-copy'
import {
getManagedAgentSkillBackgroundUpdatesDescription,
getManagedAgentSkillBackgroundUpdatesTitle
} from './managed-agent-skill-background-updates-copy'
import {
SettingsBadge,
SettingsSegmentedControl,
@@ -823,6 +827,11 @@ export function AgentsPane({ settings, updateSettings }: AgentsPaneProps): React
<AgentStatusHooksSetting settings={settings} updateSettings={updateSettings} />
<ManagedAgentSkillBackgroundUpdatesSetting
settings={settings}
updateSettings={updateSettings}
/>
<AgentGeneratedTabTitlesSetting settings={settings} updateSettings={updateSettings} />
<AgentAwakeSetting settings={settings} updateSettings={updateSettings} />
@@ -969,6 +978,28 @@ export function AgentStatusHooksSetting({
)
}
export function ManagedAgentSkillBackgroundUpdatesSetting({
settings,
updateSettings
}: AgentsPaneProps): React.JSX.Element {
const enabled = settings.managedAgentSkillBackgroundUpdatesEnabled !== false
return (
<section className="space-y-3">
<SettingsSwitchRow
label={getManagedAgentSkillBackgroundUpdatesTitle()}
description={getManagedAgentSkillBackgroundUpdatesDescription()}
checked={enabled}
onChange={() =>
updateSettings({
managedAgentSkillBackgroundUpdatesEnabled: !enabled
})
}
ariaLabel={getManagedAgentSkillBackgroundUpdatesTitle()}
/>
</section>
)
}
export function AgentGeneratedTabTitlesSetting({
settings,
updateSettings
@@ -16,8 +16,8 @@ vi.mock('./AgentSkillSetupPanel', () => ({
describe('BrowserUseSkillStep', () => {
it('forwards a single-skill installed command even when setup installs a bundle', () => {
const bundleInstallCommand =
'npx skills add https://github.com/stablyai/orca --skill orca-cli orchestration --global'
const updateCommand = 'npx skills update orca-cli --global'
'npx --yes skills add https://github.com/stablyai/orca --skill orca-cli orchestration --global --yes'
const updateCommand = 'npx --yes skills update orca-cli --global --yes'
renderToStaticMarkup(
<BrowserUseSkillStep
@@ -90,9 +90,11 @@ describe('CliSection project runtime defaults', () => {
})
)
expect(capturedPanel.props?.command).toContain("wsl.exe -d 'Ubuntu' -- sh -c")
expect(capturedPanel.props?.command).toContain('npx skills add')
expect(capturedPanel.props?.command).toContain('npx --yes skills add')
expect(capturedPanel.props?.installedCommand).toContain("wsl.exe -d 'Ubuntu' -- sh -c")
expect(capturedPanel.props?.installedCommand).toContain('npx skills update orca-cli --global')
expect(capturedPanel.props?.installedCommand).toContain(
'npx --yes skills update orca-cli --global --yes'
)
expect(getWslInstallStatus).toHaveBeenCalledWith({ distro: 'Ubuntu' })
expect(getWslInstallStatus).toHaveBeenCalledTimes(2)
})
@@ -8,8 +8,8 @@ import { getOrchestrationUsageExamples } from '@/lib/orchestration-usage-example
import { OrchestrationPane } from './OrchestrationPane'
const INSTALL_COMMAND =
'npx skills add https://github.com/stablyai/orca --skill orchestration --global'
const UPDATE_COMMAND = 'npx skills update orchestration --global'
'npx --yes skills add https://github.com/stablyai/orca --skill orchestration --global --yes'
const UPDATE_COMMAND = 'npx --yes skills update orchestration --global --yes'
const mocks = vi.hoisted(() => ({
dialogProps: [] as Record<string, unknown>[],
@@ -51,6 +51,10 @@ const updateCapableCallers = new Map<string, readonly string[]>([
'src/renderer/src/components/sidebar/LinearAgentSkillSetupDialog.tsx',
['installedCommand={installedCommand}']
],
[
'src/renderer/src/components/skills/ManagedAgentSkillSetupDialogHost.tsx',
['installedCommand={installedCommand}']
],
[
'src/renderer/src/components/settings/MobileEmulatorAgentControlRow.tsx',
['ORCA_CLI_SKILL_UPDATE_COMMAND', 'installedCommand={ORCA_CLI_SKILL_UPDATE_COMMAND}']
@@ -14,6 +14,11 @@ import {
getAgentStatusHooksSearchKeywords,
getAgentStatusHooksTitle
} from './agent-status-hooks-copy'
import {
getManagedAgentSkillBackgroundUpdatesDescription,
getManagedAgentSkillBackgroundUpdatesSearchKeywords,
getManagedAgentSkillBackgroundUpdatesTitle
} from './managed-agent-skill-background-updates-copy'
import { getAgentCacheTimerSearchEntries } from './agent-cache-timer-search'
import { translate } from '@/i18n/i18n'
import { searchKeywords, translateSearchKeyword, uniqueKeywords } from './settings-search-keywords'
@@ -73,6 +78,11 @@ export const getAgentsPaneSearchEntries = createLocalizedCatalog(() => [
description: getAgentStatusHooksDescription(),
keywords: getAgentStatusHooksSearchKeywords()
},
{
title: getManagedAgentSkillBackgroundUpdatesTitle(),
description: getManagedAgentSkillBackgroundUpdatesDescription(),
keywords: getManagedAgentSkillBackgroundUpdatesSearchKeywords()
},
{
title: getAgentGeneratedTabTitlesTitle(),
description: getAgentGeneratedTabTitlesDescription(),
@@ -0,0 +1,37 @@
import { translate } from '@/i18n/i18n'
import { searchKeywords } from './settings-search-keywords'
export function getManagedAgentSkillBackgroundUpdatesTitle(): string {
return translate(
'auto.components.settings.managed.agent.skill.background.updates.title',
'Allow verified Orca skill updates'
)
}
export function getManagedAgentSkillBackgroundUpdatesDescription(): string {
return translate(
'auto.components.settings.managed.agent.skill.background.updates.description',
'When Orca has verified install metadata and a safe update path, it can try managed agent skill updates in the background. Turn this off to review updates manually.'
)
}
export function getManagedAgentSkillBackgroundUpdatesSearchKeywords(): string[] {
return searchKeywords([
{
key: 'auto.components.settings.managed.agent.skill.background.updates.search.automatic',
fallback: 'automatic'
},
{
key: 'auto.components.settings.managed.agent.skill.background.updates.search.update',
fallback: 'update'
},
{
key: 'auto.components.settings.managed.agent.skill.background.updates.search.skills',
fallback: 'skills'
},
{
key: 'auto.components.settings.managed.agent.skill.background.updates.search.manual',
fallback: 'manual'
}
])
}
@@ -345,11 +345,13 @@ describe('LinearAgentSkillSetupPrompt', () => {
setupButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(document.body.textContent).toContain("wsl.exe -d 'Fedora' -- bash -lc 'npx skills add")
expect(document.body.textContent).toContain(
"wsl.exe -d 'Fedora' -- bash -lc 'npx --yes skills add"
)
expect(mocks.panelProps.at(-1)).toEqual(
expect.objectContaining({
installedCommand:
"wsl.exe -d 'Fedora' -- bash -lc 'npx skills update linear-tickets --global'",
"wsl.exe -d 'Fedora' -- bash -lc 'npx --yes skills update linear-tickets --global --yes'",
terminalShellOverride: 'powershell.exe',
getPrerequisiteStatus: expect.any(Function)
})
@@ -0,0 +1,147 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ManagedAgentSkillFallback } from '../../../../shared/skills'
import {
getManagedSkillFallbackDisplayMessage,
getManagedSkillContextCopy
} from './managed-agent-skill-dialog-copy'
import {
advanceManagedAgentSkillFallbackQueue,
enqueueManagedAgentSkillFallback,
getInstalledStateSourceKinds,
prepareManagedAgentSkillSetupTerminal,
replaceActiveAfterManagedAgentSkillRecheck
} from './managed-agent-skill-dialog-state'
const cliPrerequisiteMocks = vi.hoisted(() => ({
ensureOrcaCliAvailableForAgentSkillTerminal: vi.fn()
}))
vi.mock('@/lib/agent-skill-cli-prerequisite', () => ({
AGENT_SKILL_CLI_PREREQUISITE_NOTICE: 'CLI registration notice',
ensureOrcaCliAvailableForAgentSkillTerminal:
cliPrerequisiteMocks.ensureOrcaCliAvailableForAgentSkillTerminal,
isOrcaCliAvailableOnPath: vi.fn()
}))
function fallback(
patch: Partial<ManagedAgentSkillFallback> & Pick<ManagedAgentSkillFallback, 'context'>
): ManagedAgentSkillFallback {
const skillName = patch.skillName ?? 'orchestration'
const runtime = patch.runtime ?? 'host'
const scope = patch.scope ?? 'global'
return {
status: 'fallback',
skillName,
context: patch.context,
runtime,
scope,
reason: patch.reason ?? 'background-update-disabled',
uiKey: patch.uiKey ?? [runtime, '', skillName, patch.context].join(':'),
message: patch.message ?? 'Fallback message.',
manualCommand: patch.manualCommand,
request: patch.request ?? {
skillName,
context: patch.context,
...(runtime === 'remote' ? { remoteRuntime: true } : { discoveryTarget: { runtime: 'host' } })
}
}
}
beforeEach(() => {
cliPrerequisiteMocks.ensureOrcaCliAvailableForAgentSkillTerminal.mockReset()
})
describe('ManagedAgentSkillSetupDialogHost copy', () => {
it('names the agent-triggered orchestration context', () => {
expect(getManagedSkillContextCopy('agent-orchestration')).toBe(
'An agent just tried to use Orca orchestration. Orca needs the orchestration skill before agents can coordinate reliably.'
)
})
it('names the Linear worktree context', () => {
expect(getManagedSkillContextCopy('linear-worktree')).toBe(
'This Linear task workflow needs the Linear agent skill. Orca could not update it automatically.'
)
})
it('localizes fallback reason copy in the renderer', () => {
expect(getManagedSkillFallbackDisplayMessage('remote-runtime')).toBe(
'Remote runtimes are not updated in the background.'
)
})
})
describe('ManagedAgentSkillSetupDialogHost queue state', () => {
it('prepares the Orca CLI before opening the setup terminal', async () => {
cliPrerequisiteMocks.ensureOrcaCliAvailableForAgentSkillTerminal.mockResolvedValue(null)
await prepareManagedAgentSkillSetupTerminal()
expect(cliPrerequisiteMocks.ensureOrcaCliAvailableForAgentSkillTerminal).toHaveBeenCalledOnce()
})
it('returns stable source-kind filters for installed-state refreshes', () => {
expect(getInstalledStateSourceKinds('global')).toBe(getInstalledStateSourceKinds('global'))
expect(getInstalledStateSourceKinds('project')).toBe(getInstalledStateSourceKinds('project'))
expect(getInstalledStateSourceKinds('bundled')).toBe(getInstalledStateSourceKinds('bundled'))
expect(getInstalledStateSourceKinds('plugin')).toBe(getInstalledStateSourceKinds('plugin'))
})
it('shows the first fallback immediately and queues later fallbacks FIFO', () => {
const first = fallback({
context: 'agent-orchestration',
uiKey: 'host::orchestration:agent-orchestration'
})
const second = fallback({
skillName: 'computer-use',
context: 'agent-computer-use',
uiKey: 'host::computer-use:agent-computer-use'
})
const withFirst = enqueueManagedAgentSkillFallback({ active: null, queue: [] }, first)
const withSecond = enqueueManagedAgentSkillFallback(withFirst, second)
expect(withSecond).toEqual({ active: first, queue: [second] })
expect(advanceManagedAgentSkillFallbackQueue(withSecond)).toEqual({
active: second,
queue: []
})
})
it('clears the active fallback when the queue is empty', () => {
const event = fallback({
context: 'agent-orchestration',
uiKey: 'host::orchestration:agent-orchestration'
})
expect(advanceManagedAgentSkillFallbackQueue({ active: event, queue: [] })).toEqual({
active: null,
queue: []
})
})
it('does not replace the active modal with a non-actionable re-check fallback', () => {
const active = fallback({
context: 'agent-orchestration',
uiKey: 'host::orchestration:agent-orchestration',
manualCommand: {
kind: 'install',
command: 'npx skills install orchestration',
runtime: 'host',
scope: 'global'
}
})
const deadEndFallback = fallback({
context: 'agent-orchestration',
reason: 'lockfile-malformed',
uiKey: 'host::orchestration:agent-orchestration'
})
expect(
replaceActiveAfterManagedAgentSkillRecheck({ active, queue: [] }, deadEndFallback)
).toEqual({
active: null,
queue: []
})
})
})
@@ -0,0 +1,246 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Info, RefreshCw } from 'lucide-react'
import type { ManagedAgentSkillFallback } from '../../../../shared/skills'
import { buildAgentFeatureSkillUpdateCommand } from '@/lib/agent-feature-install-commands'
import {
notifyInstalledAgentSkillsChanged,
useInstalledAgentSkill
} from '@/hooks/useInstalledAgentSkills'
import {
getManagedSkillContextCopy,
getManagedSkillFallbackDisplayMessage
} from './managed-agent-skill-dialog-copy'
import {
advanceManagedAgentSkillFallbackQueue,
enqueueManagedAgentSkillFallback,
getInstalledStateSourceKinds,
prepareManagedAgentSkillSetupTerminal,
replaceActiveAfterManagedAgentSkillRecheck,
type ManagedAgentSkillDialogState
} from './managed-agent-skill-dialog-state'
import { AgentSkillSetupPanel } from '@/components/settings/AgentSkillSetupPanel'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import {
AGENT_SKILL_CLI_PREREQUISITE_NOTICE,
isOrcaCliAvailableOnPath
} from '@/lib/agent-skill-cli-prerequisite'
import { translate } from '@/i18n/i18n'
const DISMISS_STORAGE_PREFIX = 'orca:managed-agent-skill-setup-dismissed:'
export function ManagedAgentSkillSetupDialogHost(): React.JSX.Element | null {
const [dialogState, setDialogState] = useState<ManagedAgentSkillDialogState>({
active: null,
queue: []
})
const queuedKeysRef = useRef(new Set<string>())
const snoozedKeysRef = useRef(new Set<string>())
const [rechecking, setRechecking] = useState(false)
const active = dialogState.active
const installedState = useInstalledAgentSkill(active?.skillName ?? 'linear-tickets', {
enabled: active !== null,
discoveryTarget: active?.request.discoveryTarget,
projectRootPath: active?.request.discoveryTarget?.projectRootPath,
sourceKinds: active ? getInstalledStateSourceKinds(active.scope) : undefined
})
const enqueueFallback = useCallback((event: ManagedAgentSkillFallback): void => {
if (isDismissed(event.uiKey) || snoozedKeysRef.current.has(event.uiKey)) {
return
}
if (queuedKeysRef.current.has(event.uiKey)) {
return
}
queuedKeysRef.current.add(event.uiKey)
setDialogState((current) => enqueueManagedAgentSkillFallback(current, event))
}, [])
const advanceQueue = useCallback((): void => {
setDialogState((current) => {
if (current.active) {
queuedKeysRef.current.delete(current.active.uiKey)
}
return advanceManagedAgentSkillFallbackQueue(current)
})
setRechecking(false)
}, [])
useEffect(() => {
const unsubscribeFallback = window.api.skills.onManagedFallback(enqueueFallback)
const unsubscribeUpdated = window.api.skills.onManagedUpdated(() => {
notifyInstalledAgentSkillsChanged()
})
return () => {
unsubscribeFallback()
unsubscribeUpdated()
}
}, [enqueueFallback])
const contextCopy = active ? getManagedSkillContextCopy(active.context) : ''
const installedCommand = useMemo(
() => (active ? buildAgentFeatureSkillUpdateCommand(active.skillName) : ''),
[active]
)
const recheck = useCallback(async (): Promise<void> => {
if (!active) {
return
}
setRechecking(true)
try {
const result = await window.api.skills.ensureManagedReady({ ...active.request, force: true })
if (result.status === 'fallback') {
setDialogState((current) => replaceActiveAfterManagedAgentSkillRecheck(current, result))
return
}
notifyInstalledAgentSkillsChanged()
advanceQueue()
} finally {
setRechecking(false)
}
}, [active, advanceQueue])
if (!active) {
return null
}
const command = active.manualCommand?.command
const loading = installedState.loading || rechecking
return (
<Dialog
open
onOpenChange={(open) => {
if (!open) {
snoozedKeysRef.current.add(active.uiKey)
advanceQueue()
}
}}
>
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-[640px]">
<div className="px-6 pt-6 pr-14">
<DialogHeader>
<DialogTitle>
{translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.title',
'Agent skill setup needed'
)}
</DialogTitle>
<DialogDescription>{contextCopy}</DialogDescription>
</DialogHeader>
<div className="mt-4 flex items-start gap-2 text-[13px] leading-snug text-muted-foreground">
<Info className="mt-0.5 size-4 shrink-0" />
<p>{getManagedSkillFallbackDisplayMessage(active.reason)}</p>
</div>
</div>
{command ? (
<AgentSkillSetupPanel
className="px-6 pt-4 pb-3"
variant="inline"
hideHeader
title={translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.panelTitle',
'Set up managed agent skill'
)}
description={contextCopy}
command={command}
installedCommand={installedCommand}
terminalTitle={translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.terminalTitle',
'Set up agent skill'
)}
terminalAriaLabel={translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.terminalAria',
'Agent skill setup terminal'
)}
terminalWorktreeId={`managed-agent-skill-setup-${active.skillName}-${active.context}`}
terminalHeightPx={240}
installed={installedState.installed}
loading={loading}
error={installedState.error}
installLabel={
active.manualCommand?.kind === 'update'
? translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.update',
'Update'
)
: translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.install',
'Install'
)
}
installedInstallLabel={translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.update',
'Update'
)}
preInstallNotice={AGENT_SKILL_CLI_PREREQUISITE_NOTICE}
isPrerequisiteAvailable={isOrcaCliAvailableOnPath}
onBeforeOpenTerminal={prepareManagedAgentSkillSetupTerminal}
onRecheck={recheck}
/>
) : (
<div className="px-6 pt-4 pb-3">
<Button type="button" variant="outline" size="sm" onClick={() => void recheck()}>
<RefreshCw className={loading ? 'size-3.5 animate-spin' : 'size-3.5'} />
{translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.recheck',
'Re-check'
)}
</Button>
</div>
)}
<DialogFooter className="px-6 pb-6">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setDismissed(active.uiKey)
advanceQueue()
}}
>
{translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.dontShowAgain',
"Don't show again"
)}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
snoozedKeysRef.current.add(active.uiKey)
advanceQueue()
}}
>
{translate('auto.components.skills.ManagedAgentSkillSetupDialogHost.notNow', 'Not now')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function isDismissed(uiKey: string): boolean {
try {
return window.localStorage.getItem(`${DISMISS_STORAGE_PREFIX}${uiKey}`) === 'true'
} catch {
return false
}
}
function setDismissed(uiKey: string): void {
try {
window.localStorage.setItem(`${DISMISS_STORAGE_PREFIX}${uiKey}`, 'true')
} catch {
// Local storage can be unavailable in constrained test/browser contexts.
}
}
@@ -0,0 +1,142 @@
import type {
ManagedAgentSkillContext,
ManagedAgentSkillFallbackReason
} from '../../../../shared/skills'
import { translate } from '@/i18n/i18n'
export function getManagedSkillContextCopy(context: ManagedAgentSkillContext): string {
switch (context) {
case 'linear-worktree':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.linearWorktreeContext',
'This Linear task workflow needs the Linear agent skill. Orca could not update it automatically.'
)
case 'agent-orchestration':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.agentOrchestrationContext',
'An agent just tried to use Orca orchestration. Orca needs the orchestration skill before agents can coordinate reliably.'
)
case 'agent-computer-use':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.agentComputerUseContext',
'An agent just tried to use Computer Use. Orca needs the Computer Use skill before agents can control apps reliably.'
)
case 'agent-orca-cli':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.agentOrcaCliContext',
"An agent just tried to use Orca's CLI skill for this workflow. Orca needs the CLI skill before this workflow can continue reliably."
)
}
}
export function getManagedSkillFallbackDisplayMessage(
reason: ManagedAgentSkillFallbackReason
): string {
switch (reason) {
case 'target-required':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.targetRequired',
'Orca could not resolve the runtime that should read this skill.'
)
case 'unsupported-skill':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.unsupportedSkill',
'This is not an Orca-managed agent skill.'
)
case 'repair-required-runtime':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.repairRequiredRuntime',
'The selected runtime needs repair before Orca can inspect its skills.'
)
case 'remote-runtime':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.remoteRuntime',
'Remote runtimes are not updated in the background.'
)
case 'wsl-runtime':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.wslRuntime',
'WSL skill updates are manual in this version.'
)
case 'missing-install':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.missingInstall',
'The managed skill is not installed in the selected runtime.'
)
case 'project-install':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.projectInstall',
'Project-scoped skills are not updated in the background.'
)
case 'ambiguous-install':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.ambiguousInstall',
'Orca found both global and project-scoped copies of this skill.'
)
case 'bundled-or-plugin-install':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.bundledOrPluginInstall',
'Bundled and plugin-cache skills are not mutated by Orca.'
)
case 'symlinked-global-install':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.symlinkedGlobalInstall',
'Symlinked global skills are not safe for background updates yet.'
)
case 'unsupported-cli-contract':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.unsupportedCliContract',
'This build does not include the verified skills CLI update contract.'
)
case 'expected-hash-missing':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.expectedHashMissing',
'This build does not include an expected hash for this managed skill.'
)
case 'lockfile-missing':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.lockfileMissing',
'Orca could not find the global skills lockfile.'
)
case 'lockfile-malformed':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.lockfileMalformed',
'The global skills lockfile could not be parsed.'
)
case 'lockfile-unsupported-schema':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.lockfileUnsupportedSchema',
'The global skills lockfile uses an unsupported schema version.'
)
case 'lock-entry-missing':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.lockEntryMissing',
'The global skills lockfile does not track this skill.'
)
case 'lock-entry-unmanaged-source':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.lockEntryUnmanagedSource',
'The installed skill is not tracked as Orca-managed source.'
)
case 'background-update-disabled':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.backgroundUpdateDisabled',
'Automatic skill updates are turned off, so this update needs manual review.'
)
case 'cooldown':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.cooldown',
'Orca recently tried this managed-skill check and is cooling down.'
)
case 'update-failed':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.updateFailed',
'The managed-skill update command failed.'
)
case 'update-timeout':
return translate(
'auto.components.skills.ManagedAgentSkillSetupDialogHost.updateTimeout',
'The managed-skill update command timed out.'
)
}
}
@@ -0,0 +1,62 @@
import type {
ManagedAgentSkillFallback,
ManagedAgentSkillScope,
SkillSourceKind
} from '../../../../shared/skills'
import { shouldEmitManagedAgentSkillFallback } from '../../../../shared/skills'
import { ensureOrcaCliAvailableForAgentSkillTerminal } from '@/lib/agent-skill-cli-prerequisite'
export type ManagedAgentSkillDialogState = {
active: ManagedAgentSkillFallback | null
queue: ManagedAgentSkillFallback[]
}
const GLOBAL_SKILL_SOURCE_KINDS = ['home'] as const satisfies readonly SkillSourceKind[]
const PROJECT_SKILL_SOURCE_KINDS = ['repo'] as const satisfies readonly SkillSourceKind[]
const BUNDLED_SKILL_SOURCE_KINDS = ['bundled'] as const satisfies readonly SkillSourceKind[]
const PLUGIN_SKILL_SOURCE_KINDS = ['plugin'] as const satisfies readonly SkillSourceKind[]
export function enqueueManagedAgentSkillFallback(
current: ManagedAgentSkillDialogState,
event: ManagedAgentSkillFallback
): ManagedAgentSkillDialogState {
return current.active
? { active: current.active, queue: [...current.queue, event] }
: { active: event, queue: current.queue }
}
export function advanceManagedAgentSkillFallbackQueue(
current: ManagedAgentSkillDialogState
): ManagedAgentSkillDialogState {
const [next, ...rest] = current.queue
return { active: next ?? null, queue: rest }
}
export function replaceActiveAfterManagedAgentSkillRecheck(
current: ManagedAgentSkillDialogState,
event: ManagedAgentSkillFallback
): ManagedAgentSkillDialogState {
return shouldEmitManagedAgentSkillFallback(event)
? { ...current, active: event }
: advanceManagedAgentSkillFallbackQueue(current)
}
export function getInstalledStateSourceKinds(
scope: ManagedAgentSkillScope
): readonly SkillSourceKind[] | undefined {
switch (scope) {
case 'global':
case 'missing':
return GLOBAL_SKILL_SOURCE_KINDS
case 'project':
return PROJECT_SKILL_SOURCE_KINDS
case 'bundled':
return BUNDLED_SKILL_SOURCE_KINDS
case 'plugin':
return PLUGIN_SKILL_SOURCE_KINDS
}
}
export async function prepareManagedAgentSkillSetupTerminal(): Promise<void> {
await ensureOrcaCliAvailableForAgentSkillTerminal()
}
@@ -0,0 +1,120 @@
import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../../shared/skills'
let cachedDiscoveryByTarget = new Map<string, SkillDiscoveryResult>()
let pendingDiscoveryByTarget = new Map<string, Promise<SkillDiscoveryResult>>()
let pendingDiscoverySatisfiesForcedRefreshByTarget = new Map<string, boolean>()
export function clearInstalledAgentSkillDiscoveryCache(): void {
cachedDiscoveryByTarget.clear()
}
export function getCachedInstalledAgentSkillDiscovery(
target: SkillDiscoveryTarget | undefined
): SkillDiscoveryResult | null {
return cachedDiscoveryByTarget.get(getSkillDiscoveryTargetKey(target)) ?? null
}
export function normalizeSkillDiscoveryTarget(
target: SkillDiscoveryTarget | undefined
): SkillDiscoveryTarget | undefined {
const projectRuntime = target?.projectRuntime
if (projectRuntime) {
if (projectRuntime.status === 'repair-required') {
return { projectRuntime }
}
if (projectRuntime.runtime.kind === 'wsl') {
return {
runtime: 'wsl',
wslDistro: projectRuntime.runtime.distro,
projectRuntime
}
}
return {
runtime: 'host',
projectRuntime,
projectRootPath: target.projectRootPath
}
}
if (target?.runtime === 'host' && target.projectRootPath) {
return { runtime: 'host', projectRootPath: target.projectRootPath }
}
if (target?.runtime !== 'wsl') {
return undefined
}
return { runtime: 'wsl', wslDistro: target.wslDistro?.trim() || null }
}
export function getSkillDiscoveryTargetKey(target: SkillDiscoveryTarget | undefined): string {
if (target?.projectRuntime) {
const projectRootPath = target.projectRootPath ? `:${target.projectRootPath}` : ''
return target.projectRuntime.status === 'resolved'
? `${target.projectRuntime.runtime.cacheKey}${projectRootPath}`
: `${target.projectRuntime.repair.cacheKey}${projectRootPath}`
}
const normalizedTarget = normalizeSkillDiscoveryTarget(target)
if (normalizedTarget?.runtime === 'host' && normalizedTarget.projectRootPath) {
return `host:${normalizedTarget.projectRootPath}`
}
return normalizedTarget?.runtime === 'wsl' ? `wsl:${normalizedTarget.wslDistro ?? ''}` : 'host'
}
function startInstalledAgentSkillDiscovery(
force: boolean,
target: SkillDiscoveryTarget | undefined
): Promise<SkillDiscoveryResult> {
const key = getSkillDiscoveryTargetKey(target)
const normalizedTarget = normalizeSkillDiscoveryTarget(target)
const discovery = window.api.skills
.discover(normalizedTarget)
.then((result) => {
cachedDiscoveryByTarget.set(key, result)
return result
})
.finally(() => {
if (pendingDiscoveryByTarget.get(key) === discovery) {
pendingDiscoveryByTarget.delete(key)
pendingDiscoverySatisfiesForcedRefreshByTarget.delete(key)
}
})
pendingDiscoveryByTarget.set(key, discovery)
pendingDiscoverySatisfiesForcedRefreshByTarget.set(key, force)
return discovery
}
export async function discoverInstalledAgentSkills(
force: boolean,
target?: SkillDiscoveryTarget
): Promise<SkillDiscoveryResult> {
const key = getSkillDiscoveryTargetKey(target)
const cachedDiscovery = cachedDiscoveryByTarget.get(key)
if (!force && cachedDiscovery) {
return cachedDiscovery
}
const inFlightDiscovery = pendingDiscoveryByTarget.get(key)
if (inFlightDiscovery) {
if (!force || pendingDiscoverySatisfiesForcedRefreshByTarget.get(key)) {
return inFlightDiscovery
}
try {
await inFlightDiscovery
} catch {
// Why: an explicit re-check should still read current disk state even if
// the older background scan failed.
}
const nextPendingDiscovery = pendingDiscoveryByTarget.get(key)
if (nextPendingDiscovery && nextPendingDiscovery !== inFlightDiscovery) {
return nextPendingDiscovery
}
}
return startInstalledAgentSkillDiscovery(force, target)
}
export function resetInstalledAgentSkillDiscoveryForTests(): void {
cachedDiscoveryByTarget = new Map()
pendingDiscoveryByTarget = new Map()
pendingDiscoverySatisfiesForcedRefreshByTarget = new Map()
}
@@ -113,6 +113,50 @@ describe('hasInstalledAgentSkill', () => {
})
).toBe(true)
})
it('filters repo skills to the requested project root', () => {
expect(
hasInstalledAgentSkill(
[
skill({
name: 'linear-tickets',
sourceKind: 'repo',
sourceLabel: 'Repo current .agents',
rootPath: '/repo/current/.agents/skills',
directoryPath: '/repo/current/.agents/skills/linear-tickets',
skillFilePath: '/repo/current/.agents/skills/linear-tickets/SKILL.md'
}),
skill({
id: 'skill-2',
name: 'linear-tickets',
sourceKind: 'repo',
sourceLabel: 'Repo other .agents',
rootPath: '/repo/other/.agents/skills',
directoryPath: '/repo/other/.agents/skills/linear-tickets',
skillFilePath: '/repo/other/.agents/skills/linear-tickets/SKILL.md'
})
],
'linear-tickets',
{ projectRootPath: '/repo/current', sourceKinds: ['repo'] }
)
).toBe(true)
expect(
hasInstalledAgentSkill(
[
skill({
name: 'linear-tickets',
sourceKind: 'repo',
sourceLabel: 'Repo other .agents',
rootPath: '/repo/other/.agents/skills',
directoryPath: '/repo/other/.agents/skills/linear-tickets',
skillFilePath: '/repo/other/.agents/skills/linear-tickets/SKILL.md'
})
],
'linear-tickets',
{ projectRootPath: '/repo/current', sourceKinds: ['repo'] }
)
).toBe(false)
})
})
describe('isOrchestrationSkillName', () => {
+43 -107
View File
@@ -5,8 +5,16 @@ import type {
SkillDiscoveryTarget,
SkillSourceKind
} from '../../../shared/skills'
import { isPathInsideOrEqual } from '../../../shared/cross-platform-path'
import { ORCHESTRATION_SKILL_NAME } from '@/lib/agent-feature-install-commands'
import { markOrchestrationSetupComplete } from '@/lib/orchestration-setup-state'
import {
clearInstalledAgentSkillDiscoveryCache,
discoverInstalledAgentSkills,
getCachedInstalledAgentSkillDiscovery,
getSkillDiscoveryTargetKey,
resetInstalledAgentSkillDiscoveryForTests
} from './installed-agent-skill-discovery'
import { useMountedRef } from './useMountedRef'
const INSTALLED_AGENT_SKILLS_CHANGED_EVENT = 'orca:installed-agent-skills-changed'
@@ -17,10 +25,12 @@ export const GLOBAL_AGENT_SKILL_SOURCE_KINDS = [
type InstalledAgentSkillOptions = {
enabled?: boolean
discoveryTarget?: SkillDiscoveryTarget
projectRootPath?: string | null
sourceKinds?: readonly SkillSourceKind[]
}
type InstalledAgentSkillMatchOptions = {
projectRootPath?: string | null
sourceKinds?: readonly SkillSourceKind[]
}
@@ -32,10 +42,6 @@ export type InstalledAgentSkillState = {
refresh: () => Promise<boolean>
}
let cachedDiscoveryByTarget = new Map<string, SkillDiscoveryResult>()
let pendingDiscoveryByTarget = new Map<string, Promise<SkillDiscoveryResult>>()
let pendingDiscoverySatisfiesForcedRefreshByTarget = new Map<string, boolean>()
function normalizeSkillName(value: string): string {
return value.trim().toLowerCase()
}
@@ -61,6 +67,13 @@ export function hasInstalledAgentSkill(
if (options.sourceKinds && !options.sourceKinds.includes(skill.sourceKind)) {
return false
}
if (
options.projectRootPath &&
skill.sourceKind === 'repo' &&
!isPathInsideOrEqual(options.projectRootPath, skill.rootPath)
) {
return false
}
return (
normalizeSkillName(skill.name) === expected ||
normalizeSkillName(basenameFromPath(skill.directoryPath)) === expected
@@ -69,120 +82,31 @@ export function hasInstalledAgentSkill(
}
export function notifyInstalledAgentSkillsChanged(): void {
cachedDiscoveryByTarget.clear()
clearInstalledAgentSkillDiscoveryCache()
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(INSTALLED_AGENT_SKILLS_CHANGED_EVENT))
}
}
function normalizeSkillDiscoveryTarget(
target: SkillDiscoveryTarget | undefined
): SkillDiscoveryTarget | undefined {
const projectRuntime = target?.projectRuntime
if (projectRuntime) {
if (projectRuntime.status === 'repair-required') {
return { projectRuntime }
}
if (projectRuntime.runtime.kind === 'wsl') {
return {
runtime: 'wsl',
wslDistro: projectRuntime.runtime.distro,
projectRuntime
}
}
return {
runtime: 'host',
projectRuntime
}
}
if (target?.runtime !== 'wsl') {
return undefined
}
return { runtime: 'wsl', wslDistro: target.wslDistro?.trim() || null }
}
function getSkillDiscoveryTargetKey(target: SkillDiscoveryTarget | undefined): string {
if (target?.projectRuntime) {
return target.projectRuntime.status === 'resolved'
? target.projectRuntime.runtime.cacheKey
: target.projectRuntime.repair.cacheKey
}
const normalizedTarget = normalizeSkillDiscoveryTarget(target)
return normalizedTarget?.runtime === 'wsl' ? `wsl:${normalizedTarget.wslDistro ?? ''}` : 'host'
}
function startInstalledAgentSkillDiscovery(
force: boolean,
target: SkillDiscoveryTarget | undefined
): Promise<SkillDiscoveryResult> {
const key = getSkillDiscoveryTargetKey(target)
const normalizedTarget = normalizeSkillDiscoveryTarget(target)
const discovery = window.api.skills
.discover(normalizedTarget)
.then((result) => {
cachedDiscoveryByTarget.set(key, result)
return result
})
.finally(() => {
if (pendingDiscoveryByTarget.get(key) === discovery) {
pendingDiscoveryByTarget.delete(key)
pendingDiscoverySatisfiesForcedRefreshByTarget.delete(key)
}
})
pendingDiscoveryByTarget.set(key, discovery)
pendingDiscoverySatisfiesForcedRefreshByTarget.set(key, force)
return discovery
}
async function discoverInstalledAgentSkills(
force: boolean,
target?: SkillDiscoveryTarget
): Promise<SkillDiscoveryResult> {
const key = getSkillDiscoveryTargetKey(target)
const cachedDiscovery = cachedDiscoveryByTarget.get(key)
if (!force && cachedDiscovery) {
return cachedDiscovery
}
const inFlightDiscovery = pendingDiscoveryByTarget.get(key)
if (inFlightDiscovery) {
if (!force || pendingDiscoverySatisfiesForcedRefreshByTarget.get(key)) {
return inFlightDiscovery
}
try {
await inFlightDiscovery
} catch {
// Why: an explicit re-check should still read current disk state even if
// the older background scan failed.
}
const nextPendingDiscovery = pendingDiscoveryByTarget.get(key)
if (nextPendingDiscovery && nextPendingDiscovery !== inFlightDiscovery) {
return nextPendingDiscovery
}
}
return startInstalledAgentSkillDiscovery(force, target)
}
export const _installedAgentSkillDiscoveryInternalsForTests = {
discoverInstalledAgentSkills,
getSkillDiscoveryTargetKey,
isOrchestrationSkillName,
reset(): void {
cachedDiscoveryByTarget = new Map()
pendingDiscoveryByTarget = new Map()
pendingDiscoverySatisfiesForcedRefreshByTarget = new Map()
}
reset: resetInstalledAgentSkillDiscoveryForTests
}
export function useInstalledAgentSkill(
skillName: string,
options: InstalledAgentSkillOptions = {}
): InstalledAgentSkillState {
const { enabled = true, discoveryTarget, sourceKinds } = options
const {
enabled = true,
discoveryTarget,
projectRootPath = discoveryTarget?.projectRootPath,
sourceKinds
} = options
const discoveryTargetKey = getSkillDiscoveryTargetKey(discoveryTarget)
const cachedDiscovery = cachedDiscoveryByTarget.get(discoveryTargetKey) ?? null
const cachedDiscovery = getCachedInstalledAgentSkillDiscovery(discoveryTarget)
const [result, setResult] = useState<SkillDiscoveryResult | null>(cachedDiscovery)
const [loading, setLoading] = useState(enabled && !cachedDiscovery)
const [error, setError] = useState<string | null>(null)
@@ -200,7 +124,7 @@ export function useInstalledAgentSkill(
stateResetInputRef.current.discoveryTargetKey !== discoveryTargetKey ||
stateResetInputRef.current.enabled !== enabled
) {
const nextCachedDiscovery = cachedDiscoveryByTarget.get(discoveryTargetKey) ?? null
const nextCachedDiscovery = getCachedInstalledAgentSkillDiscovery(discoveryTarget)
const nextLoading = enabled && !nextCachedDiscovery
stateResetInputRef.current = { discoveryTargetKey, enabled }
resultForRender = nextCachedDiscovery
@@ -237,7 +161,10 @@ export function useInstalledAgentSkill(
let installedAfterRefresh = false
try {
const next = await discoverInstalledAgentSkills(force, discoveryTarget)
installedAfterRefresh = hasInstalledAgentSkill(next.skills, skillName, { sourceKinds })
installedAfterRefresh = hasInstalledAgentSkill(next.skills, skillName, {
projectRootPath,
sourceKinds
})
writeIfCurrent(() => {
setResult(next)
setError(null)
@@ -257,7 +184,15 @@ export function useInstalledAgentSkill(
}
return installedAfterRefresh
},
[discoveryTarget, discoveryTargetKey, enabled, mountedRef, skillName, sourceKinds]
[
discoveryTarget,
discoveryTargetKey,
enabled,
mountedRef,
projectRootPath,
skillName,
sourceKinds
]
)
useEffect(() => {
@@ -287,8 +222,9 @@ export function useInstalledAgentSkill(
)
const installed = useMemo(
() => (enabled ? hasInstalledAgentSkill(skills, skillName, { sourceKinds }) : false),
[enabled, skills, skillName, sourceKinds]
() =>
enabled ? hasInstalledAgentSkill(skills, skillName, { projectRootPath, sourceKinds }) : false,
[enabled, projectRootPath, skills, skillName, sourceKinds]
)
useEffect(() => {
+52 -6
View File
@@ -152,7 +152,9 @@
"36761d9604": "Unknown keybinding action \"{{value0}}\" was ignored.",
"d2e43e426a": "{{value0}} must be an object.",
"fb290366b2": "Unavailable on web.",
"76122208ca": "Shortcut for \"{{value0}}\" was ignored: {{value1}}"
"76122208ca": "Shortcut for \"{{value0}}\" was ignored: {{value1}}",
"remoteManagedSkillRuntime": "Remote runtimes are not updated in the background.",
"remoteManagedSkillCooldown": "Orca recently tried this managed-skill check and is cooling down."
}
},
"runtime": {
@@ -2322,8 +2324,7 @@
"f3eeb1de13": "Copy",
"c2f0b72b8d": "Insert",
"925f49f210": "Expand Pane",
"df766809e0": "Collapse Pane",
"copyTerminalId": "Copy Terminal ID"
"df766809e0": "Collapse Pane"
},
"TerminalErrorToast": {
"e4aa243f8c": "Restart daemon",
@@ -3261,6 +3262,42 @@
"cd7893fbc1": "Scanning skills",
"35b9a724a0": "Available",
"0c74e7ff34": "Local"
},
"ManagedAgentSkillSetupDialogHost": {
"title": "Agent skill setup needed",
"panelTitle": "Set up managed agent skill",
"terminalTitle": "Set up agent skill",
"terminalAria": "Agent skill setup terminal",
"update": "Update",
"install": "Install",
"recheck": "Re-check",
"dontShowAgain": "Don't show again",
"notNow": "Not now",
"linearWorktreeContext": "This Linear task workflow needs the Linear agent skill. Orca could not update it automatically.",
"agentOrchestrationContext": "An agent just tried to use Orca orchestration. Orca needs the orchestration skill before agents can coordinate reliably.",
"agentComputerUseContext": "An agent just tried to use Computer Use. Orca needs the Computer Use skill before agents can control apps reliably.",
"agentOrcaCliContext": "An agent just tried to use Orca's CLI skill for this workflow. Orca needs the CLI skill before this workflow can continue reliably.",
"targetRequired": "Orca could not resolve the runtime that should read this skill.",
"unsupportedSkill": "This is not an Orca-managed agent skill.",
"repairRequiredRuntime": "The selected runtime needs repair before Orca can inspect its skills.",
"remoteRuntime": "Remote runtimes are not updated in the background.",
"wslRuntime": "WSL skill updates are manual in this version.",
"missingInstall": "The managed skill is not installed in the selected runtime.",
"projectInstall": "Project-scoped skills are not updated in the background.",
"ambiguousInstall": "Orca found both global and project-scoped copies of this skill.",
"bundledOrPluginInstall": "Bundled and plugin-cache skills are not mutated by Orca.",
"symlinkedGlobalInstall": "Symlinked global skills are not safe for background updates yet.",
"unsupportedCliContract": "This build does not include the verified skills CLI update contract.",
"expectedHashMissing": "This build does not include an expected hash for this managed skill.",
"lockfileMissing": "Orca could not find the global skills lockfile.",
"lockfileMalformed": "The global skills lockfile could not be parsed.",
"lockfileUnsupportedSchema": "The global skills lockfile uses an unsupported schema version.",
"lockEntryMissing": "The global skills lockfile does not track this skill.",
"lockEntryUnmanagedSource": "The installed skill is not tracked as Orca-managed source.",
"backgroundUpdateDisabled": "Automatic skill updates are turned off, so this update needs manual review.",
"cooldown": "Orca recently tried this managed-skill check and is cooling down.",
"updateFailed": "The managed-skill update command failed.",
"updateTimeout": "The managed-skill update command timed out."
}
},
"sidebar": {
@@ -4283,6 +4320,18 @@
}
}
},
"managed": {
"agent": {
"skill": {
"background": {
"updates": {
"title": "Allow verified Orca skill updates",
"description": "When Orca has verified install metadata and a safe update path, it can try managed agent skill updates in the background. Turn this off to review updates manually."
}
}
}
}
},
"AccountsPane": {
"c2d2751587": "Remove Account",
"dbb9626ed1": "Cancel",
@@ -9144,9 +9193,6 @@
"copyCommand": "Copy command"
}
}
},
"SourceControlEntryContextMenu": {
"a1f2c8d901": "View"
}
}
},
+52 -6
View File
@@ -152,7 +152,9 @@
"36761d9604": "Se ignoró la acción de combinación de teclas desconocida \"{{value0}}\".",
"d2e43e426a": "{{value0}} debe ser un objeto.",
"fb290366b2": "No disponible en la web.",
"76122208ca": "Se ignoró el acceso directo para \"{{value0}}\": {{value1}}"
"76122208ca": "Se ignoró el acceso directo para \"{{value0}}\": {{value1}}",
"remoteManagedSkillRuntime": "Los entornos de ejecución remotos no se actualizan en segundo plano.",
"remoteManagedSkillCooldown": "Orca intentó recientemente esta comprobación de habilidad administrada y está esperando antes de volver a intentarlo."
}
},
"runtime": {
@@ -2322,8 +2324,7 @@
"f3eeb1de13": "Copiar",
"c2f0b72b8d": "Insertar",
"925f49f210": "Expandir panel",
"df766809e0": "Contraer panel",
"copyTerminalId": "Copy Terminal ID"
"df766809e0": "Contraer panel"
},
"TerminalErrorToast": {
"e4aa243f8c": "Reiniciar demonio",
@@ -3261,6 +3262,42 @@
"cd7893fbc1": "Habilidades de escaneo",
"35b9a724a0": "Disponible",
"0c74e7ff34": "Local"
},
"ManagedAgentSkillSetupDialogHost": {
"title": "Se necesita configurar la habilidad del agente",
"panelTitle": "Configurar habilidad de agente administrada",
"terminalTitle": "Configurar habilidad de agente",
"terminalAria": "Terminal de configuración de habilidad de agente",
"update": "Actualizar",
"install": "Instalar",
"recheck": "Volver a comprobar",
"dontShowAgain": "No volver a mostrar",
"notNow": "Ahora no",
"linearWorktreeContext": "Este flujo de tarea de Linear necesita la habilidad de agente de Linear. Orca no pudo actualizarla automáticamente.",
"agentOrchestrationContext": "Un agente acaba de intentar usar la orquestación de Orca. Orca necesita la habilidad de orquestación para que los agentes puedan coordinarse de forma fiable.",
"agentComputerUseContext": "Un agente acaba de intentar usar Computer Use. Orca necesita la habilidad Computer Use para que los agentes puedan controlar apps de forma fiable.",
"agentOrcaCliContext": "Un agente acaba de intentar usar la habilidad de CLI de Orca para este flujo. Orca necesita la habilidad de CLI para que este flujo pueda continuar de forma fiable.",
"targetRequired": "Orca no pudo resolver el entorno de ejecución que debe leer esta habilidad.",
"unsupportedSkill": "Esta no es una habilidad de agente administrada por Orca.",
"repairRequiredRuntime": "El entorno de ejecución seleccionado necesita reparación antes de que Orca pueda inspeccionar sus habilidades.",
"remoteRuntime": "Los entornos de ejecución remotos no se actualizan en segundo plano.",
"wslRuntime": "Las actualizaciones de habilidades en WSL son manuales en esta versión.",
"missingInstall": "La habilidad administrada no está instalada en el entorno de ejecución seleccionado.",
"projectInstall": "Las habilidades con alcance de proyecto no se actualizan en segundo plano.",
"ambiguousInstall": "Orca encontró copias globales y con alcance de proyecto de esta habilidad.",
"bundledOrPluginInstall": "Orca no modifica habilidades incluidas ni de caché de plugins.",
"symlinkedGlobalInstall": "Las habilidades globales con enlaces simbólicos aún no son seguras para actualizaciones en segundo plano.",
"unsupportedCliContract": "Esta compilación no incluye el contrato verificado de actualización de la CLI de skills.",
"expectedHashMissing": "Esta compilación no incluye un hash esperado para esta habilidad administrada.",
"lockfileMissing": "Orca no pudo encontrar el lockfile global de skills.",
"lockfileMalformed": "No se pudo analizar el lockfile global de skills.",
"lockfileUnsupportedSchema": "El lockfile global de skills usa una versión de esquema no compatible.",
"lockEntryMissing": "El lockfile global de skills no registra esta habilidad.",
"lockEntryUnmanagedSource": "La habilidad instalada no está registrada como origen administrado por Orca.",
"backgroundUpdateDisabled": "Las actualizaciones automáticas de habilidades están desactivadas, por lo que esta actualización necesita revisión manual.",
"cooldown": "Orca intentó recientemente esta comprobación de habilidad administrada y está esperando antes de volver a intentarlo.",
"updateFailed": "El comando de actualización de la habilidad administrada falló.",
"updateTimeout": "El comando de actualización de la habilidad administrada agotó el tiempo de espera."
}
},
"sidebar": {
@@ -4283,6 +4320,18 @@
}
}
},
"managed": {
"agent": {
"skill": {
"background": {
"updates": {
"title": "Permitir actualizaciones verificadas de habilidades de Orca",
"description": "Cuando Orca tiene metadatos de instalación verificados y una ruta de actualización segura, puede intentar actualizar las habilidades de agente administradas en segundo plano. Desactiva esto para revisar las actualizaciones manualmente."
}
}
}
}
},
"AccountsPane": {
"c2d2751587": "Eliminar cuenta",
"dbb9626ed1": "Cancelar",
@@ -9144,9 +9193,6 @@
"copyCommand": "Copy command"
}
}
},
"SourceControlEntryContextMenu": {
"a1f2c8d901": "View"
}
}
},
+52 -6
View File
@@ -152,7 +152,9 @@
"36761d9604": "不明なキーバインド 操作「{{value0}}」は無視されました。",
"d2e43e426a": "{{value0}} はオブジェクトでなければなりません。",
"fb290366b2": "ウェブでは利用できません。",
"76122208ca": "「{{value0}}」のショートカットは無視されました: {{value1}}"
"76122208ca": "「{{value0}}」のショートカットは無視されました: {{value1}}",
"remoteManagedSkillRuntime": "リモートランタイムはバックグラウンドでは更新されません。",
"remoteManagedSkillCooldown": "Orca は最近この管理対象スキルのチェックを試行したため、クールダウン中です。"
}
},
"runtime": {
@@ -2322,8 +2324,7 @@
"f3eeb1de13": "コピー",
"c2f0b72b8d": "入れる",
"925f49f210": "ペインを展開する",
"df766809e0": "ペインを折りたたむ",
"copyTerminalId": "Copy Terminal ID"
"df766809e0": "ペインを折りたたむ"
},
"TerminalErrorToast": {
"e4aa243f8c": "デーモンを再起動します",
@@ -3261,6 +3262,42 @@
"cd7893fbc1": "スキャンスキル",
"35b9a724a0": "利用可能",
"0c74e7ff34": "ローカル"
},
"ManagedAgentSkillSetupDialogHost": {
"title": "エージェントスキルの設定が必要です",
"panelTitle": "管理対象エージェントスキルを設定",
"terminalTitle": "エージェントスキルを設定",
"terminalAria": "エージェントスキル設定ターミナル",
"update": "更新",
"install": "インストール",
"recheck": "再チェック",
"dontShowAgain": "今後表示しない",
"notNow": "今はしない",
"linearWorktreeContext": "この Linear タスクのワークフローには Linear エージェントスキルが必要です。Orca は自動更新できませんでした。",
"agentOrchestrationContext": "エージェントが Orca オーケストレーションを使おうとしました。エージェントが確実に連携するには、Orca にオーケストレーションスキルが必要です。",
"agentComputerUseContext": "エージェントが Computer Use を使おうとしました。エージェントがアプリを確実に操作するには、Orca に Computer Use スキルが必要です。",
"agentOrcaCliContext": "エージェントがこのワークフローで Orca の CLI スキルを使おうとしました。このワークフローを確実に続行するには、Orca に CLI スキルが必要です。",
"targetRequired": "Orca はこのスキルを読み取るランタイムを解決できませんでした。",
"unsupportedSkill": "これは Orca 管理のエージェントスキルではありません。",
"repairRequiredRuntime": "Orca がスキルを検査する前に、選択したランタイムの修復が必要です。",
"remoteRuntime": "リモートランタイムはバックグラウンドでは更新されません。",
"wslRuntime": "このバージョンでは WSL スキル更新は手動です。",
"missingInstall": "選択したランタイムに管理対象スキルがインストールされていません。",
"projectInstall": "プロジェクトスコープのスキルはバックグラウンドでは更新されません。",
"ambiguousInstall": "Orca はこのスキルのグローバルコピーとプロジェクトスコープのコピーの両方を見つけました。",
"bundledOrPluginInstall": "Orca はバンドルスキルやプラグインキャッシュのスキルを変更しません。",
"symlinkedGlobalInstall": "シンボリックリンクされたグローバルスキルは、まだバックグラウンド更新に対して安全ではありません。",
"unsupportedCliContract": "このビルドには検証済みの skills CLI 更新契約が含まれていません。",
"expectedHashMissing": "このビルドには、この管理対象スキルの期待ハッシュが含まれていません。",
"lockfileMissing": "Orca はグローバル skills ロックファイルを見つけられませんでした。",
"lockfileMalformed": "グローバル skills ロックファイルを解析できませんでした。",
"lockfileUnsupportedSchema": "グローバル skills ロックファイルは対応していないスキーマバージョンを使用しています。",
"lockEntryMissing": "グローバル skills ロックファイルはこのスキルを追跡していません。",
"lockEntryUnmanagedSource": "インストール済みスキルは Orca 管理のソースとして追跡されていません。",
"backgroundUpdateDisabled": "スキルの自動更新がオフになっているため、この更新には手動確認が必要です。",
"cooldown": "Orca は最近この管理対象スキルのチェックを試行したため、クールダウン中です。",
"updateFailed": "管理対象スキルの更新コマンドに失敗しました。",
"updateTimeout": "管理対象スキルの更新コマンドがタイムアウトしました。"
}
},
"sidebar": {
@@ -4268,6 +4305,18 @@
}
},
"settings": {
"managed": {
"agent": {
"skill": {
"background": {
"updates": {
"title": "検証済みの Orca スキル更新を許可",
"description": "Orca に検証済みのインストールメタデータと安全な更新経路がある場合、管理対象のエージェントスキルをバックグラウンドで更新しようとできます。更新を手動で確認するにはオフにします。"
}
}
}
}
},
"AccountsPane": {
"c2d2751587": "アカウントの削除",
"dbb9626ed1": "キャンセル",
@@ -9144,9 +9193,6 @@
"copyCommand": "Copy command"
}
}
},
"SourceControlEntryContextMenu": {
"a1f2c8d901": "View"
}
}
},
+52 -6
View File
@@ -152,7 +152,9 @@
"36761d9604": "알 수 없는 키 바인딩 작업 \"{{value0}}\"이(가) 무시되었습니다.",
"d2e43e426a": "{{value0}}은(는) 객체여야 합니다.",
"fb290366b2": "웹에서는 사용할 수 없습니다.",
"76122208ca": "\"{{value0}}\"에 대한 단축키가 무시되었습니다: {{value1}}"
"76122208ca": "\"{{value0}}\"에 대한 단축키가 무시되었습니다: {{value1}}",
"remoteManagedSkillRuntime": "원격 런타임은 백그라운드에서 업데이트되지 않습니다.",
"remoteManagedSkillCooldown": "Orca가 최근 이 관리형 스킬 확인을 시도했으며 현재 대기 중입니다."
}
},
"runtime": {
@@ -2322,8 +2324,7 @@
"f3eeb1de13": "복사",
"c2f0b72b8d": "삽입",
"925f49f210": "창 확장",
"df766809e0": "창 축소",
"copyTerminalId": "Copy Terminal ID"
"df766809e0": "창 축소"
},
"TerminalErrorToast": {
"e4aa243f8c": "데몬 재시작",
@@ -3261,6 +3262,42 @@
"cd7893fbc1": "스킬 스캔 중",
"35b9a724a0": "사용 가능",
"0c74e7ff34": "로컬"
},
"ManagedAgentSkillSetupDialogHost": {
"title": "에이전트 스킬 설정이 필요합니다",
"panelTitle": "관리형 에이전트 스킬 설정",
"terminalTitle": "에이전트 스킬 설정",
"terminalAria": "에이전트 스킬 설정 터미널",
"update": "업데이트",
"install": "설치",
"recheck": "다시 확인",
"dontShowAgain": "다시 표시하지 않기",
"notNow": "나중에",
"linearWorktreeContext": "이 Linear 작업 흐름에는 Linear 에이전트 스킬이 필요합니다. Orca가 자동으로 업데이트할 수 없습니다.",
"agentOrchestrationContext": "에이전트가 방금 Orca 오케스트레이션을 사용하려고 했습니다. 에이전트가 안정적으로 조율되려면 Orca에 오케스트레이션 스킬이 필요합니다.",
"agentComputerUseContext": "에이전트가 방금 Computer Use를 사용하려고 했습니다. 에이전트가 앱을 안정적으로 제어하려면 Orca에 Computer Use 스킬이 필요합니다.",
"agentOrcaCliContext": "에이전트가 이 흐름에서 Orca의 CLI 스킬을 사용하려고 했습니다. 이 흐름을 안정적으로 계속하려면 Orca에 CLI 스킬이 필요합니다.",
"targetRequired": "Orca가 이 스킬을 읽어야 하는 런타임을 확인할 수 없습니다.",
"unsupportedSkill": "Orca가 관리하는 에이전트 스킬이 아닙니다.",
"repairRequiredRuntime": "Orca가 스킬을 검사하기 전에 선택한 런타임을 복구해야 합니다.",
"remoteRuntime": "원격 런타임은 백그라운드에서 업데이트되지 않습니다.",
"wslRuntime": "이 버전에서는 WSL 스킬 업데이트가 수동입니다.",
"missingInstall": "선택한 런타임에 관리형 스킬이 설치되어 있지 않습니다.",
"projectInstall": "프로젝트 범위 스킬은 백그라운드에서 업데이트되지 않습니다.",
"ambiguousInstall": "Orca가 이 스킬의 전역 복사본과 프로젝트 범위 복사본을 모두 찾았습니다.",
"bundledOrPluginInstall": "Orca는 번들 또는 플러그인 캐시 스킬을 변경하지 않습니다.",
"symlinkedGlobalInstall": "심볼릭 링크된 전역 스킬은 아직 백그라운드 업데이트에 안전하지 않습니다.",
"unsupportedCliContract": "이 빌드에는 검증된 skills CLI 업데이트 계약이 포함되어 있지 않습니다.",
"expectedHashMissing": "이 빌드에는 이 관리형 스킬의 예상 해시가 포함되어 있지 않습니다.",
"lockfileMissing": "Orca가 전역 skills 잠금 파일을 찾을 수 없습니다.",
"lockfileMalformed": "전역 skills 잠금 파일을 파싱할 수 없습니다.",
"lockfileUnsupportedSchema": "전역 skills 잠금 파일이 지원되지 않는 스키마 버전을 사용합니다.",
"lockEntryMissing": "전역 skills 잠금 파일이 이 스킬을 추적하지 않습니다.",
"lockEntryUnmanagedSource": "설치된 스킬이 Orca 관리 소스로 추적되지 않습니다.",
"backgroundUpdateDisabled": "자동 스킬 업데이트가 꺼져 있어 이 업데이트는 수동 검토가 필요합니다.",
"cooldown": "Orca가 최근 이 관리형 스킬 확인을 시도했으며 현재 대기 중입니다.",
"updateFailed": "관리형 스킬 업데이트 명령이 실패했습니다.",
"updateTimeout": "관리형 스킬 업데이트 명령 시간이 초과되었습니다."
}
},
"sidebar": {
@@ -4268,6 +4305,18 @@
}
},
"settings": {
"managed": {
"agent": {
"skill": {
"background": {
"updates": {
"title": "검증된 Orca 스킬 업데이트 허용",
"description": "Orca에 검증된 설치 메타데이터와 안전한 업데이트 경로가 있으면 관리형 에이전트 스킬을 백그라운드에서 업데이트하려고 시도할 수 있습니다. 업데이트를 직접 검토하려면 이 옵션을 끄세요."
}
}
}
}
},
"AccountsPane": {
"c2d2751587": "계정 삭제",
"dbb9626ed1": "취소",
@@ -9144,9 +9193,6 @@
"copyCommand": "명령 복사"
}
}
},
"SourceControlEntryContextMenu": {
"a1f2c8d901": "보기"
}
}
},
+52 -6
View File
@@ -152,7 +152,9 @@
"36761d9604": "未知的键绑定操作“{{value0}}”被忽略。",
"d2e43e426a": "{{value0}} 必须是一个对象。",
"fb290366b2": "无法在网络上使用。",
"76122208ca": "“{{value0}}”的快捷方式被忽略:{{value1}}"
"76122208ca": "“{{value0}}”的快捷方式被忽略:{{value1}}",
"remoteManagedSkillRuntime": "远程运行时不会在后台更新。",
"remoteManagedSkillCooldown": "Orca 最近已尝试此托管技能检查,正在等待重试。"
}
},
"runtime": {
@@ -2322,8 +2324,7 @@
"f3eeb1de13": "复制",
"c2f0b72b8d": "插入",
"925f49f210": "展开窗格",
"df766809e0": "折叠窗格",
"copyTerminalId": "Copy Terminal ID"
"df766809e0": "折叠窗格"
},
"TerminalErrorToast": {
"e4aa243f8c": "重新启动守护进程",
@@ -3261,6 +3262,42 @@
"cd7893fbc1": "正在扫描技能",
"35b9a724a0": "可用的",
"0c74e7ff34": "当地的"
},
"ManagedAgentSkillSetupDialogHost": {
"title": "需要设置代理技能",
"panelTitle": "设置托管代理技能",
"terminalTitle": "设置代理技能",
"terminalAria": "代理技能设置终端",
"update": "更新",
"install": "安装",
"recheck": "重新检查",
"dontShowAgain": "不再显示",
"notNow": "稍后",
"linearWorktreeContext": "此 Linear 任务流程需要 Linear 代理技能。Orca 无法自动更新它。",
"agentOrchestrationContext": "代理刚刚尝试使用 Orca 编排。Orca 需要编排技能,代理才能可靠地协作。",
"agentComputerUseContext": "代理刚刚尝试使用 Computer Use。Orca 需要 Computer Use 技能,代理才能可靠地控制应用。",
"agentOrcaCliContext": "代理刚刚尝试在此流程中使用 Orca 的 CLI 技能。Orca 需要 CLI 技能,此流程才能可靠地继续。",
"targetRequired": "Orca 无法解析应读取此技能的运行时。",
"unsupportedSkill": "这不是 Orca 托管的代理技能。",
"repairRequiredRuntime": "所选运行时需要修复,Orca 才能检查其技能。",
"remoteRuntime": "远程运行时不会在后台更新。",
"wslRuntime": "此版本中 WSL 技能更新需要手动完成。",
"missingInstall": "所选运行时未安装托管技能。",
"projectInstall": "项目范围的技能不会在后台更新。",
"ambiguousInstall": "Orca 找到了此技能的全局副本和项目范围副本。",
"bundledOrPluginInstall": "Orca 不会修改捆绑技能或插件缓存技能。",
"symlinkedGlobalInstall": "符号链接的全局技能目前还不能安全地进行后台更新。",
"unsupportedCliContract": "此构建不包含经过验证的 skills CLI 更新契约。",
"expectedHashMissing": "此构建不包含此托管技能的预期哈希。",
"lockfileMissing": "Orca 找不到全局 skills 锁定文件。",
"lockfileMalformed": "无法解析全局 skills 锁定文件。",
"lockfileUnsupportedSchema": "全局 skills 锁定文件使用了不受支持的架构版本。",
"lockEntryMissing": "全局 skills 锁定文件未跟踪此技能。",
"lockEntryUnmanagedSource": "已安装的技能未被跟踪为 Orca 托管来源。",
"backgroundUpdateDisabled": "自动技能更新已关闭,因此此次更新需要手动审核。",
"cooldown": "Orca 最近已尝试此托管技能检查,正在等待重试。",
"updateFailed": "托管技能更新命令失败。",
"updateTimeout": "托管技能更新命令超时。"
}
},
"sidebar": {
@@ -4268,6 +4305,18 @@
}
},
"settings": {
"managed": {
"agent": {
"skill": {
"background": {
"updates": {
"title": "允许已验证的 Orca 技能更新",
"description": "当 Orca 拥有已验证的安装元数据和安全更新路径时,可以尝试在后台更新托管的智能体技能。关闭后可手动审核更新。"
}
}
}
}
},
"AccountsPane": {
"c2d2751587": "删除账户",
"dbb9626ed1": "取消",
@@ -9144,9 +9193,6 @@
"copyCommand": "Copy command"
}
}
},
"SourceControlEntryContextMenu": {
"a1f2c8d901": "View"
}
}
},
@@ -76,6 +76,26 @@ const mockApi = {
},
runtimeEnvironments: {
call: runtimeEnvironmentTransportCall
},
platform: {
get: vi.fn(() => ({ platform: 'win32' }))
},
skills: {
ensureManagedReady: vi.fn().mockResolvedValue({
status: 'fallback',
skillName: 'linear-tickets',
context: 'linear-worktree',
runtime: 'remote',
scope: 'missing',
reason: 'remote-runtime',
uiKey: 'remote::linear-tickets:linear-worktree',
message: 'Remote runtimes are not updated in the background.',
request: {
skillName: 'linear-tickets',
context: 'linear-worktree',
remoteRuntime: true
}
})
}
}
@@ -1615,6 +1635,141 @@ describe('createWorktree base status merge', () => {
})
})
it('marks linked Linear skill setup remote for SSH-owned worktree creates', async () => {
const store = createTestStore()
const wt = makeWorktree({
id: 'repo1::/path/wt1',
repoId: 'repo1',
path: '/path/wt1',
linkedLinearIssue: 'ENG-123'
})
store.setState({
repos: [{ id: 'repo1', path: '/repo', connectionId: 'ssh-1' } as AppState['repos'][number]]
} as Partial<AppState>)
mockApi.worktrees.create.mockResolvedValue({ worktree: wt })
await store
.getState()
.createWorktree(
'repo1',
'feature',
'origin/main',
'inherit',
undefined,
'sidebar',
undefined,
undefined,
undefined,
undefined,
undefined,
'ENG-123'
)
expect(mockApi.skills.ensureManagedReady).toHaveBeenCalledWith({
skillName: 'linear-tickets',
context: 'linear-worktree',
remoteRuntime: true
})
})
it('passes the local project root for host linked Linear skill setup nudges', async () => {
const store = createTestStore()
const wt = makeWorktree({
id: 'repo1::/repo/wt1',
repoId: 'repo1',
path: '/repo/wt1',
linkedLinearIssue: 'ENG-123'
})
store.setState({
repos: [
{
id: 'repo1',
path: '/repo',
connectionId: null
} as AppState['repos'][number]
]
} as Partial<AppState>)
mockApi.worktrees.create.mockResolvedValue({ worktree: wt })
await store
.getState()
.createWorktree(
'repo1',
'feature',
'origin/main',
'inherit',
undefined,
'sidebar',
undefined,
undefined,
undefined,
undefined,
undefined,
'ENG-123'
)
expect(mockApi.skills.ensureManagedReady).toHaveBeenCalledWith({
skillName: 'linear-tickets',
context: 'linear-worktree',
discoveryTarget: expect.objectContaining({
projectRootPath: '/repo/wt1'
})
})
})
it('preserves local WSL project runtime for linked Linear skill setup nudges', async () => {
const store = createTestStore()
const wt = makeWorktree({
id: 'repo1::C:\\repo\\wt1',
repoId: 'repo1',
path: 'C:\\repo\\wt1',
linkedLinearIssue: 'ENG-123'
})
store.setState({
repos: [
{
id: 'repo1',
path: 'C:\\repo',
connectionId: null
} as AppState['repos'][number]
],
projects: [{ id: 'repo1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }]
} as Partial<AppState>)
mockApi.worktrees.create.mockResolvedValue({ worktree: wt })
await store
.getState()
.createWorktree(
'repo1',
'feature',
'origin/main',
'inherit',
undefined,
'sidebar',
undefined,
undefined,
undefined,
undefined,
undefined,
'ENG-123'
)
expect(mockApi.skills.ensureManagedReady).toHaveBeenCalledWith({
skillName: 'linear-tickets',
context: 'linear-worktree',
discoveryTarget: {
projectRootPath: 'C:\\repo\\wt1',
projectRuntime: expect.objectContaining({
status: 'resolved',
runtime: expect.objectContaining({
kind: 'wsl',
distro: 'Ubuntu'
})
})
}
})
})
it('passes the active folder workspace as parent for in-app worktree creates', async () => {
const store = createTestStore()
const wt = makeWorktree({
@@ -29,8 +29,11 @@ import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import {
callRuntimeRpc,
getActiveRuntimeTarget,
type RuntimeClientTarget,
RuntimeRpcCallError
} from '../../runtime/runtime-rpc-client'
import { LINEAR_TICKETS_SKILL_NAME } from '@/lib/agent-feature-install-commands'
import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector'
import { getHostedReviewCacheKey, refreshHostedReviewCard } from './hosted-review'
import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from './github-cache-key'
@@ -185,6 +188,34 @@ function showPreservedBranchToast(
)
}
function nudgeLinearManagedSkillSetup(
target: RuntimeClientTarget,
remote: boolean,
projectRuntime: ReturnType<typeof getLocalRepoProjectExecutionRuntimeContext>,
projectRootPath: string | null,
linkedLinearIssue: string | undefined
): void {
if (!linkedLinearIssue) {
return
}
const localDiscoveryTarget =
target.kind === 'local' && !remote
? projectRuntime
? { projectRuntime, projectRootPath }
: { runtime: 'host' as const, projectRootPath }
: null
void window.api.skills
.ensureManagedReady({
skillName: LINEAR_TICKETS_SKILL_NAME,
context: 'linear-worktree',
...(localDiscoveryTarget ? { discoveryTarget: localDiscoveryTarget } : {}),
...(target.kind === 'environment' || remote ? { remoteRuntime: true } : {})
})
.catch(() => {
// Best-effort setup checks must never block worktree creation.
})
}
function arraysShallowEqual(a: string[] | undefined, b: string[] | undefined): boolean {
if (a === b) {
return true
@@ -1844,6 +1875,18 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
},
{ timeoutMs: 10 * 60_000 }
)
const repo = get().repos.find((entry) => entry.id === repoId)
const projectRuntime =
target.kind === 'local' && !repo?.connectionId
? getLocalRepoProjectExecutionRuntimeContext(get(), repoId)
: undefined
nudgeLinearManagedSkillSetup(
target,
Boolean(repo?.connectionId),
projectRuntime,
result.worktree.path,
linkedLinearIssue
)
// Why: a file watcher (worktrees.onChanged) can fire between the
// backend creating the worktree and this callback running, causing
// fetchWorktrees to add the worktree first. Appending unconditionally
@@ -1400,6 +1400,38 @@ describe('web UI preload API', () => {
)
})
it('returns remote managed skill fallback from web ensure checks without emitting setup', async () => {
const { api } = await installApi()
const fallback = vi.fn()
const unsubscribe = api.skills.onManagedFallback(fallback)
const result = await api.skills.ensureManagedReady({
skillName: 'linear-tickets',
context: 'linear-worktree',
remoteRuntime: true
})
expect(result).toMatchObject({
status: 'fallback',
reason: 'remote-runtime',
skillName: 'linear-tickets'
})
expect(fallback).not.toHaveBeenCalled()
const cooldown = await api.skills.ensureManagedReady({
skillName: 'linear-tickets',
context: 'linear-worktree',
remoteRuntime: true
})
expect(cooldown).toMatchObject({
status: 'fallback',
reason: 'cooldown',
skillName: 'linear-tickets'
})
expect(fallback).not.toHaveBeenCalled()
unsubscribe()
})
it('rejects paired web computer-use status failures instead of marking the helper unavailable', async () => {
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
+53 -2
View File
@@ -25,7 +25,12 @@ import type {
WorkspaceSessionPatch,
WorkspaceSessionState
} from '../../../shared/types'
import type { SkillDiscoveryResult } from '../../../shared/skills'
import type {
ManagedAgentSkillEnsureRequest,
ManagedAgentSkillEnsureResult,
ManagedAgentSkillFallback,
SkillDiscoveryResult
} from '../../../shared/skills'
import {
getDefaultOnboardingState,
getDefaultSettings,
@@ -2272,13 +2277,59 @@ function createComputerUsePermissionsApi(): NonNullable<
}
function createSkillsApi(): NonNullable<Partial<PreloadApi>['skills']> {
const fallbackCallbacks = new Set<(event: ManagedAgentSkillFallback) => void>()
const cooldownUntilByKey = new Map<string, number>()
const cooldownMs = 60_000
return {
discover: () =>
callRuntimeResult<SkillDiscoveryResult>('skills.discover', undefined, 15_000).catch(() => ({
skills: [],
sources: [],
scannedAt: Date.now()
}))
})),
ensureManagedReady: (request: ManagedAgentSkillEnsureRequest) => {
const uiKey = ['remote', '', request.skillName, request.context].join(':')
const cooldownUntil = cooldownUntilByKey.get(uiKey)
if (!request.force && cooldownUntil && cooldownUntil > Date.now()) {
return Promise.resolve({
status: 'fallback',
skillName: request.skillName,
context: request.context,
runtime: 'remote',
scope: 'missing',
reason: 'cooldown',
uiKey,
message: translate(
'auto.web.web.preload.api.remoteManagedSkillCooldown',
'Orca recently tried this managed-skill check and is cooling down.'
),
request
} satisfies ManagedAgentSkillEnsureResult)
}
const result = {
status: 'fallback',
skillName: request.skillName,
context: request.context,
runtime: 'remote',
scope: 'missing',
reason: 'remote-runtime',
uiKey,
message: translate(
'auto.web.web.preload.api.remoteManagedSkillRuntime',
'Remote runtimes are not updated in the background.'
),
request
} satisfies ManagedAgentSkillFallback
cooldownUntilByKey.set(uiKey, Date.now() + cooldownMs)
return Promise.resolve(result satisfies ManagedAgentSkillEnsureResult)
},
onManagedFallback: (callback) => {
fallbackCallbacks.add(callback)
return () => {
fallbackCallbacks.delete(callback)
}
},
onManagedUpdated: () => () => {}
}
}
@@ -12,22 +12,28 @@ import {
describe('agent feature skill commands', () => {
it('builds single-skill update commands', () => {
expect(buildAgentFeatureSkillUpdateCommand('orchestration')).toBe(
'npx skills update orchestration --global'
'npx --yes skills update orchestration --global --yes'
)
})
it('trims and rejects blank update skill names', () => {
expect(buildAgentFeatureSkillUpdateCommand(' orca-cli ')).toBe(
'npx skills update orca-cli --global'
'npx --yes skills update orca-cli --global --yes'
)
expect(() => buildAgentFeatureSkillUpdateCommand(' ')).toThrow('A skill name is required.')
})
it('exports single-skill update constants without changing install bundles', () => {
expect(ORCA_CLI_SKILL_UPDATE_COMMAND).toBe('npx skills update orca-cli --global')
expect(COMPUTER_USE_SKILL_UPDATE_COMMAND).toBe('npx skills update computer-use --global')
expect(ORCHESTRATION_SKILL_UPDATE_COMMAND).toBe('npx skills update orchestration --global')
expect(LINEAR_TICKETS_SKILL_UPDATE_COMMAND).toBe('npx skills update linear-tickets --global')
expect(ORCA_CLI_SKILL_UPDATE_COMMAND).toBe('npx --yes skills update orca-cli --global --yes')
expect(COMPUTER_USE_SKILL_UPDATE_COMMAND).toBe(
'npx --yes skills update computer-use --global --yes'
)
expect(ORCHESTRATION_SKILL_UPDATE_COMMAND).toBe(
'npx --yes skills update orchestration --global --yes'
)
expect(LINEAR_TICKETS_SKILL_UPDATE_COMMAND).toBe(
'npx --yes skills update linear-tickets --global --yes'
)
expect(ORCA_CLI_ORCHESTRATION_SKILL_INSTALL_COMMAND).toBe(
buildAgentFeatureSkillInstallCommand(['orca-cli', 'orchestration'])
)
+2 -2
View File
@@ -9,7 +9,7 @@ export function buildAgentFeatureSkillInstallCommand(skillNames: readonly string
if (skillNames.length === 0) {
throw new Error('At least one skill name is required.')
}
return `npx skills add ${ORCA_SKILLS_REPOSITORY_URL} --skill ${skillNames.join(' ')} --global`
return `npx --yes skills add ${ORCA_SKILLS_REPOSITORY_URL} --skill ${skillNames.join(' ')} --global --yes`
}
export function buildAgentFeatureSkillUpdateCommand(skillName: string): string {
@@ -17,7 +17,7 @@ export function buildAgentFeatureSkillUpdateCommand(skillName: string): string {
if (!trimmedSkillName) {
throw new Error('A skill name is required.')
}
return `npx skills update ${trimmedSkillName} --global`
return `npx --yes skills update ${trimmedSkillName} --global --yes`
}
export const ORCA_CLI_SKILL_INSTALL_COMMAND = buildAgentFeatureSkillInstallCommand([
+1
View File
@@ -311,6 +311,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
agentDefaultEnv: { ...DEFAULT_TUI_AGENT_ENV },
agentYoloDefaultsMigrated: true,
agentStatusHooksEnabled: true,
managedAgentSkillBackgroundUpdatesEnabled: true,
tabAutoGenerateTitle: false,
confirmClosePinnedTab: true,
keepComputerAwakeWhileAgentsRun: false,
+114
View File
@@ -1,4 +1,10 @@
import type { ProjectExecutionRuntimeResolution } from './project-execution-runtime'
import type {
COMPUTER_USE_SKILL_NAME,
LINEAR_TICKETS_SKILL_NAME,
ORCA_CLI_SKILL_NAME,
ORCHESTRATION_SKILL_NAME
} from './agent-feature-install-commands'
export type SkillProvider = 'codex' | 'claude' | 'agent-skills'
@@ -13,7 +19,11 @@ export type DiscoveredSkill = {
sourceLabel: string
rootPath: string
directoryPath: string
realDirectoryPath?: string | null
directoryIsSymlink?: boolean
skillFilePath: string
realSkillFilePath?: string | null
skillFileIsSymlink?: boolean
installed: boolean
fileCount: number
updatedAt: number | null
@@ -39,9 +49,113 @@ export type SkillDiscoveryTarget = {
runtime?: 'host' | 'wsl'
wslDistro?: string | null
projectRuntime?: ProjectExecutionRuntimeResolution
projectRootPath?: string | null
}
export type SkillFrontmatterSummary = {
name: string | null
description: string | null
}
export type ManagedAgentSkillName =
| typeof COMPUTER_USE_SKILL_NAME
| typeof LINEAR_TICKETS_SKILL_NAME
| typeof ORCA_CLI_SKILL_NAME
| typeof ORCHESTRATION_SKILL_NAME
export type ManagedAgentSkillContext =
| 'linear-worktree'
| 'agent-orchestration'
| 'agent-computer-use'
| 'agent-orca-cli'
export type ManagedAgentSkillRuntime = 'host' | 'wsl' | 'remote' | 'unknown'
export type ManagedAgentSkillScope = 'global' | 'project' | 'bundled' | 'plugin' | 'missing'
export type ManagedAgentSkillFallbackReason =
| 'target-required'
| 'unsupported-skill'
| 'repair-required-runtime'
| 'remote-runtime'
| 'wsl-runtime'
| 'missing-install'
| 'project-install'
| 'ambiguous-install'
| 'bundled-or-plugin-install'
| 'symlinked-global-install'
| 'unsupported-cli-contract'
| 'expected-hash-missing'
| 'lockfile-missing'
| 'lockfile-malformed'
| 'lockfile-unsupported-schema'
| 'lock-entry-missing'
| 'lock-entry-unmanaged-source'
| 'background-update-disabled'
| 'cooldown'
| 'update-failed'
| 'update-timeout'
export type ManagedAgentSkillManualCommand = {
kind: 'install' | 'update'
command: string
runtime: ManagedAgentSkillRuntime
scope: Extract<ManagedAgentSkillScope, 'global' | 'project'>
}
export type ManagedAgentSkillEnsureRequest = {
skillName: ManagedAgentSkillName
context: ManagedAgentSkillContext
discoveryTarget?: SkillDiscoveryTarget
remoteRuntime?: boolean
force?: boolean
}
export type ManagedAgentSkillFallback = {
status: 'fallback'
skillName: ManagedAgentSkillName
context: ManagedAgentSkillContext
runtime: ManagedAgentSkillRuntime
distro?: string | null
scope: ManagedAgentSkillScope
reason: ManagedAgentSkillFallbackReason
uiKey: string
message: string
manualCommand?: ManagedAgentSkillManualCommand
request: ManagedAgentSkillEnsureRequest
}
export type ManagedAgentSkillReady = {
status: 'ready'
skillName: ManagedAgentSkillName
context: ManagedAgentSkillContext
runtime: ManagedAgentSkillRuntime
distro?: string | null
scope: Extract<ManagedAgentSkillScope, 'global'>
}
export type ManagedAgentSkillUpdated = {
status: 'updated'
skillName: ManagedAgentSkillName
context: ManagedAgentSkillContext
runtime: ManagedAgentSkillRuntime
distro?: string | null
scope: Extract<ManagedAgentSkillScope, 'global'>
}
export type ManagedAgentSkillEnsureResult =
| ManagedAgentSkillFallback
| ManagedAgentSkillReady
| ManagedAgentSkillUpdated
export function shouldEmitManagedAgentSkillFallback(
result: ManagedAgentSkillEnsureResult
): result is ManagedAgentSkillFallback {
return (
result.status === 'fallback' &&
result.reason !== 'cooldown' &&
// Why: the modal is an install/update surface; fallbacks without a command
// only create a dead-end Re-check loop.
Boolean(result.manualCommand)
)
}
+3
View File
@@ -2636,6 +2636,9 @@ export type GlobalSettings = {
/** Why: disabling must persist so startup does not reinstall global agent
* hook entries right after the user removes them from Settings or CLI. */
agentStatusHooksEnabled: boolean
/** Why: managed Orca skills are part of the agent runtime surface. Default-on
* permits verified background update attempts; off makes updates manual. */
managedAgentSkillBackgroundUpdatesEnabled?: boolean
/** Why: generated tab titles are semantic but subjective, so they stay opt-in
* and manual renames remain the stronger user intent. */
tabAutoGenerateTitle: boolean