Defer managed skill prompts until restart

This commit is contained in:
brennanb2025
2026-07-01 22:35:54 -07:00
parent 8cae748a20
commit 62b28a2f77
17 changed files with 370 additions and 96 deletions
+55 -35
View File
@@ -2,19 +2,23 @@
## 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.
Orca should record when a user or agent enters a workflow that needs an Orca-managed agent skill,
then ask about missing/outdated setup after the next Orca restart. The restart-time check should
update automatically 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 restart
prompt 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 experimental and off by default. Users can opt in 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.
Experimental settings pane; when disabled, Orca still records the same workflow triggers, but stale
global managed-skill updates fall back to the contextual manual update modal on the next restart
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.
- Keep managed Orca skills current without interrupting the workflow that revealed the need.
- 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.
@@ -23,7 +27,8 @@ background.
- 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 run broad skill checks at app startup; only re-check previously recorded managed-skill
workflow needs.
- Do not infer update scope from Orca's current working directory.
## Managed Skills
@@ -37,21 +42,26 @@ Initial support is limited to Orca-owned feature skills:
## Core UX Rule
Auto-update and ask prompts have the same trigger.
Auto-update and ask prompts have the same deferred trigger.
```text
User or agent enters a workflow that needs a managed skill
-> coordinator checks whether background update is safe
-> Orca records that managed skill need for the next launch
-> after Orca restarts, 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.
There is no separate "ask" trigger. Asking is the restart-time fallback path for the same recorded
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. Because the setting is off by default, that same trigger shows the update modal each time an update is needed unless the user has explicitly enabled experimental automatic updates.
The automatic-updates setting does not create a new trigger. It only controls whether an eligible
stale global install may be updated quietly when the recorded request is flushed after restart.
Because the setting is off by default, that same restart-time flush shows the update modal when an
update is needed unless the user has explicitly enabled experimental automatic updates.
## Intent Boundary Triggers
## Deferred Intent Triggers
| Skill | Trigger |
| --- | --- |
@@ -60,16 +70,17 @@ The automatic-updates setting does not create a new trigger. It only controls wh
| `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.
Settings pages, setup guide status cards, and generic skill browsing should not record deferred
automatic updates. They can display status and manual actions.
The Agents settings pane exposes the opt-in experimental preference as "Automatically update
The Experimental settings pane exposes the opt-in experimental preference as "Automatically update
verified Orca skills" with copy explaining that Orca can update verified Orca-managed global
skills in the background only when a workflow needs them and the safe update path has been proven.
Leaving it off means updates are reviewed manually at the same workflow boundaries.
skills in the background only after a workflow has recorded the need and the safe update path has
been proven on restart. Leaving it off means updates are reviewed manually after restart.
## Modal Copy Requirements
When the fallback modal appears from a feature trigger, the modal must name the feature context
When the fallback modal appears after restart, the modal must name the feature context
and workspace. Do not claim that an agent attempted the skill unless the triggering surface can
prove that; runtime and workflow entry points can also be initiated directly by the user.
@@ -151,7 +162,10 @@ Responsibilities:
- 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.
Renderer surfaces should not independently spawn update commands. Workflow surfaces should record a
restart prompt request; the restart flush should call the coordinator and then either clear resolved
requests, show an actionable modal response that includes a manual command, or avoid interrupting
for non-actionable fallback state.
## Performance Guardrails
@@ -163,15 +177,16 @@ The update path must be cheap when many agents are running.
- 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.
- Prefer invalidating "needs check" after an Orca app update, then checking only after a recorded
workflow need is flushed on restart.
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
-> first attempt records orchestration for the next restart
-> repeated attempts dedupe to the same restart prompt request
-> next Orca launch flushes one update/check
```
## Invalidation
@@ -181,7 +196,8 @@ 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
-> next workflow trigger records a restart prompt request
-> next Orca launch checks each recorded skill/runtime/scope once
-> success is cached for the session/app version
```
@@ -205,9 +221,9 @@ Optional future invalidation:
| Private repo or auth-gated source needs credentials | Do not auto-update when source/auth cannot be proven non-interactive; show a manual-review fallback rather than running a command. |
| WSL distro unavailable or project runtime repair-required | Do not run host update commands; show runtime-context fallback guidance and allow re-check after repair. |
| SSH/remote filesystem update writes somewhere unexpected | Do not auto-update remote/SSH skills in v1. |
| Modal appears unexpectedly because a workflow triggered it | Modal copy must name the feature context and workspace without guessing whether an agent or user initiated it. |
| Modal appears unexpectedly because a workflow triggered it | Workflow triggers only record restart prompts; modal copy must name the feature context and workspace without guessing whether an agent or user initiated it. |
| 50 agents cause 50 checks | Central coordinator with in-flight dedupe, success cache, and failure cooldown. |
| User leaves automatic updates off | Keep the same triggers, but return the manual update modal for stale global installs without cooling that disabled-path modal away. |
| User leaves automatic updates off | Keep the same deferred triggers, but return the manual update modal for stale global installs after restart 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. |
@@ -216,13 +232,14 @@ Optional future invalidation:
## 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:
2. Orca records the skill name and workflow context as a restart prompt request.
3. After Orca restarts, the renderer flushes recorded restart prompt requests.
4. Coordinator returns one of:
- `ready`: installed and recently checked.
- `updated`: Orca verified and ran the single-skill global update command successfully.
- `fallback`: return reason and optional command.
4. Workflow continues for `ready` and `updated`.
5. For `fallback`, show the contextual modal for actionable setup/manual-review states and keep
5. For `ready` and `updated`, remove the persisted restart prompt request.
6. For `fallback`, show the contextual modal for actionable setup/manual-review states and keep
cooldown/unsupported internal states silent.
## Implementation Steps
@@ -238,7 +255,9 @@ Optional future invalidation:
- failure cooldowns
4. Add IPC:
- `skills:ensureManagedReady`
5. Wire the intent boundary triggers:
- `skills:deferManagedReadyPrompt`
- `skills:flushRestartPrompts`
5. Wire the deferred intent boundary triggers:
- Linear worktree-from-task and linked Linear agent workflow.
- Orchestration workflow start.
- Computer Use workflow start or first runtime invocation.
@@ -258,17 +277,18 @@ Optional future invalidation:
- 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.
- Default-disabled automatic updates show the manual update modal on each stale-update trigger.
- Default-disabled automatic updates show the manual update modal after restart for each recorded
stale-update trigger.
- App-version invalidation re-enables a single check after update.
- Agent-triggered fallback modal includes triggering context.
- Agent-triggered fallback modal includes the recorded triggering context after restart.
## Recommended First Version
Ship the coordinator and intent-boundary triggers with modal-first behavior by default. Keep
Ship the coordinator and deferred intent-boundary triggers with restart-prompt behavior by default. Keep
automatic updates available as an experimental opt-in for tracked global Orca-managed installs
only. When enabled, the coordinator runs the explicit single-skill global update command after
discovery and lockfile validation, then verifies the post-update install before reporting
`updated`. Everything else uses the same trigger and returns fallback state; actionable fallback
states open a contextual modal.
restart-time discovery and lockfile validation, then verifies the post-update install before
reporting `updated`. Everything else uses the same deferred trigger and returns fallback state;
actionable fallback states open a contextual modal after restart.
This covers the common setup path from Orca's own install buttons while protecting custom, project, remote, and ambiguous installs.
+2 -9
View File
@@ -14,7 +14,6 @@ import {
getCanonicalUserDataPath,
migrateMobilePairingDataToCanonicalUserDataPath
} from './persistence'
import { shouldEmitManagedAgentSkillFallback } from '../shared/skills'
import { applyAppIcon } from './app-icon'
import { StatsCollector, initStatsPath } from './stats/collector'
import { ClaudeUsageStore, initClaudeUsagePath } from './claude-usage/store'
@@ -28,7 +27,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 { recordManagedSkillRestartPromptRequest } from './skills/managed-skill-restart-prompts'
import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce } from './telemetry/client'
import { runManagedHookInstallers } from './agent-hooks/install-telemetry'
import {
@@ -43,7 +42,6 @@ import { triggerStartupNotificationRegistration } from './ipc/notifications'
import { OrcaRuntimeService } from './runtime/orca-runtime'
import { OrcaRuntimeRpcServer } from './runtime/runtime-rpc'
import { abortManagedSkillUpdateProcesses } from './skills/managed-skill-updates'
import { getManagedSkillUpdateCoordinator } from './skills/managed-skill-update-coordinator-registry'
import { awaitRuntimeFileWatcherUnsubscribes } from './runtime/orca-runtime-files'
import { clearRuntimeMetadataIfOwned } from './runtime/runtime-metadata'
import { ensureMainI18n, setMainUiLanguage } from './i18n/main-i18n'
@@ -1941,17 +1939,12 @@ app.whenReady().then(async () => {
managedSkillDiscoveryTarget: { runtime: 'host' },
managedSkillRemoteRuntime: isServeMode,
nudgeManagedSkill: async ({ skillName, context, remoteRuntime, discoveryTarget }) => {
const result = await getManagedSkillUpdateCoordinator(store!).ensureManagedReady({
recordManagedSkillRestartPromptRequest(store!, {
skillName,
context,
...(remoteRuntime ? { remoteRuntime } : {}),
...(!remoteRuntime && discoveryTarget ? { discoveryTarget } : {})
})
if (result.status === 'fallback' && shouldEmitManagedAgentSkillFallback(result)) {
sendManagedSkillFallback(result)
} else if (result.status === 'updated') {
sendManagedSkillUpdated(result)
}
}
})
registerMobileHandlers(runtimeRpc)
+8 -38
View File
@@ -21,9 +21,7 @@ const {
subscribeRemoteRuntimeSharedControlRequestMock,
getRemoteRuntimeSharedControlDiagnosticsMock,
closeRemoteRuntimeRequestConnectionMock,
ensureManagedReadyMock,
sendManagedSkillFallbackMock,
sendManagedSkillUpdatedMock
recordManagedSkillRestartPromptRequestMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
onMock: vi.fn(),
@@ -37,9 +35,7 @@ const {
subscribeRemoteRuntimeSharedControlRequestMock: vi.fn(),
getRemoteRuntimeSharedControlDiagnosticsMock: vi.fn(),
closeRemoteRuntimeRequestConnectionMock: vi.fn(),
ensureManagedReadyMock: vi.fn(),
sendManagedSkillFallbackMock: vi.fn(),
sendManagedSkillUpdatedMock: vi.fn()
recordManagedSkillRestartPromptRequestMock: vi.fn()
}))
vi.mock('electron', () => ({
@@ -65,15 +61,8 @@ vi.mock('./runtime-environment-request-connections', () => ({
closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock
}))
vi.mock('../skills/managed-skill-update-coordinator-registry', () => ({
getManagedSkillUpdateCoordinator: () => ({
ensureManagedReady: ensureManagedReadyMock
})
}))
vi.mock('./skills', () => ({
sendManagedSkillFallback: sendManagedSkillFallbackMock,
sendManagedSkillUpdated: sendManagedSkillUpdatedMock
vi.mock('../skills/managed-skill-restart-prompts', () => ({
recordManagedSkillRestartPromptRequest: recordManagedSkillRestartPromptRequestMock
}))
import { registerRuntimeEnvironmentHandlers } from './runtime-environments'
@@ -114,10 +103,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
getRemoteRuntimeSharedControlDiagnosticsMock.mockReset()
getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue(null)
closeRemoteRuntimeRequestConnectionMock.mockReset()
ensureManagedReadyMock.mockReset()
ensureManagedReadyMock.mockResolvedValue({ status: 'ready' })
sendManagedSkillFallbackMock.mockReset()
sendManagedSkillUpdatedMock.mockReset()
recordManagedSkillRestartPromptRequestMock.mockReset()
})
afterEach(() => {
@@ -421,16 +407,8 @@ describe('registerRuntimeEnvironmentHandlers', () => {
expect(sendRemoteRuntimeConnectionRequestMock).not.toHaveBeenCalled()
})
it('nudges managed skill readiness after remote runtime feature RPCs and emits fallback setup', async () => {
it('records managed skill restart prompts after remote runtime feature RPCs', 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,
@@ -457,20 +435,12 @@ describe('registerRuntimeEnvironmentHandlers', () => {
).resolves.toMatchObject({ ok: true })
await vi.waitFor(() => {
expect(ensureManagedReadyMock).toHaveBeenCalledWith({
expect(recordManagedSkillRestartPromptRequestMock).toHaveBeenCalledWith(expect.anything(), {
skillName: 'orchestration',
context: 'agent-orchestration',
remoteRuntime: true
})
})
expect(sendManagedSkillFallbackMock).toHaveBeenCalledWith({
status: 'fallback',
skillName: 'orchestration',
context: 'agent-orchestration',
runtime: 'host',
scope: 'missing',
reason: 'remote-runtime'
})
})
it('nudges orca-cli readiness after remote emulator runtime RPCs', async () => {
@@ -501,7 +471,7 @@ describe('registerRuntimeEnvironmentHandlers', () => {
).resolves.toMatchObject({ ok: true })
await vi.waitFor(() => {
expect(ensureManagedReadyMock).toHaveBeenCalledWith({
expect(recordManagedSkillRestartPromptRequestMock).toHaveBeenCalledWith(expect.anything(), {
skillName: 'orca-cli',
context: 'agent-orca-cli',
remoteRuntime: true
+2 -9
View File
@@ -1,21 +1,14 @@
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-update-coordinator-registry'
import { sendManagedSkillFallback, sendManagedSkillUpdated } from './skills'
import { recordManagedSkillRestartPromptRequest } from '../skills/managed-skill-restart-prompts'
export function createRuntimeManagedSkillNudge(store: Store): RuntimeManagedSkillNudgeHandler {
return async ({ skillName, context, remoteRuntime, discoveryTarget }) => {
const result = await getManagedSkillUpdateCoordinator(store).ensureManagedReady({
recordManagedSkillRestartPromptRequest(store, {
skillName,
context,
...(remoteRuntime ? { remoteRuntime } : {}),
...(!remoteRuntime && discoveryTarget ? { discoveryTarget } : {})
})
if (shouldEmitManagedAgentSkillFallback(result)) {
sendManagedSkillFallback(result)
} else if (result.status === 'updated') {
sendManagedSkillUpdated(result)
}
}
}
+93 -1
View File
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../shared/constants'
import type { GlobalSettings } from '../../shared/types'
const { handleMock, discoverSkillsMock, getDefaultWslDistroMock, getWslHomeMock } = vi.hoisted(
() => ({
@@ -33,9 +34,14 @@ import { registerSkillsHandlers } from './skills'
describe('registerSkillsHandlers', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
const repos = [{ id: 'repo-1', path: 'C:\\Users\\alice\\repo' }]
let settings = getDefaultSettings('C:\\Users\\alice')
const store = {
getRepos: vi.fn(() => repos),
getSettings: vi.fn(() => getDefaultSettings('C:\\Users\\alice'))
getSettings: vi.fn(() => settings),
updateSettings: vi.fn((updates: Partial<GlobalSettings>) => {
settings = { ...settings, ...updates }
return settings
})
}
beforeEach(() => {
@@ -43,8 +49,10 @@ describe('registerSkillsHandlers', () => {
discoverSkillsMock.mockReset()
getDefaultWslDistroMock.mockReset()
getWslHomeMock.mockReset()
settings = getDefaultSettings('C:\\Users\\alice')
store.getRepos.mockClear()
store.getSettings.mockClear()
store.updateSettings.mockClear()
discoverSkillsMock.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 })
getWslHomeMock.mockReturnValue('\\\\wsl.localhost\\Ubuntu\\home\\alice')
Object.defineProperty(process, 'platform', {
@@ -82,6 +90,28 @@ describe('registerSkillsHandlers', () => {
) => Promise<unknown>
}
function getDeferHandler() {
registerSkillsHandlers(store as never)
const call = handleMock.mock.calls.find(
(entry: unknown[]) => entry[0] === 'skills:deferManagedReadyPrompt'
)
if (!call) {
throw new Error('skills:deferManagedReadyPrompt handler was not registered')
}
return call[1] as (_event: unknown, request: unknown) => Promise<unknown>
}
function getFlushHandler() {
registerSkillsHandlers(store as never)
const call = handleMock.mock.calls.find(
(entry: unknown[]) => entry[0] === 'skills:flushRestartPrompts'
)
if (!call) {
throw new Error('skills:flushRestartPrompts handler was not registered')
}
return call[1] as (event: { sender: { send: ReturnType<typeof vi.fn> } }) => Promise<unknown>
}
it('uses host skill discovery when resolved project runtime overrides stale WSL target state', async () => {
const handler = getDiscoverHandler()
@@ -191,6 +221,68 @@ describe('registerSkillsHandlers', () => {
expect(discoverSkillsMock).not.toHaveBeenCalled()
})
it('defers managed setup prompts into settings without emitting immediately', async () => {
const handler = getDeferHandler()
await handler(null, {
skillName: 'orca-linear',
context: 'linear-worktree',
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
})
expect(store.updateSettings).toHaveBeenCalledWith({
managedAgentSkillRestartPromptRequests: [
{
skillName: 'orca-linear',
context: 'linear-worktree',
discoveryTarget: { runtime: 'host', projectRootPath: '/workspace/current' }
}
]
})
})
it('flushes restart prompts through managed ensure and emits actionable fallbacks', async () => {
const handler = getFlushHandler()
const send = vi.fn()
settings = {
...settings,
managedAgentSkillRestartPromptRequests: [
{
skillName: 'computer-use',
context: 'agent-computer-use',
remoteRuntime: true
}
]
}
const results = await handler({ sender: { send } })
expect(results).toMatchObject([
{
status: 'fallback',
skillName: 'computer-use',
context: 'agent-computer-use',
reason: 'remote-runtime'
}
])
expect(send).toHaveBeenCalledWith(
'skills:managedFallback',
expect.objectContaining({
status: 'fallback',
skillName: 'computer-use',
context: 'agent-computer-use',
reason: 'remote-runtime'
})
)
expect(settings.managedAgentSkillRestartPromptRequests).toEqual([
{
skillName: 'computer-use',
context: 'agent-computer-use',
remoteRuntime: true
}
])
})
it('does not emit managed fallback events for cooldown results', async () => {
const handler = getEnsureHandler()
const send = vi.fn()
+27
View File
@@ -14,6 +14,10 @@ import {
publishManagedSkillFallback,
publishManagedSkillUpdated
} from '../skills/managed-skill-events'
import {
flushManagedSkillRestartPromptRequests,
recordManagedSkillRestartPromptRequest
} from '../skills/managed-skill-restart-prompts'
type SkillDiscoveryRuntimeTarget =
| { runtime: 'host' }
@@ -84,6 +88,29 @@ export function registerSkillsHandlers(store: Store): void {
return result
}
)
ipcMain.handle(
'skills:deferManagedReadyPrompt',
(_event, request: ManagedAgentSkillEnsureRequest): void => {
recordManagedSkillRestartPromptRequest(store, request)
}
)
ipcMain.handle(
'skills:flushRestartPrompts',
async (event): Promise<ManagedAgentSkillEnsureResult[]> => {
const results = await flushManagedSkillRestartPromptRequests(store)
for (const result of results) {
if (shouldEmitManagedAgentSkillFallback(result)) {
publishManagedSkillFallback(result)
event.sender.send('skills:managedFallback', result)
} else if (result.status === 'updated') {
sendManagedSkillUpdated(result)
}
}
return results
}
)
}
export function sendManagedSkillFallback(result: ManagedAgentSkillEnsureResult): void {
+1
View File
@@ -506,6 +506,7 @@ describe('Store', () => {
expect(settings.experimentalTerminalAttention).toBe(false)
expect(settings.experimentalNewWorktreeCardStyle).toBe(true)
expect(settings.managedAgentSkillSetupPromptsEnabled).toBe(true)
expect(settings.managedAgentSkillRestartPromptRequests).toEqual([])
expect(settings.managedAgentSkillBackgroundUpdatesEnabled).toBe(false)
expect(settings.floatingTerminalEnabled).toBe(true)
expect(settings.floatingTerminalDefaultedForAllUsers).toBe(true)
@@ -0,0 +1,122 @@
import type { Store } from '../persistence'
import type {
ManagedAgentSkillContext,
ManagedAgentSkillEnsureRequest,
ManagedAgentSkillEnsureResult,
SkillDiscoveryTarget
} from '../../shared/skills'
import { shouldEmitManagedAgentSkillFallback } from '../../shared/skills'
import { getManagedSkillUpdateCoordinator } from './managed-skill-update-coordinator-registry'
import { isManagedAgentSkillName } from './managed-skill-update-contract'
const MAX_RESTART_PROMPT_REQUESTS = 20
const MANAGED_SKILL_CONTEXTS = new Set<string>([
'linear-worktree',
'agent-orchestration',
'agent-computer-use',
'agent-orca-cli'
])
export function recordManagedSkillRestartPromptRequest(
store: Store,
request: ManagedAgentSkillEnsureRequest
): void {
const normalizedRequest = normalizeManagedSkillRestartPromptRequest(request)
if (!normalizedRequest) {
return
}
const current = normalizeManagedSkillRestartPromptRequests(
store.getSettings().managedAgentSkillRestartPromptRequests
)
const byKey = new Map(
current.map((entry) => [makeManagedSkillRestartPromptRequestKey(entry), entry])
)
byKey.set(makeManagedSkillRestartPromptRequestKey(normalizedRequest), normalizedRequest)
const next = Array.from(byKey.values()).slice(-MAX_RESTART_PROMPT_REQUESTS)
if (JSON.stringify(next) === JSON.stringify(current)) {
return
}
store.updateSettings({ managedAgentSkillRestartPromptRequests: next })
}
export async function flushManagedSkillRestartPromptRequests(
store: Store
): Promise<ManagedAgentSkillEnsureResult[]> {
const current = normalizeManagedSkillRestartPromptRequests(
store.getSettings().managedAgentSkillRestartPromptRequests
)
const results: ManagedAgentSkillEnsureResult[] = []
const retained: ManagedAgentSkillEnsureRequest[] = []
const coordinator = getManagedSkillUpdateCoordinator(store)
for (const request of current) {
const result = await coordinator.ensureManagedReady({ ...request, force: true })
results.push(result)
if (shouldEmitManagedAgentSkillFallback(result)) {
retained.push(request)
}
}
if (JSON.stringify(retained) !== JSON.stringify(current)) {
store.updateSettings({ managedAgentSkillRestartPromptRequests: retained })
}
return results
}
export function normalizeManagedSkillRestartPromptRequests(
value: unknown
): ManagedAgentSkillEnsureRequest[] {
if (!Array.isArray(value)) {
return []
}
const byKey = new Map<string, ManagedAgentSkillEnsureRequest>()
for (const entry of value) {
const request = normalizeManagedSkillRestartPromptRequest(entry)
if (!request) {
continue
}
byKey.set(makeManagedSkillRestartPromptRequestKey(request), request)
}
return Array.from(byKey.values()).slice(-MAX_RESTART_PROMPT_REQUESTS)
}
function normalizeManagedSkillRestartPromptRequest(
value: unknown
): ManagedAgentSkillEnsureRequest | null {
if (
!isRecord(value) ||
typeof value.skillName !== 'string' ||
!isManagedAgentSkillName(value.skillName)
) {
return null
}
if (typeof value.context !== 'string' || !MANAGED_SKILL_CONTEXTS.has(value.context)) {
return null
}
const request: ManagedAgentSkillEnsureRequest = {
skillName: value.skillName,
context: value.context as ManagedAgentSkillContext
}
if (value.remoteRuntime === true) {
request.remoteRuntime = true
}
if (isRecord(value.discoveryTarget)) {
request.discoveryTarget = value.discoveryTarget as SkillDiscoveryTarget
}
return request
}
function makeManagedSkillRestartPromptRequestKey(
request: ManagedAgentSkillEnsureRequest
): string {
return JSON.stringify({
skillName: request.skillName,
context: request.context,
remoteRuntime: request.remoteRuntime === true,
discoveryTarget: request.discoveryTarget ?? null
})
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
+2
View File
@@ -2033,6 +2033,8 @@ export type PreloadApi = {
ensureManagedReady: (
request: ManagedAgentSkillEnsureRequest
) => Promise<ManagedAgentSkillEnsureResult>
deferManagedReadyPrompt: (request: ManagedAgentSkillEnsureRequest) => Promise<void>
flushRestartPrompts: () => Promise<ManagedAgentSkillEnsureResult[]>
onManagedFallback: (callback: (event: ManagedAgentSkillFallback) => void) => () => void
onManagedUpdated: (callback: (event: ManagedAgentSkillUpdated) => void) => () => void
}
+6
View File
@@ -2015,6 +2015,12 @@ const api = {
): Promise<ManagedAgentSkillEnsureResult> =>
ipcRenderer.invoke('skills:ensureManagedReady', request),
deferManagedReadyPrompt: (request: ManagedAgentSkillEnsureRequest): Promise<void> =>
ipcRenderer.invoke('skills:deferManagedReadyPrompt', request),
flushRestartPrompts: (): Promise<ManagedAgentSkillEnsureResult[]> =>
ipcRenderer.invoke('skills:flushRestartPrompts'),
onManagedFallback: (callback: (event: ManagedAgentSkillFallback) => void): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
@@ -260,6 +260,9 @@ describe('ManagedAgentSkillSetupDialogHost rendering', () => {
let fallbackListener: ((event: ManagedAgentSkillFallback) => void) | null = null
window.api = {
skills: {
ensureManagedReady: vi.fn(),
deferManagedReadyPrompt: vi.fn(),
flushRestartPrompts: vi.fn().mockResolvedValue([]),
onManagedFallback: vi.fn((listener) => {
fallbackListener = listener
return vi.fn()
@@ -321,6 +324,29 @@ describe('ManagedAgentSkillSetupDialogHost rendering', () => {
root.unmount()
})
it('flushes deferred restart prompts once after settings hydrate', async () => {
const { root } = await renderHost()
await vi.waitFor(() => {
expect(window.api.skills.flushRestartPrompts).toHaveBeenCalledOnce()
})
root.unmount()
})
it('does not flush deferred restart prompts when prompts are disabled at startup', async () => {
storeMocks.state.settings = {
...getDefaultSettings('/tmp'),
managedAgentSkillSetupPromptsEnabled: false
}
const { root } = await renderHost()
await Promise.resolve()
expect(window.api.skills.flushRestartPrompts).not.toHaveBeenCalled()
root.unmount()
})
it('keeps the modal open when Don’t show again cannot persist', async () => {
storeMocks.state.updateSettings.mockResolvedValue(undefined)
const { root, emitFallback } = await renderHost()
@@ -45,7 +45,9 @@ export function ManagedAgentSkillSetupDialogHost(): React.JSX.Element | null {
})
const queuedKeysRef = useRef(new Set<string>())
const snoozedKeysRef = useRef(new Set<string>())
const flushedRestartPromptsRef = useRef(false)
const [rechecking, setRechecking] = useState(false)
const settings = useAppStore((state) => state.settings)
const setupPromptsEnabled = useAppStore(
(state) => state.settings?.managedAgentSkillSetupPromptsEnabled !== false
)
@@ -105,6 +107,18 @@ export function ManagedAgentSkillSetupDialogHost(): React.JSX.Element | null {
}
}, [enqueueFallback])
useEffect(() => {
if (!settings || flushedRestartPromptsRef.current) {
return
}
flushedRestartPromptsRef.current = true
if (!setupPromptsEnabled) {
return
}
// Why: workflow-triggered setup nudges are persisted and surfaced only after restart.
void window.api.skills.flushRestartPrompts().catch(() => {})
}, [settings, setupPromptsEnabled])
const contextActionLabel =
active?.manualCommand?.kind === 'install'
? translate('auto.components.skills.ManagedAgentSkillSetupDialogHost.install', 'Install')
@@ -84,6 +84,7 @@ const mockApi = {
get: vi.fn(() => ({ platform: 'win32' }))
},
skills: {
deferManagedReadyPrompt: vi.fn().mockResolvedValue(undefined),
ensureManagedReady: vi.fn().mockResolvedValue({
status: 'fallback',
skillName: 'orca-linear',
@@ -2865,7 +2866,7 @@ describe('createWorktree base status merge', () => {
'ENG-123'
)
expect(mockApi.skills.ensureManagedReady).toHaveBeenCalledWith({
expect(mockApi.skills.deferManagedReadyPrompt).toHaveBeenCalledWith({
skillName: 'orca-linear',
context: 'linear-worktree',
remoteRuntime: true
@@ -2908,7 +2909,7 @@ describe('createWorktree base status merge', () => {
'ENG-123'
)
expect(mockApi.skills.ensureManagedReady).toHaveBeenCalledWith({
expect(mockApi.skills.deferManagedReadyPrompt).toHaveBeenCalledWith({
skillName: 'orca-linear',
context: 'linear-worktree',
discoveryTarget: expect.objectContaining({
@@ -2954,7 +2955,7 @@ describe('createWorktree base status merge', () => {
'ENG-123'
)
expect(mockApi.skills.ensureManagedReady).toHaveBeenCalledWith({
expect(mockApi.skills.deferManagedReadyPrompt).toHaveBeenCalledWith({
skillName: 'orca-linear',
context: 'linear-worktree',
discoveryTarget: {
+1 -1
View File
@@ -180,7 +180,7 @@ function nudgeLinearManagedSkillSetup(
: { runtime: 'host' as const, projectRootPath }
: null
void window.api.skills
.ensureManagedReady({
.deferManagedReadyPrompt({
skillName: ORCA_LINEAR_SKILL_NAME,
context: 'linear-worktree',
...(localDiscoveryTarget ? { discoveryTarget: localDiscoveryTarget } : {}),
+2
View File
@@ -2606,6 +2606,8 @@ function createSkillsApi(): NonNullable<Partial<PreloadApi>['skills']> {
}
return Promise.resolve(result satisfies ManagedAgentSkillEnsureResult)
},
deferManagedReadyPrompt: () => Promise.resolve(),
flushRestartPrompts: () => Promise.resolve([]),
onManagedFallback: (callback) => {
fallbackCallbacks.add(callback)
ensureManagedEventSubscription()
+1
View File
@@ -325,6 +325,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
agentYoloDefaultsMigrated: true,
agentStatusHooksEnabled: true,
managedAgentSkillSetupPromptsEnabled: true,
managedAgentSkillRestartPromptRequests: [],
// Why: keep silent skill mutation opt-in while the verified updater is experimental.
managedAgentSkillBackgroundUpdatesEnabled: false,
tabAutoGenerateTitle: false,
+4
View File
@@ -40,6 +40,7 @@ import type {
GlobalWindowsRuntimeDefault,
LocalWindowsRuntimePreference
} from './project-execution-runtime'
import type { ManagedAgentSkillEnsureRequest } from './skills'
// Re-exported for backward compat with renderer call sites that import
// `WorkspaceCreateTelemetrySource` from '../../../shared/types'.
@@ -2762,6 +2763,9 @@ export type GlobalSettings = {
/** Why: users can silence managed-skill setup prompts from the modal, but
* the prompt should be easy to restore from normal Agents settings. */
managedAgentSkillSetupPromptsEnabled?: boolean
/** Why: workflow-triggered skill setup nudges should surface after restart,
* not while the user is entering the workflow that caused the check. */
managedAgentSkillRestartPromptRequests?: ManagedAgentSkillEnsureRequest[]
/** Why: verified background updates mutate user skill files, so this remains
* opt-in while experimental; off makes updates manual. */
managedAgentSkillBackgroundUpdatesEnabled?: boolean