Implement data recovery for failed agent catalog v1 migrations

Add recovery-point inventory and atomic restore, data-recovery IPC surface,
and app-level migration-blocked notice that persists until retry succeeds or
a pre-v1 backup is restored. Offline CLI writes are schema-transparent: they
refuse pre-v1 file downgrades and never stamp v1 fields without the pinned
backup contract. Mobile surfaces the blocked state in the agent picker.
Web clients probe host identity-launch capability and degrade to legacy paths
on pre-identity hosts, failing fast for custom agents or stored defaults that
cannot resolve.
This commit is contained in:
Jinjing
2026-09-01 03:53:17 -07:00
parent a86f0c310b
commit 4576ddf77f
41 changed files with 1800 additions and 1469 deletions
+1 -3
View File
@@ -361,9 +361,7 @@ jobs:
# into the REMOTE worktree, and observes the spawned process's argv+env from
# /proc inside the container — proving one host resolution produced one remote
# PTY with the exact custom executable/args/env, and that reconnect never
# duplicates the launch. Ubuntu-only because macOS/Windows GitHub runners
# cannot host a Linux Docker container (the platform matrix in pr.yml covers
# the pure-resolution cross-OS cells instead).
# duplicates the launch.
ssh-custom-agent:
name: e2e ssh custom agent
needs: build
+1
View File
@@ -18,6 +18,7 @@
"../src/main/agent-hooks/runtime-home-hook-command.ts",
"../src/main/agent-hooks/windows-powershell-hook-launcher.ts",
"../src/main/amp/agent-status-plugin-source.ts",
"../src/main/agent-launch/agent-catalog-schema-migration.ts",
"../src/main/amp/hook-service.ts",
"../src/main/amp/managed-plugin-install-status.ts",
"../src/main/antigravity/hook-events.ts",
@@ -9,7 +9,7 @@ rollback contract) and acceptance oracle 39.
The v1 data file is **forward-readable but not honestly writable** by an older Orca
binary: pre-v1 code does not understand custom identities in defaults, owner
records, or resume attribution. Runtime compatibility projections protect old
*clients*; they do not make a downgraded desktop binary schema-aware.
_clients_; they do not make a downgraded desktop binary schema-aware.
## Rules
@@ -20,18 +20,102 @@ records, or resume attribution. Runtime compatibility projections protect old
must not resolve a feature incident this way.
3. **Explicit user downgrade = restore the pinned backup** (below). This is the
only supported pre-v1 binary rollback and discards settings/workspace metadata
written after the backup point.
written after the backup point. Users perform this through **Data recovery**;
direct file manipulation is a support-only fallback.
4. **Offline CLI writes are schema-transparent.** CLI commands that edit the
data file while the app is not running (e.g. `orca agent hooks on|off`) must
never add, upgrade, or default any agent-catalog schema field, and must
refuse the write when the file carries a newer schema than the binary knows.
Only the app's migration path may perform the first v1 write.
5. **The hard protocol-floor bump ships only after the rollback window closes.**
While a forward-rollback build is a supported production state, raising
`MIN_COMPATIBLE_DESKTOP_VERSION` (or retiring the
`agent-launch.identity.v1` soft capability phase) would strand updated
clients against a rolled-back host.
## The pinned pre-v1 backup
- Written once, before the first v1 write, beside the rotating backups at
`<data-file>.pre-agent-catalog-v1.backup` (`pinnedPreV1BackupPath`).
- Same filesystem permissions as the data file; `fsync` + atomic rename.
- If it cannot be created, migration performs **no v1 write** and Settings reports a
local migration error; launch behavior stays on the clean built-in baseline.
- Never synced, never exposed through RPC, never given weaker permissions.
- If it cannot be created, migration performs **no v1 write**; launch behavior
stays on the clean built-in baseline and catalog/reference writes remain
blocked until migration succeeds.
- Never synced, never given weaker permissions, and never exposed to the renderer
as raw contents or a filesystem path. The renderer receives only recovery-point
metadata and invokes main-process-owned retry/restore operations.
- Removed only after the documented one-release rollback window (see follow-up).
## Migration user experience
The v1 migration runs during profile load; users do not need to open Settings to
start it or learn that it failed. A successful migration is silent. A failed
backup or migration produces a persistent, app-level notice
(`DataRecoveryMigrationNotice`) as soon as the profile is loaded. The notice
explains that:
- Orca left the existing data unchanged;
- built-in agent launches remain available; and
- custom-agent catalog and reference changes are read-only until recovery.
The notice provides **Retry migration** and **Open Data recovery** actions plus
copyable local error details. It remains visible across navigation and relaunches
until retry succeeds or the user restores a recovery point. Settings mirrors the
same status and actions, but is not the only place that reports the failure.
## General Data recovery UI
Backup restoration is a reusable main-process capability
(`src/main/data-recovery/recovery-points.ts`), not an agent-catalog-specific
file-copy instruction. **Data recovery** inventories recovery points for this
and future migrations (new migrations register their points there). Each entry
shows its migration identifier, creation time, compatibility target, and a
plain-language summary of data that will be lost. The renderer receives
metadata only — never a filesystem path or backup contents.
After explicit confirmation, the main process validates the selected backup,
suspends writes, creates a separate pre-restore safety copy, and atomically
replaces the live data file. Cancellation or failure leaves both the current file
and recovery point intact and reports an actionable local error.
For a recovery point compatible with the running build, the final action is
**Restore and restart**. For the pinned pre-v1 point, it is **Prepare downgrade**:
Orca restores the point and quits without relaunching, preventing the current v1
build from immediately migrating the restored file again.
## Remote hosts (`orca serve`)
Catalogs are per-host. A serve host runs the same profile load, migration, and
pinned backup against its own data file on its own disk; every rule above
applies to each host independently. A desktop and a remote host are rolled back
separately and may skew — runtime projections and the protocol floor cover the
skew, not this runbook.
Serve-host contract:
- The migration-blocked state is projected env-free over runtime RPC as a
`migrationBlocked` boolean on the remote catalog snapshot — never the local
error text — so a connected client (mobile, paired web, remote desktop) can
show that the host's catalog is read-only pending recovery. Mobile surfaces
it in the agent picker.
- The headless downgrade replaces **Prepare downgrade**: stop the serve process
and disable any supervisor auto-restart, restore the pinned backup, install
the pre-v1 binary, then start the service. A supervisor that relaunches the
v1 binary after the restore re-runs the migration and defeats the downgrade.
- New desktop/paired-web clients follow the same probe-and-degrade contract as
mobile and the CLI (compat design, client column): probe
`agent-launch.identity.v1` before sending an identity-only `agentLaunch`
(`worktree-create-launch-compat.ts`); a pre-identity host gets the legacy
built-in id fallback, and custom/default selections fail fast with an
update-the-host error.
## Mobile clients
Mobile needs no rollback procedure of its own: it never writes the catalog,
holds only an in-memory env-free per-host cache, and old clients are protected
by the legacy projections and the capability/protocol floor. Its half of a host
incident is displaying the projected migration-blocked state above.
## Crash safety (oracle 39)
Schema migration, backup creation, catalog/reference mutation, and snapshot
@@ -42,20 +126,50 @@ mid-migration always finds an intact v0 file to restart from.
## Explicit downgrade procedure
1. Quit Orca.
2. Copy `<data-file>.pre-agent-catalog-v1.backup` over `<data-file>`.
3. Install the pre-v1 binary and relaunch.
1. Open **Data recovery** from the app-level migration notice or Settings.
2. Select the recovery point labeled as the state before agent-catalog v1.
3. Choose **Prepare downgrade**, review the post-backup data-loss summary, and
confirm. Orca restores the point atomically and quits.
4. Install the pre-v1 binary before opening Orca again, then relaunch.
The restored file is byte-identical to the pre-v1 state; all post-backup metadata
is intentionally discarded.
### Support-only manual fallback
Use this only when Orca cannot open Data recovery:
1. Quit every running Orca process, including terminal daemons and any `orca`
CLI invocation — a v1-era CLI's offline write between restore and downgrade
can re-stamp the schema.
2. Preserve the current `<data-file>` separately, then copy
`<data-file>.pre-agent-catalog-v1.backup` over `<data-file>`.
3. Install the pre-v1 binary before opening Orca again, then relaunch.
Support must verify the profile and paths before copying; the UI flow above is the
normal user procedure.
## Verification
Rollback verification runs on a **disposable** profile, never on user data:
`src/main/agent-launch/agent-catalog-forward-rollback-fixture.test.ts` exercises
migrate → v1-reference resolve → forward-rollback resolve → pinned-backup restore →
crash-before-write restart. Unit field-mapping coverage lives in
`agent-catalog-schema-migration.test.ts`.
`agent-catalog-schema-migration.test.ts`. CLI offline-write schema transparency
(Rule 4) is covered in `src/cli/handlers/agent-hooks.test.ts`; load-time
surfacing of the blocked state in `agent-catalog-service.test.ts` and
`AgentCatalogSection.test.tsx`; retry in
`persistence-agent-catalog-migration.test.ts`; inventory/atomic
restore/safety-copy/failure-unfreeze in
`src/main/data-recovery/recovery-points.test.ts`; the app-level notice, retry
actions, and Prepare-downgrade flow in `DataRecoveryMigrationNotice.test.tsx`;
the client probe-and-degrade in `worktree-create-launch-compat.test.ts`; the
serve `migrationBlocked` projection in `agent-catalog-service.test.ts` and
`mobile-agent-catalog-projection.test.ts`.
Before release, end-to-end coverage should still exercise the full
restore-quit-relaunch cycle against a packaged build (component/unit tests
above stop at the IPC boundary).
## Follow-up (fill before merge)
+21 -21
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useMemo, useRef, useState } from 'react'
import { Keyboard } from 'react-native'
import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection'
import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection'
@@ -7,10 +7,8 @@ import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-mo
import { deriveRepoSlug, type PasteRepoCandidate } from '../tasks/smart-source-paste-intent'
import { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
import { useNewWorktreeRuntimeCapabilities } from '../tasks/worktree-create-capability'
import {
buildRetiredWorktreeNamesRefreshKey,
useRetiredWorktreeNames
} from '../worktree/use-retired-worktree-names'
import { useRetiredWorktreeNames } from '../worktree/use-retired-worktree-names'
import { hostAgentCatalogReadOnlyNotice } from '../tasks/mobile-agent-catalog-projection'
import { BottomDrawerModalHost } from './bottom-drawer-modal-host'
import {
getMobileWorkspaceRepoBadgeColor,
@@ -34,25 +32,26 @@ import { useNewWorkspaceSetupScript } from './use-new-workspace-setup-script'
import { useNewWorktreeDrawerNavigation } from './use-new-worktree-drawer-navigation'
export function NewWorktreeModal(props: NewWorktreeModalProps) {
const openEpochRef = useRef(0)
const wasVisibleRef = useRef(false)
const clientEpochRef = useRef({ client: props.client, epoch: 0 })
// Why: each drawer opening is a fresh form session; remounting resets local
// form state before paint instead of clearing it in a visible-prop Effect.
// State, not a ref: react-native-screens freezes a blurred screen by suspending
// this subtree, and a counter bumped during a render React then throws away
// would restart the session for an opening that never committed.
const [session, setSession] = useState({ openEpoch: 0, visible: props.visible })
if (session.visible !== props.visible) {
setSession({
openEpoch: props.visible ? session.openEpoch + 1 : session.openEpoch,
visible: props.visible
})
if (props.visible && !wasVisibleRef.current) {
openEpochRef.current += 1
}
wasVisibleRef.current = props.visible
if (clientEpochRef.current.client !== props.client) {
clientEpochRef.current = { client: props.client, epoch: clientEpochRef.current.epoch + 1 }
}
// Why: key the session on the HOST, never on the RpcClient object. A reconnect,
// forceReconnect, or foreground revival swaps that object for the same host
// (see useHostClient), and keying on it silently remounted this form mid-edit
// and threw away the picked source. Every client-scoped hook below already
// drops responses from a superseded client, so no remount is needed for that.
return <NewWorktreeModalContent key={`${session.openEpoch}:${props.hostId}`} {...props} />
return (
<NewWorktreeModalContent
key={`${openEpochRef.current}:${clientEpochRef.current.epoch}`}
{...props}
/>
)
}
function NewWorktreeModalContent(props: NewWorktreeModalProps) {
@@ -94,7 +93,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
agentCatalog
})
const retiredNamesRefreshKey = useMemo(
() => buildRetiredWorktreeNamesRefreshKey(existingWorktreePaths),
() => (existingWorktreePaths ?? []).toSorted().join('\0'),
[existingWorktreePaths]
)
const retiredWorktreeNames = useRetiredWorktreeNames(
@@ -249,6 +248,7 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
selectedProjectId={selectedProjectId}
runTargetPickerItems={runTargetPickerItems}
pickerAgentOptions={agentSelection.pickerAgentOptions}
agentPickerSubtitle={hostAgentCatalogReadOnlyNotice(agentCatalog)}
selectedAgent={agentSelection.selectedAgent}
setupTrustPrompt={createSubmit.setupTrustPrompt}
creating={createSubmit.creating}
@@ -34,6 +34,7 @@ export function NewWorktreeModalDrawers(props: {
selectedProjectId: string | null
runTargetPickerItems: NewWorkspaceRunTargetOption<MobileWorkspaceRepo>[]
pickerAgentOptions: NewWorktreeAgentOption[]
agentPickerSubtitle?: string
selectedAgent: NewWorktreeAgentOption
setupTrustPrompt: SetupTrustPrompt | null
creating: boolean
@@ -94,6 +95,7 @@ export function NewWorktreeModalDrawers(props: {
<PickerListDrawer
visible={props.visible && props.drawerView === 'agent'}
title="Agent"
subtitle={props.agentPickerSubtitle}
items={props.pickerAgentOptions}
selectedId={props.selectedAgent.id}
onSelect={props.onAgentChange}
@@ -11,6 +11,8 @@ type PickerListItem = { id: string; label: string; detail?: string }
type Props<T extends PickerListItem> = {
visible: boolean
title: string
/** Muted line under the title for host-state context (e.g. read-only catalog). */
subtitle?: string
items: T[]
selectedId: string
onSelect: (item: T) => void
@@ -21,6 +23,7 @@ type Props<T extends PickerListItem> = {
export function PickerListDrawer<T extends PickerListItem>({
visible,
title,
subtitle,
items,
selectedId,
onSelect,
@@ -72,6 +75,7 @@ export function PickerListDrawer<T extends PickerListItem>({
>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : null}
</View>
<FlatList
data={items}
@@ -125,6 +129,11 @@ const styles = StyleSheet.create({
fontWeight: '500',
color: colors.textMuted
},
subtitle: {
fontSize: 12,
color: colors.textMuted,
marginTop: spacing.xs
},
group: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
@@ -3,7 +3,10 @@ import { describe, expect, it } from 'vitest'
import type { AgentCatalogSnapshot } from '../../../src/shared/agent-catalog-snapshot'
import type { AgentCatalogValue } from '../transport/agent-catalog-sync'
import type { BuiltInTuiAgent, CustomTuiAgentId } from '../../../src/shared/types'
import { buildMobileAgentPickerRows } from './mobile-agent-catalog-projection'
import {
buildMobileAgentPickerRows,
isHostAgentCatalogMigrationBlocked
} from './mobile-agent-catalog-projection'
import { MOBILE_AGENT_CATALOG } from './mobile-agent-catalog'
import { MOBILE_TUI_AGENT_AUTO_PICK_ORDER } from './mobile-tui-agents'
@@ -149,4 +152,14 @@ describe('mobile agent catalog projection', () => {
const custom = rows.find((row) => row.isCustom)
expect(Object.keys(custom ?? {}).sort()).toEqual(['baseAgent', 'id', 'isCustom', 'label'])
})
it('reads the host migration-blocked flag as a boolean-only projection', () => {
expect(isHostAgentCatalogMigrationBlocked(null)).toBe(false)
expect(isHostAgentCatalogMigrationBlocked(snapshot())).toBe(false)
expect(isHostAgentCatalogMigrationBlocked(projectionError)).toBe(false)
const blocked = snapshot({ migrationBlocked: true })
expect(isHostAgentCatalogMigrationBlocked(blocked)).toBe(true)
// The wire value carries no error text or path — boolean only.
expect(JSON.stringify(blocked)).not.toMatch(/error|\/Users\//i)
})
})
@@ -71,6 +71,22 @@ function groupEnabledReadyCustoms(
return grouped
}
/** True while the host's agent catalog is fail-closed pending recovery (failed
* pre-v1 backup): catalog edits are read-only ON THE HOST and customs are
* absent. Boolean projection only — the error detail never crosses the wire. */
export function isHostAgentCatalogMigrationBlocked(snapshot: AgentCatalogValue | null): boolean {
return isCatalogSnapshot(snapshot) && snapshot.migrationBlocked === true
}
/** Picker subtitle for a blocked host, or undefined when healthy. */
export function hostAgentCatalogReadOnlyNotice(
snapshot: AgentCatalogValue | null
): string | undefined {
return isHostAgentCatalogMigrationBlocked(snapshot)
? 'Agent settings on this host are read-only until its data migration is recovered.'
: undefined
}
/** Build the ordered picker rows. With no usable snapshot or with customs gated
* off, returns exactly the static built-in rows. Otherwise each enabled, ready
* custom is inserted directly below its base harness in the built-in order. */
+78
View File
@@ -47,9 +47,16 @@ vi.mock('../runtime-client', () => {
}
}
class RuntimeRpcFailureError extends RuntimeClientError {
constructor(readonly response: unknown) {
super('rpc_failure', 'rpc failure')
}
}
return {
RuntimeClient,
RuntimeClientError,
RuntimeRpcFailureError,
getDefaultUserDataPath: getDefaultUserDataPathMock
}
})
@@ -81,6 +88,7 @@ describe('agent hooks CLI handler', () => {
beforeEach(() => {
userDataPath = mkdtempSync(join(tmpdir(), 'orca-agent-hooks-cli-'))
applyAgentStatusHooksEnabledMock.mockReset()
applyAgentStatusHooksEnabledMock.mockReturnValue([])
callMock.mockReset()
getCliStatusMock.mockClear()
@@ -228,4 +236,74 @@ describe('agent hooks CLI handler', () => {
timeoutMs: 1_000
})
})
const AGENT_CATALOG_KEYS = [
'agentCatalogSchemaVersion',
'agentCatalogRevision',
'agentReferenceRevision',
'defaultTuiAgent',
'disabledTuiAgents',
'customTuiAgents',
'deletedCustomTuiAgents'
] as const
it('never stamps agent-catalog schema fields onto an existing pre-v1 file', async () => {
const existing = getDefaultPersistedState(userDataPath)
const settings = existing.settings as unknown as Record<string, unknown>
for (const key of AGENT_CATALOG_KEYS) {
delete settings[key]
}
// Pre-v1 legacy shape: an explicit null default, no schema stamp.
settings.defaultTuiAgent = null
writeDataFile(userDataPath, existing)
await runAgentHooksOff(userDataPath)
const persisted = readDataFile(userDataPath).settings as unknown as Record<string, unknown>
expect(persisted.agentStatusHooksEnabled).toBe(false)
expect(persisted.defaultTuiAgent).toBeNull()
for (const key of AGENT_CATALOG_KEYS) {
if (key === 'defaultTuiAgent') {
continue
}
expect(persisted, key).not.toHaveProperty(key)
}
// Non-catalog defaults still normalize.
expect(persisted.experimentalNewWorktreeCardStyle).toBe(false)
})
it('round-trips an existing v1 catalog byte-for-byte through an offline update', async () => {
const existing = getDefaultPersistedState(userDataPath)
const settings = existing.settings as unknown as Record<string, unknown>
settings.agentCatalogSchemaVersion = 1
settings.agentCatalogRevision = 7
settings.agentReferenceRevision = 3
settings.customTuiAgents = [{ id: 'custom-abc', label: 'My Agent', baseAgent: 'claude' }]
settings.deletedCustomTuiAgents = [{ id: 'custom-gone' }]
writeDataFile(userDataPath, existing)
await runAgentHooksOff(userDataPath)
const persisted = readDataFile(userDataPath).settings as unknown as Record<string, unknown>
expect(persisted.agentCatalogSchemaVersion).toBe(1)
expect(persisted.agentCatalogRevision).toBe(7)
expect(persisted.agentReferenceRevision).toBe(3)
expect(persisted.customTuiAgents).toEqual([
{ id: 'custom-abc', label: 'My Agent', baseAgent: 'claude' }
])
expect(persisted.deletedCustomTuiAgents).toEqual([{ id: 'custom-gone' }])
})
it('refuses the offline write when the file carries a newer agent-catalog schema', async () => {
const existing = getDefaultPersistedState(userDataPath)
;(existing.settings as unknown as Record<string, unknown>).agentCatalogSchemaVersion = 2
writeDataFile(userDataPath, existing)
const before = readFileSync(join(userDataPath, 'orca-data.json'), 'utf-8')
await runAgentHooksOff(userDataPath)
expect(process.exitCode).toBe(1)
expect(readFileSync(join(userDataPath, 'orca-data.json'), 'utf-8')).toBe(before)
expect(applyAgentStatusHooksEnabledMock).not.toHaveBeenCalled()
})
})
+31
View File
@@ -20,6 +20,20 @@ import {
getManagedAgentHookStatuses,
prepareManagedCodexHomeBeforeShellLaunch
} from '../../main/agent-hooks/managed-agent-hook-controls'
import { AGENT_CATALOG_SCHEMA_VERSION } from '../../main/agent-launch/agent-catalog-schema-migration'
// Why: offline writes must be schema-transparent for the agent catalog — merging
// defaults would stamp agentCatalogSchemaVersion on a pre-v1 file without the
// pinned pre-v1 backup the app's migration contract requires before any v1 write.
const AGENT_CATALOG_SETTINGS_KEYS = [
'agentCatalogSchemaVersion',
'agentCatalogRevision',
'agentReferenceRevision',
'defaultTuiAgent',
'disabledTuiAgents',
'customTuiAgents',
'deletedCustomTuiAgents'
] as const
type AgentHookCommandResult = {
enabled: boolean
@@ -132,12 +146,29 @@ function updateEnabledOnDisk(enabled: boolean): {
settings: Pick<GlobalSettings, 'agentCmdOverrides' | 'disabledTuiAgents'>
} {
const dataPath = getDataPath()
const fileExists = existsSync(dataPath)
const state = readPersistedState(dataPath)
const originalSettings: Record<string, unknown> = { ...state.settings }
const rawVersion = originalSettings.agentCatalogSchemaVersion
if (typeof rawVersion === 'number' && rawVersion > AGENT_CATALOG_SCHEMA_VERSION) {
throw new RuntimeClientError(
'runtime_error',
`${dataPath} uses agent-catalog schema ${rawVersion}, newer than this CLI supports (${AGENT_CATALOG_SCHEMA_VERSION}). Update the Orca CLI or change this setting from the app.`
)
}
state.settings = {
...getDefaultPersistedState(homedir()).settings,
...state.settings,
agentStatusHooksEnabled: enabled
}
if (fileExists) {
const merged = state.settings as unknown as Record<string, unknown>
for (const key of AGENT_CATALOG_SETTINGS_KEYS) {
if (!(key in originalSettings)) {
delete merged[key]
}
}
}
writePersistedState(dataPath, state)
return {
settingsPath: dataPath,
@@ -0,0 +1,44 @@
// §973 base-disable impact: the counts of persisted-owner references and
// resumable sessions that will block when a built-in base is disabled. Saved
// references include the base id and any live custom derivative of it (a
// derivative can't launch without its harness); sessions are counted by base
// and excluded from the reference scan so the two counts never overlap. Counts
// only — never a label or config. Enabled-derivative counts stay client-side.
import type { BuiltInTuiAgent, GlobalSettings } from '../../shared/types'
import type { BaseDisableImpact } from '../../shared/agent-reference-snapshot'
import type { AgentTombstoneReferenceIndex } from './agent-tombstone-reference-index'
import { normalizeCatalogFromSettings } from './agent-catalog-projections'
import { getHostAgentSessionRecordStore } from './agent-session-record-store-host'
export function computeBaseDisableImpact(
settings: GlobalSettings,
referenceIndex: AgentTombstoneReferenceIndex,
base: BuiltInTuiAgent
): BaseDisableImpact {
const catalog = normalizeCatalogFromSettings(settings)
const derivativeIds = new Set<string>()
for (const agent of catalog.liveCustomAgents) {
if (agent.baseAgent === base) {
derivativeIds.add(agent.id)
}
}
const matches = (value: unknown): boolean =>
value === base || (typeof value === 'string' && derivativeIds.has(value))
const saved = referenceIndex.countMatchingReferences(matches, {
excludeOwners: new Set(['session'])
})
let resumableSessions: BaseDisableImpact['resumableSessions']
try {
resumableSessions = {
count: getHostAgentSessionRecordStore().countRecordsByBase(base),
atLeast: false
}
} catch {
resumableSessions = { count: 0, atLeast: true }
}
return {
savedReferences: { count: saved.count, atLeast: !saved.complete },
resumableSessions
}
}
@@ -619,6 +619,38 @@ describe('pre-v1 migration gate', () => {
// No v1 write of any kind may land on the unbacked-up profile.
expect(state.settings).toBe(before)
})
it('carries the block into the local snapshot so Settings surfaces it on load', () => {
const state: StoreStubState = {
settings: baseSettings(),
repos: [],
automations: [],
agentCatalogMigrationError: 'disk full'
}
const service = new AgentCatalogService(makeStoreStub(state))
expect(service.getLocalSnapshot().migrationBlockedError).toBe('disk full')
state.agentCatalogMigrationError = null
expect(service.getLocalSnapshot().migrationBlockedError).toBeUndefined()
})
it('projects the block to remote clients as a boolean only — never the error text', () => {
const state: StoreStubState = {
settings: baseSettings(),
repos: [],
automations: [],
agentCatalogMigrationError: 'disk full at /Users/someone/Library'
}
const service = new AgentCatalogService(makeStoreStub(state))
const remote = service.getRemoteSnapshot()
expect('customAgents' in remote && remote.migrationBlocked).toBe(true)
expect(JSON.stringify(remote)).not.toContain('disk full')
expect(JSON.stringify(remote)).not.toContain('/Users/')
state.agentCatalogMigrationError = null
const healthy = service.getRemoteSnapshot()
expect('migrationBlocked' in healthy).toBe(false)
})
})
describe('reference payload budget (L1-#2)', () => {
+14 -42
View File
@@ -39,7 +39,7 @@ import {
isSecurityReducingMutation,
type AgentCatalogMigrationBlockedError
} from './agent-catalog-write-policy'
import { getHostAgentSessionRecordStore } from './agent-session-record-store-host'
import { computeBaseDisableImpact } from './agent-catalog-base-disable-impact'
import { applyAgentReferenceMutation } from './agent-reference-mutations'
import type {
AgentReferenceMutationRequest,
@@ -91,11 +91,19 @@ export class AgentCatalogService {
}
getLocalSnapshot(): LocalAgentCatalogSnapshot {
return buildLocalAgentCatalogSnapshot(this.store.getSettings(), this.repairTokens)
const snapshot = buildLocalAgentCatalogSnapshot(this.store.getSettings(), this.repairTokens)
const blocked = agentCatalogMigrationBlockedError(this.store)
return blocked ? { ...snapshot, migrationBlockedError: blocked.migrationError } : snapshot
}
getRemoteSnapshot(): ReturnType<typeof buildAgentCatalogSnapshot> {
return buildAgentCatalogSnapshot(this.store.getSettings())
const snapshot = buildAgentCatalogSnapshot(this.store.getSettings())
// Boolean-only block flag (the error text never crosses the wire); a
// projection error never carries it — a blocked host is pre-v1, never oversize.
if (!('customAgents' in snapshot) || !agentCatalogMigrationBlockedError(this.store)) {
return snapshot
}
return { ...snapshot, migrationBlocked: true }
}
/** Local-desktop-only reference summary for delete confirmation and "Review
@@ -104,38 +112,8 @@ export class AgentCatalogService {
return this.referenceIndex.summarizeReferences(id)
}
/** §973 base-disable impact: the counts of persisted-owner references and
* resumable sessions that will block when a built-in base is disabled. Saved
* references include the base id and any live custom derivative of it (a
* derivative can't launch without its harness); sessions are counted by base
* and excluded from the reference scan so the two counts never overlap. Counts
* only — never a label or config. Enabled-derivative counts stay client-side. */
getBaseDisableImpact(base: BuiltInTuiAgent): BaseDisableImpact {
const catalog = normalizeCatalogFromSettings(this.store.getSettings())
const derivativeIds = new Set<string>()
for (const agent of catalog.liveCustomAgents) {
if (agent.baseAgent === base) {
derivativeIds.add(agent.id)
}
}
const matches = (value: unknown): boolean =>
value === base || (typeof value === 'string' && derivativeIds.has(value))
const saved = this.referenceIndex.countMatchingReferences(matches, {
excludeOwners: new Set(['session'])
})
let resumableSessions: BaseDisableImpact['resumableSessions']
try {
resumableSessions = {
count: getHostAgentSessionRecordStore().countRecordsByBase(base),
atLeast: false
}
} catch {
resumableSessions = { count: 0, atLeast: true }
}
return {
savedReferences: { count: saved.count, atLeast: !saved.complete },
resumableSessions
}
return computeBaseDisableImpact(this.store.getSettings(), this.referenceIndex, base)
}
/** Single-record full-env editor read, access-checked by the preload boundary
@@ -217,18 +195,12 @@ export class AgentCatalogService {
}
}
/** Non-null while the profile is pinned pre-v1 by a failed backup; all
* catalog/reference mutations are gated on it. */
private getMigrationBlockedError(): AgentCatalogMigrationBlockedError | null {
return agentCatalogMigrationBlockedError(this.store)
}
mutateReferences(
request: AgentReferenceMutationRequest
): AgentReferenceMutationResult<LocalAgentReferenceSnapshot> | AgentCatalogMigrationBlockedError {
// The failed-backup invariant is "no v1 write": reference mutations stamp
// agentReferenceRevision, so they are blocked alongside catalog mutations.
const blocked = this.getMigrationBlockedError()
const blocked = agentCatalogMigrationBlockedError(this.store)
if (blocked) {
return blocked
}
@@ -311,7 +283,7 @@ export class AgentCatalogService {
): AgentCatalogMutationResult | AgentCatalogMigrationBlockedError {
// A failed pinned pre-v1 backup means no v1 write may land on the profile;
// fail closed here so authoring cannot bypass the migration invariant.
const blocked = this.getMigrationBlockedError()
const blocked = agentCatalogMigrationBlockedError(this.store)
if (blocked) {
return blocked
}
@@ -0,0 +1,118 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import {
chmodSync,
existsSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import {
PRE_RESTORE_SAFETY_SUFFIX,
listRecoveryPoints,
restoreRecoveryPoint
} from './recovery-points'
const PINNED_SUFFIX = '.pre-agent-catalog-v1.backup'
describe('data recovery points', () => {
let dir = ''
let dataFile = ''
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'orca-data-recovery-'))
dataFile = join(dir, 'orca-data.json')
})
afterEach(() => {
chmodSync(dir, 0o755)
rmSync(dir, { recursive: true, force: true })
})
function makeStore() {
return {
getDataFilePath: () => dataFile,
freezeWrites: vi.fn(),
unfreezeWrites: vi.fn(),
waitForPendingWrite: vi.fn(() => Promise.resolve())
}
}
it('lists no points when no backup exists', () => {
expect(listRecoveryPoints(dataFile)).toEqual([])
})
it('lists the pinned pre-v1 point with metadata only — no path, no contents', () => {
writeFileSync(`${dataFile}${PINNED_SUFFIX}`, JSON.stringify({ settings: {} }))
const points = listRecoveryPoints(dataFile)
expect(points).toHaveLength(1)
expect(points[0].id).toBe('agent-catalog-pre-v1')
expect(points[0].compatibility).toBe('previous-binary')
expect(points[0].sizeBytes).toBeGreaterThan(0)
expect(points[0].createdAtMs).toBeGreaterThan(0)
expect(JSON.stringify(points)).not.toContain(dir)
expect(JSON.stringify(points)).not.toContain('settings')
})
it('restores atomically: freeze before replace, safety copy kept, point preserved', async () => {
const preV1 = JSON.stringify({ settings: { defaultTuiAgent: null } })
const current = JSON.stringify({ settings: { agentCatalogSchemaVersion: 1 } })
writeFileSync(dataFile, current, { mode: 0o600 })
writeFileSync(`${dataFile}${PINNED_SUFFIX}`, preV1, { mode: 0o600 })
const store = makeStore()
const result = await restoreRecoveryPoint(store, 'agent-catalog-pre-v1')
expect(result).toEqual({ ok: true })
expect(store.freezeWrites).toHaveBeenCalledTimes(1)
expect(store.waitForPendingWrite).toHaveBeenCalledTimes(1)
expect(store.unfreezeWrites).not.toHaveBeenCalled()
// Byte-identical restore; the point and a safety copy of the v1 file both survive.
expect(readFileSync(dataFile, 'utf-8')).toBe(preV1)
expect(readFileSync(`${dataFile}${PINNED_SUFFIX}`, 'utf-8')).toBe(preV1)
expect(readFileSync(`${dataFile}${PRE_RESTORE_SAFETY_SUFFIX}`, 'utf-8')).toBe(current)
expect(statSync(dataFile).mode & 0o777).toBe(0o600)
})
it('rejects a missing point without touching anything', async () => {
writeFileSync(dataFile, '{}')
const store = makeStore()
const result = await restoreRecoveryPoint(store, 'agent-catalog-pre-v1')
expect(result.ok).toBe(false)
expect(store.freezeWrites).not.toHaveBeenCalled()
expect(readFileSync(dataFile, 'utf-8')).toBe('{}')
})
it('rejects a corrupt backup before suspending writes', async () => {
writeFileSync(dataFile, '{"settings":{}}')
writeFileSync(`${dataFile}${PINNED_SUFFIX}`, 'not json {')
const store = makeStore()
const result = await restoreRecoveryPoint(store, 'agent-catalog-pre-v1')
expect(result.ok).toBe(false)
expect(store.freezeWrites).not.toHaveBeenCalled()
expect(readFileSync(dataFile, 'utf-8')).toBe('{"settings":{}}')
expect(existsSync(`${dataFile}${PRE_RESTORE_SAFETY_SUFFIX}`)).toBe(false)
})
// Why skip on win32: chmod cannot make a directory read-only on Windows.
it.skipIf(process.platform === 'win32')(
're-enables writes and leaves the live file intact when the replace fails',
async () => {
const current = '{"settings":{"agentCatalogSchemaVersion":1}}'
writeFileSync(dataFile, current, { mode: 0o600 })
writeFileSync(`${dataFile}${PINNED_SUFFIX}`, '{"settings":{}}', { mode: 0o600 })
const store = makeStore()
// Read-only directory: the safety-copy tmp file cannot be created.
chmodSync(dir, 0o500)
const result = await restoreRecoveryPoint(store, 'agent-catalog-pre-v1')
chmodSync(dir, 0o755)
expect(result.ok).toBe(false)
expect(store.freezeWrites).toHaveBeenCalledTimes(1)
expect(store.unfreezeWrites).toHaveBeenCalledTimes(1)
expect(readFileSync(dataFile, 'utf-8')).toBe(current)
}
)
})
+133
View File
@@ -0,0 +1,133 @@
// Recovery-point inventory and atomic restore for migration backups (runbook:
// "General Data recovery UI"). Only metadata crosses to the renderer — never a
// filesystem path or raw backup contents; restore/retry are main-owned.
import {
closeSync,
existsSync,
fsyncSync,
openSync,
readFileSync,
renameSync,
statSync,
unlinkSync,
writeSync
} from 'node:fs'
import { pinnedPreV1BackupPath } from '../agent-launch/agent-catalog-schema-migration'
import type { RecoveryPointDto, RecoveryPointId } from '../../shared/data-recovery'
export type { RecoveryPointDto, RecoveryPointId } from '../../shared/data-recovery'
export const PRE_RESTORE_SAFETY_SUFFIX = '.pre-restore-safety.backup'
type RestoreStore = {
getDataFilePath(): string
freezeWrites(): void
unfreezeWrites(): void
waitForPendingWrite(): Promise<void>
}
function recoveryPointPath(dataFile: string, id: RecoveryPointId): string {
switch (id) {
case 'agent-catalog-pre-v1':
return pinnedPreV1BackupPath(dataFile)
}
}
export function listRecoveryPoints(dataFile: string): RecoveryPointDto[] {
const points: RecoveryPointDto[] = []
const pinned = pinnedPreV1BackupPath(dataFile)
if (existsSync(pinned)) {
let createdAtMs: number | null = null
let sizeBytes: number | null = null
try {
const stat = statSync(pinned)
createdAtMs = stat.birthtimeMs > 0 ? stat.birthtimeMs : stat.mtimeMs
sizeBytes = stat.size
} catch {
// Metadata is best-effort; the point is still restorable.
}
points.push({
id: 'agent-catalog-pre-v1',
compatibility: 'previous-binary',
createdAtMs,
sizeBytes
})
}
return points
}
function writeFileAtomicSync(targetPath: string, contents: string, mode: number): void {
const tmpPath = `${targetPath}.tmp`
const fd = openSync(tmpPath, 'w', mode)
try {
writeSync(fd, contents)
fsyncSync(fd)
} finally {
closeSync(fd)
}
try {
renameSync(tmpPath, targetPath)
} catch (error) {
try {
unlinkSync(tmpPath)
} catch {
// Best-effort tmp cleanup; surface the rename error instead.
}
throw error
}
}
export type RestoreRecoveryPointResult = { ok: true } | { ok: false; error: string }
/** Validates the selected backup, suspends writes, keeps a pre-restore safety
* copy, and atomically replaces the live data file. Failure or invalid input
* leaves the current file and the recovery point intact and re-enables writes;
* the caller owns the post-success app action (restart or quit). */
export async function restoreRecoveryPoint(
store: RestoreStore,
id: RecoveryPointId
): Promise<RestoreRecoveryPointResult> {
const dataFile = store.getDataFilePath()
const backupPath = recoveryPointPath(dataFile, id)
if (!existsSync(backupPath)) {
return { ok: false, error: 'The selected recovery point no longer exists.' }
}
let backupContents: string
try {
backupContents = readFileSync(backupPath, 'utf-8')
const parsed: unknown = JSON.parse(backupContents)
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return { ok: false, error: 'The recovery point is not a valid Orca data file.' }
}
} catch (error) {
return {
ok: false,
error: `The recovery point could not be validated: ${error instanceof Error ? error.message : String(error)}`
}
}
// Why freeze before the safety copy: an in-flight or quit-time save landing
// after the copy would make the safety copy stale and could clobber the
// restored file between rename and process exit.
store.freezeWrites()
try {
await store.waitForPendingWrite()
const mode = existsSync(dataFile) ? statSync(dataFile).mode & 0o777 : 0o600
if (existsSync(dataFile)) {
writeFileAtomicSync(
`${dataFile}${PRE_RESTORE_SAFETY_SUFFIX}`,
readFileSync(dataFile, 'utf-8'),
mode
)
}
writeFileAtomicSync(dataFile, backupContents, mode)
return { ok: true }
} catch (error) {
store.unfreezeWrites()
return {
ok: false,
error: `Restore failed and no changes were made: ${error instanceof Error ? error.message : String(error)}`
}
}
}
+42
View File
@@ -0,0 +1,42 @@
// Local preload IPC for Data recovery (runbook release requirement): migration
// status, retry, recovery-point inventory, and main-owned restore. Never a
// runtime RPC method; the renderer sees metadata only.
import { app, ipcMain } from 'electron'
import type { Store } from '../persistence'
import type { DataRecoveryMigrationStatus } from '../../shared/data-recovery'
import { listRecoveryPoints, restoreRecoveryPoint } from '../data-recovery/recovery-points'
export function registerDataRecoveryHandlers(store: Store): void {
ipcMain.handle(
'dataRecovery:migrationStatus',
(): DataRecoveryMigrationStatus => ({
agentCatalogMigrationError: store.getAgentCatalogMigrationError()
})
)
ipcMain.handle('dataRecovery:retryAgentCatalogMigration', () =>
store.retryAgentCatalogMigration()
)
ipcMain.handle('dataRecovery:listPoints', () => listRecoveryPoints(store.getDataFilePath()))
ipcMain.handle('dataRecovery:restore', async (_event, args: { id?: unknown; mode?: unknown }) => {
// The pinned pre-v1 point is only restorable as Prepare downgrade: a
// restart would relaunch the v1 build and immediately re-migrate the
// restored file, undoing the restore.
if (args?.id !== 'agent-catalog-pre-v1' || args?.mode !== 'prepare-downgrade') {
return { ok: false, error: 'Invalid restore request.' }
}
const result = await restoreRecoveryPoint(store, args.id)
if (!result.ok) {
return result
}
// Reply first, then quit without relaunching; the frozen store cannot
// write over the restored file during shutdown.
setImmediate(() => {
app.quit()
})
return result
})
}
@@ -16,6 +16,8 @@ const {
registerDeveloperPermissionHandlersMock,
registerComputerUsePermissionHandlersMock,
registerSettingsHandlersMock,
registerAgentCatalogHandlersMock,
registerDataRecoveryHandlersMock,
registerKeybindingHandlersMock,
registerTelemetryHandlersMock,
registerDiagnosticsHandlersMock,
@@ -59,7 +61,6 @@ const {
registerTerminalPreviewHandlersMock,
registerSpeechHandlersMock,
registerSkillsHandlersMock,
registerAgentCatalogHandlersMock,
registerWorkspaceSpaceHandlersMock,
registerWorkspacePortHandlersMock,
registerLocalhostWorktreeLabelHandlersMock,
@@ -82,6 +83,8 @@ const {
registerDeveloperPermissionHandlersMock: vi.fn(),
registerComputerUsePermissionHandlersMock: vi.fn(),
registerSettingsHandlersMock: vi.fn(),
registerAgentCatalogHandlersMock: vi.fn(),
registerDataRecoveryHandlersMock: vi.fn(),
registerKeybindingHandlersMock: vi.fn(),
registerTelemetryHandlersMock: vi.fn(),
registerDiagnosticsHandlersMock: vi.fn(),
@@ -125,7 +128,6 @@ const {
registerTerminalPreviewHandlersMock: vi.fn(),
registerSpeechHandlersMock: vi.fn(),
registerSkillsHandlersMock: vi.fn(),
registerAgentCatalogHandlersMock: vi.fn(),
registerWorkspaceSpaceHandlersMock: vi.fn(),
registerWorkspacePortHandlersMock: vi.fn(),
registerLocalhostWorktreeLabelHandlersMock: vi.fn(),
@@ -224,14 +226,18 @@ vi.mock('./settings', () => ({
registerSettingsHandlers: registerSettingsHandlersMock
}))
vi.mock('./skills', () => ({
registerSkillsHandlers: registerSkillsHandlersMock
vi.mock('./data-recovery', () => ({
registerDataRecoveryHandlers: registerDataRecoveryHandlersMock
}))
vi.mock('./agent-catalog', () => ({
registerAgentCatalogHandlers: registerAgentCatalogHandlersMock
}))
vi.mock('./skills', () => ({
registerSkillsHandlers: registerSkillsHandlersMock
}))
vi.mock('./workspace-space', () => ({
registerWorkspaceSpaceHandlers: registerWorkspaceSpaceHandlersMock
}))
@@ -535,7 +541,8 @@ describe('registerCoreHandlers', () => {
expect(registerTerminalPreviewHandlersMock).toHaveBeenCalledWith(runtime)
expect(registerSettingsHandlersMock).toHaveBeenCalledWith(store, agentAwakeService)
expect(registerAgentCatalogHandlersMock).toHaveBeenCalledWith(store)
expect(registerSkillsHandlersMock).toHaveBeenCalledWith(store, runtime)
expect(registerSkillsHandlersMock).toHaveBeenCalledWith(store, runtime)
expect(registerDataRecoveryHandlersMock).toHaveBeenCalledWith(store)
expect(registerWorkspaceSpaceHandlersMock).toHaveBeenCalledWith(store)
expect(registerWorkspacePortHandlersMock).toHaveBeenCalledWith(store)
expect(registerLocalhostWorktreeLabelHandlersMock).toHaveBeenCalledWith(store)
@@ -37,9 +37,10 @@ import { setAgentBrowserBridgeRef, registerBrowserHandlers } from './browser'
import { setTrustedBrowserRendererWebContentsId } from './browser-renderer-trust'
import { registerSessionHandlers } from './session'
import { registerSettingsHandlers } from './settings'
import { registerAgentCatalogHandlers } from './agent-catalog'
import { registerDataRecoveryHandlers } from './data-recovery'
import { registerDiagnosticsHandlers } from './diagnostics'
import { registerSkillsHandlers } from './skills'
import { registerAgentCatalogHandlers } from './agent-catalog'
import { registerWorkspaceSpaceHandlers } from './workspace-space'
import { registerWorkspacePortHandlers } from './workspace-ports'
import { registerLocalhostWorktreeLabelHandlers } from './localhost-worktree-labels'
@@ -178,6 +179,7 @@ export function registerCoreHandlers(
registerSettingsHandlers(store, agentAwakeService)
registerAgentCatalogHandlers(store)
registerSkillsHandlers(store, runtime)
registerDataRecoveryHandlers(store)
if (automations) {
registerAutomationHandlers(store, automations)
}
@@ -146,4 +146,44 @@ describe('agent-catalog v1 migration through the real Store', () => {
expect(store.getSettings().agentCatalogSchemaVersion).toBe(1)
expect(existsSync(`${dataFile}${PINNED_BACKUP_SUFFIX}`)).toBe(false)
})
// Why skip on win32: chmod cannot make a directory read-only on Windows.
it.skipIf(process.platform === 'win32')(
'retryAgentCatalogMigration completes a blocked migration once the cause clears',
async () => {
const original = JSON.stringify({ settings: { defaultTuiAgent: null } })
writeFileSync(dataFile, original, { mode: 0o600 })
chmodSync(dir, 0o500)
const store = await createStore(dataFile)
expect(store.getAgentCatalogMigrationError()).not.toBeNull()
// Still failing: retry reports the error and stays pre-v1.
const stillBlocked = store.retryAgentCatalogMigration()
expect(stillBlocked.ok).toBe(false)
expect(store.getAgentCatalogMigrationError()).not.toBeNull()
expect(store.getSettings().agentCatalogSchemaVersion).toBeUndefined()
chmodSync(dir, 0o755)
const result = store.retryAgentCatalogMigration()
expect(result).toEqual({ ok: true })
expect(store.getAgentCatalogMigrationError()).toBeNull()
expect(store.getSettings().agentCatalogSchemaVersion).toBe(1)
expect(store.getSettings().defaultTuiAgent).toBe('auto')
// The backup captured the untouched pre-v1 bytes before any v1 write.
expect(readFileSync(`${dataFile}${PINNED_BACKUP_SUFFIX}`, 'utf-8')).toBe(original)
store.flushOrThrow()
const persisted = JSON.parse(readFileSync(dataFile, 'utf-8'))
expect(persisted.settings.agentCatalogSchemaVersion).toBe(1)
}
)
it('retryAgentCatalogMigration is a no-op when the migration is not blocked', async () => {
writeFileSync(dataFile, JSON.stringify({ settings: { defaultTuiAgent: null } }), {
mode: 0o600
})
const store = await createStore(dataFile)
expect(store.getAgentCatalogMigrationError()).toBeNull()
expect(store.retryAgentCatalogMigration()).toEqual({ ok: true })
expect(store.getSettings().agentCatalogSchemaVersion).toBe(1)
})
})
@@ -1,3 +1,4 @@
import { existsSync, readFileSync } from 'node:fs'
import type { GlobalSettings } from '../../../shared/global-settings-types'
import type { OnboardingChecklistState } from '../../../shared/onboarding-state-types'
import type { PersistedState } from '../../../shared/persisted-state-types'
@@ -17,12 +18,16 @@ import {
import type { StoreRuntimeState } from './store-runtime-state'
import type { WriteSchedulingOperations } from './write-scheduling'
import { scheduleSave } from './write-scheduling'
import { bumpLocalWorktreeScanGeneration } from '../../local-worktree-scan-generation'
import {
createPinnedPreV1Backup,
migrateAgentCatalogSchema
} from '../../agent-launch/agent-catalog-schema-migration'
type ProfilePreferencesRuntime = Pick<
StoreRuntimeState,
| 'activeViewPreference'
| 'agentCatalogMigrationError'
| 'dataFile'
| 'githubCacheDirty'
| 'githubCacheGeneration'
| 'protectedSecrets'
@@ -52,6 +57,33 @@ export class ProfilePreferences {
return this[profilePreferencesContext].runtime.agentCatalogMigrationError
}
retryAgentCatalogMigration(): { ok: true } | { ok: false; error: string } {
const runtime = this[profilePreferencesContext].runtime
if (runtime.agentCatalogMigrationError === null) {
return { ok: true }
}
let raw: string | null
try {
raw = existsSync(runtime.dataFile) ? readFileSync(runtime.dataFile, 'utf-8') : null
} catch (error) {
const message = `Could not read the data file: ${error instanceof Error ? error.message : String(error)}`
runtime.agentCatalogMigrationError = message
return { ok: false, error: message }
}
const migration = migrateAgentCatalogSchema({
settings: runtime.state.settings,
preV1RawContents: raw,
createBackup: () => createPinnedPreV1Backup(runtime.dataFile, raw ?? '')
})
if (migration.backupError) {
runtime.agentCatalogMigrationError = migration.backupError
return { ok: false, error: migration.backupError }
}
runtime.agentCatalogMigrationError = null
this.updateSettings(migration.settingsPatch)
return { ok: true }
}
onSettingsChanged(
listener: (
updates: Partial<GlobalSettings>,
@@ -161,7 +193,6 @@ export function getSettingsMutationOperations(
): SettingsMutationOperations {
return {
state: owner[profilePreferencesContext].runtime.state,
bumpLocalWorktreeScanGeneration,
removeRetainedBlob: (slot) =>
owner[profilePreferencesContext].runtime.protectedSecrets.removeRetainedBlob(slot),
scheduleSave: () => scheduleSave(owner[profilePreferencesContext].scheduling),
@@ -87,6 +87,10 @@ export class Store {
return dirname(this.runtime.dataFile)
}
getDataFilePath(): string {
return this.runtime.dataFile
}
freezeWrites(): void {
this.runtime.writesFrozen = true
if (this.runtime.writeTimer) {
@@ -94,6 +98,10 @@ export class Store {
this.runtime.writeTimer = null
}
}
unfreezeWrites(): void {
this.runtime.writesFrozen = false
}
}
// oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging derives Store's prototype API directly from the exact concrete domain classes installed below
+19 -4
View File
@@ -6,7 +6,7 @@ import type {
GrokAccountsApi,
MinimaxCredentialsApi
} from './api/agent-account-api'
import type { HooksApi } from './api/agent-hook-api'
import type { AgentHooksApi, HooksApi } from './api/agent-hook-api'
import type { SkillsApi } from './api/agent-skill-api'
import type { AgentAwakeApi, AgentStatusApi, AgentTrustApi } from './api/agent-status-api'
import type {
@@ -22,7 +22,13 @@ import type { BrowserApi } from './api/browser-api'
import type { CliApi } from './api/cli-install-api'
import type { CrashReportsApi, FeedbackApi } from './api/crash-report-api'
import type { DashboardApi, TerminalPreviewApi } from './api/dashboard-api'
import type { DocPreviewApi } from './api/doc-preview-api'
import type {
DataRecoveryMigrationStatus,
DataRecoveryOperationResult,
RecoveryPointDto,
RecoveryPointId,
RestoreRecoveryPointMode
} from '../shared/data-recovery'
import type { EmulatorApi } from './api/emulator-api'
import type { EphemeralVmApi } from './api/ephemeral-vm-api'
import type { ExportApi, FilesystemApi } from './api/filesystem-api'
@@ -104,6 +110,7 @@ export type PreloadApi = {
claudeAccounts: ClaudeAccountsApi
cli: CliApi
codexConfigSync: CodexConfigSyncApi
agentHooks: AgentHooksApi
agentTrust: AgentTrustApi
preflight: PreflightApi
notifications: NotificationsApi
@@ -125,7 +132,6 @@ export type PreloadApi = {
remoteWorkspace: WorkspaceSessionApi['remoteWorkspace']
updater: UpdaterApi
notebook: FilesystemApi['notebook']
docPreview: DocPreviewApi['docPreview']
stats: StatsApi
memory: MemoryApi
claudeUsage: ClaudeUsageApi
@@ -150,11 +156,20 @@ export type PreloadApi = {
agentStatus: AgentStatusApi
mobile: MobileApi
speech: SpeechApi
/** Desktop-only data recovery; paired web clients do not expose this surface. */
dataRecovery?: {
migrationStatus: () => Promise<DataRecoveryMigrationStatus>
retryAgentCatalogMigration: () => Promise<DataRecoveryOperationResult>
listPoints: () => Promise<RecoveryPointDto[]>
restore: (args: {
id: RecoveryPointId
mode: RestoreRecoveryPointMode
}) => Promise<DataRecoveryOperationResult>
}
}
export type { ClaudeUsageApi, CodexUsageApi, OpenCodeUsageApi } from './api/agent-usage-api'
export type { AiVaultApi } from './api/ai-vault-api'
export type { AutomationsApi, ExternalAutomationManagerResult } from './api/automation-api'
export type { AppApi } from './api/app-api'
export type { BrowserApi, DetectedBrowserInfo, DetectedBrowserProfileInfo } from './api/browser-api'
export type { EmulatorApi } from './api/emulator-api'
+8
View File
@@ -7069,6 +7069,14 @@ const api = {
ipcRenderer.invoke('localhostWorktreeLabels:register', args)
} satisfies PreloadApi['localhostWorktreeLabels'],
dataRecovery: {
migrationStatus: (): Promise<unknown> => ipcRenderer.invoke('dataRecovery:migrationStatus'),
retryAgentCatalogMigration: (): Promise<unknown> =>
ipcRenderer.invoke('dataRecovery:retryAgentCatalogMigration'),
listPoints: (): Promise<unknown> => ipcRenderer.invoke('dataRecovery:listPoints'),
restore: (args: unknown): Promise<unknown> => ipcRenderer.invoke('dataRecovery:restore', args)
},
keybindings: {
get: (): Promise<KeybindingFileSnapshot> => ipcRenderer.invoke('keybindings:get'),
ensureFile: (): Promise<KeybindingFileSnapshot> => ipcRenderer.invoke('keybindings:ensureFile'),
+2
View File
@@ -6,6 +6,7 @@ import { BrowserWebAuthnAccountDialog } from './components/browser-webauthn-acco
import { DocPreviewExternalLinkConfirmation } from './components/browser-pane/workspace-doc/doc-preview-external-link-confirmation'
import { LinkRoutingPreferenceDialogProvider } from './components/link-routing-preference-dialog'
import { SkillFreshnessNudge } from './components/skills/SkillFreshnessNudge'
import { DataRecoveryMigrationNotice } from './components/data-recovery/DataRecoveryMigrationNotice'
import PinnedTabCloseDialog from './components/terminal-pane/PinnedTabCloseDialog'
import RunningTerminalCloseDialog from './components/terminal-pane/RunningTerminalCloseDialog'
import WorktreeBaseFallbackDialog from './components/WorktreeBaseFallbackDialog'
@@ -105,6 +106,7 @@ function App(): React.JSX.Element {
</TooltipProvider>
<Toaster closeButton toastOptions={{ className: 'font-sans text-sm' }} />
<SkillFreshnessNudge />
<DataRecoveryMigrationNotice />
<WorktreeBaseFallbackDialog />
<PinnedTabCloseDialog />
<RunningTerminalCloseDialog />
@@ -0,0 +1,204 @@
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { translate } from '@/i18n/i18n'
import type { RecoveryPointDto } from '../../../../shared/data-recovery'
export type DataRecoveryDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
function pointTitle(point: RecoveryPointDto): string {
switch (point.id) {
case 'agent-catalog-pre-v1':
return translate(
'auto.components.dataRecovery.pointAgentCatalogPreV1Title',
'Before the custom-agents update (agent catalog v1)'
)
}
}
function pointLossSummary(point: RecoveryPointDto): string {
switch (point.id) {
case 'agent-catalog-pre-v1':
return translate(
'auto.components.dataRecovery.pointAgentCatalogPreV1Loss',
'Restoring discards all settings and workspace metadata saved after this point, including any custom agents.'
)
}
}
/** Main-owned restore flow (runbook: General Data recovery UI). Lists recovery
* points by metadata only; the pinned pre-v1 point restores via Prepare
* downgrade — Orca restores atomically and quits without relaunching. */
export function DataRecoveryDialog({ open, onOpenChange }: DataRecoveryDialogProps) {
const [points, setPoints] = useState<RecoveryPointDto[] | null>(null)
const [confirming, setConfirming] = useState<RecoveryPointDto | null>(null)
const [restoring, setRestoring] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!open) {
setConfirming(null)
setError(null)
return
}
let cancelled = false
void window.api.dataRecovery
?.listPoints()
.then((list) => {
if (!cancelled) {
setPoints(list)
}
})
.catch(() => {
if (!cancelled) {
setPoints([])
}
})
return () => {
cancelled = true
}
}, [open])
const handleRestore = async (point: RecoveryPointDto): Promise<void> => {
setRestoring(true)
setError(null)
try {
const result = await window.api.dataRecovery?.restore({
id: point.id,
mode: 'prepare-downgrade'
})
// On success Orca quits; only failures come back to this dialog.
if (result && !result.ok) {
setError(result.error)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setRestoring(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{translate('auto.components.dataRecovery.title', 'Data recovery')}
</DialogTitle>
<DialogDescription>
{translate(
'auto.components.dataRecovery.description',
'Restore this profile from a recovery point created before a data migration. Restoring never deletes the recovery point.'
)}
</DialogDescription>
</DialogHeader>
{points === null ? (
<p className="text-sm text-muted-foreground">
{translate('auto.components.dataRecovery.loading', 'Looking for recovery points…')}
</p>
) : points.length === 0 ? (
<p className="text-sm text-muted-foreground">
{translate(
'auto.components.dataRecovery.empty',
'No recovery points exist for this profile.'
)}
</p>
) : (
<ul className="flex flex-col gap-3">
{points.map((point) => (
<li key={point.id} className="rounded-md border border-border p-3 text-sm">
<p className="font-medium">{pointTitle(point)}</p>
<p className="text-muted-foreground">
{point.createdAtMs
? translate('auto.components.dataRecovery.createdAt', 'Created {{date}}', {
date: new Date(point.createdAtMs).toLocaleString()
})
: translate(
'auto.components.dataRecovery.createdUnknown',
'Creation time unknown'
)}
</p>
{point.compatibility === 'previous-binary' ? (
<p className="text-muted-foreground">
{translate(
'auto.components.dataRecovery.previousBinary',
'After restoring, Orca quits. Install the previous Orca version before opening it again.'
)}
</p>
) : null}
<p className="mt-1 text-muted-foreground">{pointLossSummary(point)}</p>
{confirming?.id === point.id ? (
<div className="mt-2 flex items-center gap-2">
<Button
type="button"
variant="destructive"
size="xs"
disabled={restoring}
onClick={() => void handleRestore(point)}
>
{restoring
? translate('auto.components.dataRecovery.restoring', 'Restoring…')
: translate(
'auto.components.dataRecovery.confirmRestore',
'Restore and quit'
)}
</Button>
<Button
type="button"
variant="outline"
size="xs"
disabled={restoring}
onClick={() => setConfirming(null)}
>
{translate('auto.components.dataRecovery.cancel', 'Cancel')}
</Button>
</div>
) : (
<Button
type="button"
variant="outline"
size="xs"
className="mt-2"
onClick={() => setConfirming(point)}
>
{translate(
'auto.components.dataRecovery.prepareDowngrade',
'Prepare downgrade…'
)}
</Button>
)}
</li>
))}
</ul>
)}
{error ? (
<p role="alert" className="break-words text-sm text-destructive">
{translate(
'auto.components.dataRecovery.restoreFailed',
'Restore failed and no changes were made: {{error}}',
{ error }
)}
</p>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{translate('auto.components.dataRecovery.close', 'Close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,100 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { DataRecoveryMigrationNotice } from './DataRecoveryMigrationNotice'
type ApiStub = {
migrationStatus: ReturnType<typeof vi.fn>
retryAgentCatalogMigration: ReturnType<typeof vi.fn>
listPoints: ReturnType<typeof vi.fn>
restore: ReturnType<typeof vi.fn>
}
function installApi(overrides: Partial<ApiStub> = {}): ApiStub {
const api: ApiStub = {
migrationStatus: vi.fn().mockResolvedValue({ agentCatalogMigrationError: 'disk full' }),
retryAgentCatalogMigration: vi.fn().mockResolvedValue({ ok: true }),
listPoints: vi.fn().mockResolvedValue([
{
id: 'agent-catalog-pre-v1',
compatibility: 'previous-binary',
createdAtMs: 1_752_000_000_000,
sizeBytes: 1024
}
]),
restore: vi.fn().mockResolvedValue({ ok: true }),
...overrides
}
;(window as unknown as { api: unknown }).api = { dataRecovery: api }
return api
}
describe('DataRecoveryMigrationNotice', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
cleanup()
})
it('renders nothing when the migration is healthy', async () => {
installApi({
migrationStatus: vi.fn().mockResolvedValue({ agentCatalogMigrationError: null })
})
render(<DataRecoveryMigrationNotice />)
await vi.waitFor(() => expect(screen.queryByRole('alert')).toBeNull())
})
it('renders nothing on paired web where the dataRecovery surface is absent', async () => {
;(window as unknown as { api: unknown }).api = {}
render(<DataRecoveryMigrationNotice />)
await vi.waitFor(() => expect(screen.queryByRole('alert')).toBeNull())
})
it('shows the blocked state at load with error details and both actions', async () => {
installApi()
render(<DataRecoveryMigrationNotice />)
expect(await screen.findByRole('alert')).toBeTruthy()
expect(screen.getByText('disk full')).toBeTruthy()
expect(screen.getByText('Retry migration')).toBeTruthy()
expect(screen.getByText('Open Data recovery')).toBeTruthy()
})
it('clears the notice when retry succeeds and keeps it when retry fails', async () => {
const api = installApi({
retryAgentCatalogMigration: vi
.fn()
.mockResolvedValueOnce({ ok: false, error: 'still failing' })
.mockResolvedValueOnce({ ok: true })
})
api.migrationStatus
.mockResolvedValueOnce({ agentCatalogMigrationError: 'disk full' })
.mockResolvedValue({ agentCatalogMigrationError: 'still failing' })
render(<DataRecoveryMigrationNotice />)
const retry = await screen.findByText('Retry migration')
fireEvent.click(retry)
await vi.waitFor(() => expect(screen.getByText('still failing')).toBeTruthy())
fireEvent.click(screen.getByText('Retry migration'))
await vi.waitFor(() => expect(screen.queryByRole('alert')).toBeNull())
})
it('opens Data recovery listing the pinned point with Prepare downgrade', async () => {
const api = installApi()
render(<DataRecoveryMigrationNotice />)
fireEvent.click(await screen.findByText('Open Data recovery'))
expect(
await screen.findByText('Before the custom-agents update (agent catalog v1)')
).toBeTruthy()
fireEvent.click(screen.getByText('Prepare downgrade…'))
fireEvent.click(screen.getByText('Restore and quit'))
await vi.waitFor(() =>
expect(api.restore).toHaveBeenCalledWith({
id: 'agent-catalog-pre-v1',
mode: 'prepare-downgrade'
})
)
})
})
@@ -0,0 +1,89 @@
import { useCallback, useEffect, useState } from 'react'
import { AlertTriangle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { DataRecoveryDialog } from './DataRecoveryDialog'
/** App-level persistent notice for a blocked agent-catalog migration (runbook:
* Migration user experience). Shown at profile load — not only in Settings —
* and stays until Retry succeeds or a recovery point is restored. Renders
* nothing on paired web (no dataRecovery preload surface) or when healthy. */
export function DataRecoveryMigrationNotice() {
const [migrationError, setMigrationError] = useState<string | null>(null)
const [retrying, setRetrying] = useState(false)
const [recoveryOpen, setRecoveryOpen] = useState(false)
const refresh = useCallback(async () => {
try {
const status = await window.api.dataRecovery?.migrationStatus()
setMigrationError(status?.agentCatalogMigrationError ?? null)
} catch {
setMigrationError(null)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
if (migrationError === null) {
return null
}
const handleRetry = async (): Promise<void> => {
setRetrying(true)
try {
const result = await window.api.dataRecovery?.retryAgentCatalogMigration()
if (result?.ok) {
setMigrationError(null)
} else {
await refresh()
}
} finally {
setRetrying(false)
}
}
return (
<div
role="alert"
className="flex flex-wrap items-start gap-2 border-b border-destructive/40 bg-destructive/5 px-3 py-2 text-sm"
>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<p className="font-medium text-destructive">
{translate(
'auto.components.dataRecovery.noticeTitle',
'Orca could not back up your data before a required update'
)}
</p>
<p className="text-muted-foreground">
{translate(
'auto.components.dataRecovery.noticeBody',
'Your existing data is unchanged and built-in agents keep working, but custom-agent settings are read-only until this is resolved.'
)}
</p>
<p className="break-words font-mono text-xs text-muted-foreground">{migrationError}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button type="button" size="xs" disabled={retrying} onClick={() => void handleRetry()}>
{retrying
? translate('auto.components.dataRecovery.retrying', 'Retrying…')
: translate('auto.components.dataRecovery.retryMigration', 'Retry migration')}
</Button>
<Button type="button" size="xs" variant="outline" onClick={() => setRecoveryOpen(true)}>
{translate('auto.components.dataRecovery.openDataRecovery', 'Open Data recovery')}
</Button>
<Button
type="button"
size="xs"
variant="ghost"
onClick={() => void navigator.clipboard.writeText(migrationError)}
>
{translate('auto.components.dataRecovery.copyDetails', 'Copy details')}
</Button>
</div>
<DataRecoveryDialog open={recoveryOpen} onOpenChange={setRecoveryOpen} />
</div>
)
}
@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { DataRecoveryDialog } from './DataRecoveryDialog'
/** Settings-side entry into Data recovery (runbook: downgrade step 1 offers
* "the app-level migration notice or Settings"). Renders nothing when no
* recovery point exists or the surface is absent (paired web). */
export function DataRecoverySettingsRow() {
const [hasPoints, setHasPoints] = useState(false)
const [open, setOpen] = useState(false)
useEffect(() => {
let cancelled = false
void window.api.dataRecovery
?.listPoints()
.then((points) => {
if (!cancelled) {
setHasPoints(points.length > 0)
}
})
.catch(() => {})
return () => {
cancelled = true
}
}, [])
if (!hasPoints) {
return null
}
return (
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>
{translate(
'auto.components.dataRecovery.settingsRowHint',
'A pre-update recovery point exists for this profile.'
)}
</span>
<Button type="button" variant="outline" size="xs" onClick={() => setOpen(true)}>
{translate('auto.components.dataRecovery.settingsRowOpen', 'Data recovery…')}
</Button>
<DataRecoveryDialog open={open} onOpenChange={setOpen} />
</div>
)
}
@@ -105,6 +105,22 @@ describe('AgentCatalogSection (connected)', () => {
expect(mutate).toHaveBeenCalledTimes(1)
})
it('surfaces the migration block from the load-time snapshot before any mutation', async () => {
const getLocal = vi
.fn()
.mockResolvedValue(buildLocalCatalogSnapshot({ migrationBlockedError: 'disk full' }))
;(window as unknown as { api: unknown }).api = {
settings: {
agentCatalog: { getLocal },
onChanged: () => () => {}
}
}
render(<AgentCatalogSection agentCmdOverrides={{}} />)
expect(await screen.findByRole('alert')).toBeTruthy()
expect(screen.getByText('Agent settings are temporarily read-only')).toBeTruthy()
expect(screen.getByText('disk full')).toBeTruthy()
})
it('keeps catalog search usable in read-only mode while controls stay disabled', async () => {
render(<AgentCatalogSection agentCmdOverrides={{}} readOnly />)
const search = (await screen.findByLabelText('Search agents')) as HTMLInputElement
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import type {
BuiltInTuiAgent,
CustomTuiAgentId,
@@ -22,6 +22,7 @@ import {
AgentCatalogMigrationBlockedNotice,
asAgentCatalogMigrationBlocked
} from './agent-catalog-migration-blocked'
import { DataRecoverySettingsRow } from '../data-recovery/DataRecoverySettingsRow'
import { CustomAgentEditorDialog } from './CustomAgentEditorDialog'
import { BuiltInLaunchSettingsDialog } from './BuiltInLaunchSettingsDialog'
import { CustomAgentDeleteDialog } from './CustomAgentDeleteDialog'
@@ -70,7 +71,7 @@ export function AgentCatalogSection({
detectionTarget,
readOnly
}: AgentCatalogSectionProps): React.JSX.Element {
const { snapshot, refetch } = useLocalAgentCatalog()
const { snapshot, unavailable, refetch } = useLocalAgentCatalog()
const { detectedIds, isRefreshing, refresh } = useDetectedAgents(detectionTarget)
const detectedSet = useMemo<ReadonlySet<string> | null>(
() => (detectedIds ? new Set(detectedIds) : null),
@@ -81,6 +82,18 @@ export function AgentCatalogSection({
// write; without surfacing that, the toggles/default picker look like silent
// no-ops. A later success clears the notice (the block lifted).
const [migrationBlockedError, setMigrationBlockedError] = useState<string | null>(null)
// Why: the block is a load-time state (failed pre-v1 backup), not just a
// mutation outcome — a user who never edits must still learn writes are
// fail-closed. Each real snapshot is authoritative both ways: it arms the
// notice and clears one left stale by an app-banner Retry.
useEffect(() => {
if (!snapshot) {
return
}
setMigrationBlockedError(
typeof snapshot.migrationBlockedError === 'string' ? snapshot.migrationBlockedError : null
)
}, [snapshot])
const surfaceMigrationBlock = (result: { ok: boolean }): void => {
const blocked = asAgentCatalogMigrationBlocked(result)
if (blocked) {
@@ -257,6 +270,19 @@ export function AgentCatalogSection({
void refresh()
}
if (unavailable) {
// Paired web: the local catalog surface doesn't exist here; the pane-level
// read-only notice explains where to manage agents.
return (
<p className="text-sm text-muted-foreground">
{translate(
'auto.components.settings.AgentCatalogSection.unavailable',
'Custom agents are managed on the desktop host.'
)}
</p>
)
}
if (!snapshot) {
return (
<p className="text-sm text-muted-foreground">
@@ -289,6 +315,7 @@ export function AgentCatalogSection({
onRepairReplace={handleRepairReplace}
onRepairDuplicate={handleRepairDuplicate}
/>
{!readOnly ? <DataRecoverySettingsRow /> : null}
{editorMode.kind === 'built-in-launch' ? (
<BuiltInLaunchSettingsDialog
open={editorOpen}
+26 -6
View File
@@ -5,6 +5,9 @@ export type UseLocalAgentCatalog = {
/** Null while the first `getLocal` is in flight. */
snapshot: LocalAgentCatalogSnapshot | null
loading: boolean
/** True when the local catalog surface does not exist here (paired web);
* callers render their read-only empty state instead of a loading spinner. */
unavailable: boolean
/** Re-read the local snapshot from the host (desktop preload IPC only). */
refetch: () => void
/** Adopt the authoritative snapshot a local mutation just returned, without a
@@ -24,16 +27,27 @@ export type UseLocalAgentCatalog = {
*/
export function useLocalAgentCatalog(): UseLocalAgentCatalog {
const [snapshot, setSnapshot] = useState<LocalAgentCatalogSnapshot | null>(null)
const [unavailable, setUnavailable] = useState(false)
const requestTokenRef = useRef(0)
const mountedRef = useRef(true)
const load = useCallback(() => {
const token = (requestTokenRef.current += 1)
void window.api.settings.agentCatalog.getLocal().then((next) => {
if (mountedRef.current && token === requestTokenRef.current) {
setSnapshot(next)
}
})
void window.api.settings.agentCatalog
.getLocal()
.then((next) => {
if (mountedRef.current && token === requestTokenRef.current) {
setSnapshot(next)
setUnavailable(false)
}
})
.catch(() => {
// Paired web rejects (not_available_on_paired_web): surface an honest
// read-only empty state rather than a perpetual loading spinner.
if (mountedRef.current && token === requestTokenRef.current) {
setUnavailable(true)
}
})
}, [])
const applySnapshot = useCallback((next: LocalAgentCatalogSnapshot) => {
@@ -65,5 +79,11 @@ export function useLocalAgentCatalog(): UseLocalAgentCatalog {
}
}, [load])
return { snapshot, loading: snapshot === null, refetch: load, applySnapshot }
return {
snapshot,
loading: snapshot === null && !unavailable,
unavailable,
refetch: load,
applySnapshot
}
}
File diff suppressed because it is too large Load Diff
+46 -144
View File
@@ -185,6 +185,10 @@
"07f788de83": "WebSocket"
}
}
},
"worktreeCreate": {
"hostPredatesDefaultLaunch": "This Orca host predates default-agent launch. Pick a specific built-in agent or update Orca on the host.",
"hostPredatesCustomAgents": "This Orca host predates custom agents. Pick a built-in agent or update Orca on the host."
}
},
"store": {
@@ -263,10 +267,7 @@
"3b57982bf6": "Git no pudo eliminar de forma segura la rama \"{{value0}}\" después de eliminar el espacio de trabajo \"{{value1}}\", así que Orca la conservó para evitar perder commits locales.",
"81f13f48d2": "Git no pudo eliminar de forma segura la rama \"{{value0}}\" después de eliminar el worktree \"{{value1}}\", así que Orca la conservó para evitar perder commits locales.",
"runtimeScopeForbiddenTitle": "Esta conexión tiene acceso limitado (móvil)",
"runtimeScopeForbiddenDescription": "Los espacios de trabajo no están disponibles con un emparejamiento de acceso móvil. Vuelve a conectarte con el enlace del navegador desde Ajustes → Servidores remotos de Orca → Comparte este servidor Orca.",
"createdWithoutParentNesting": "Creado sin anidarlo bajo \"{{value0}}\"",
"createdWithoutParentNestingUnnamed": "Creado sin anidarlo bajo el worktree padre seleccionado",
"createdWithoutParentNestingDetail": "El espacio de trabajo padre ya no estaba disponible. Puedes establecerlo desde el menú del espacio de trabajo."
"runtimeScopeForbiddenDescription": "Los espacios de trabajo no están disponibles con un emparejamiento de acceso móvil. Vuelve a conectarte con el enlace del navegador desde Ajustes → Servidores remotos de Orca → Comparte este servidor Orca."
},
"repos": {
"2975400634": "Actualiza el servidor Orca para abrir carpetas que no sean Git en este host.",
@@ -3013,8 +3014,7 @@
"25dc1cd653": "Abrir archivo",
"7cdf8ee0c8": "Abrir URL",
"b27864279e": "Iniciar agente",
"omniboxPlaceholderWithHistory": "Buscar pestañas abiertas, historial, archivos, URL y agentes…",
"openPage": "Abrir página",
"0e5b7a3f16": "Buscar pestañas abiertas, archivos, URL y agentes…",
"8f0a1c4d92": "Cambiar a la pestaña",
"2c38630a01": "El espacio de trabajo ya no existe",
"4f0d9a71c2": "La pestaña ya no existe",
@@ -3060,6 +3060,7 @@
"classifier": {
"42e6262ae9": "No hay acción disponible.",
"097a982ee0": "Cargando archivos...",
"c41f8d20b7": "Buscar pestañas abiertas, archivos, URL y agentes…",
"90eb94dc48": "Introduce una URL http:// o https://.",
"5553b283ce": "Introduce una URL o ruta de archivo.",
"queryTooLarge": "El texto de búsqueda es demasiado grande.",
@@ -3505,8 +3506,7 @@
"resource": {
"memory": {
"metric": {
"workingSetDescription": "Suma del conjunto de trabajo (WS): las páginas residentes en RAM en este momento. Las páginas compartidas pueden aparecer en más de un proceso, y la memoria que Windows ha paginado a disco no se cuenta aquí.",
"privateBytesDescription": "Suma de bytes privados: la memoria que estos procesos han confirmado, contada tanto si está residente como si está paginada a disco. Es lo que el host imputa a su límite de confirmación, así que sigue subiendo mientras el conjunto de trabajo de arriba se reduce por la paginación.",
"workingSetDescription": "Suma del conjunto de trabajo (WS). Las páginas compartidas pueden aparecer en más de un proceso.",
"rssDescription": "Suma del tamaño del conjunto residente (RSS). Las páginas compartidas o con alias pueden aparecer en más de un proceso."
}
},
@@ -3877,97 +3877,6 @@
}
},
"skills": {
"SkillsPage": {
"cb142070b4": "Actualizar",
"a68dee6a32": "Buscar skills",
"f43ad6edf3": "Skills",
"ea72d6185b": "No se pudieron analizar las skills",
"dc4c3328ee": "Mostrar archivo",
"9963dff6d3": "No se encontró ninguna descripción.",
"995fde8337": "No se pudo mostrar el archivo de la skill",
"4acd6d68ec": "No se encontraron skills",
"6a62a0168c": "Sin coincidencias",
"08a321a984": "Ninguna skill coincide con la búsqueda y los filtros actuales.",
"cd7893fbc1": "Analizando skills",
"35b9a724a0": "Disponible",
"c13b82793c": "Gestionar instalaciones",
"aee7b99cc6": "Instalar desde un enlace",
"filterProvider": "Filtrar por agente",
"filterSource": "Filtrar por origen",
"allSources": "Todo",
"clearFilters": "Borrar filtros",
"closeSkills": "Cerrar skills",
"closeTooltip": "Cerrar · Esc",
"moreActions": "Más acciones",
"sharedLinks": "Enlaces compartidos",
"emptyCopy": "Las carpetas de skills analizadas están vacías. Instala un paquete compartido o actualiza después de añadir una skill.",
"retry": "Reintentar",
"remoteShareNotice": "Estas skills están en {{host}}. Abre Skills en esa máquina para compartirlas.",
"viewSwitch": "Mostrar",
"searchLinks": "Buscar enlaces",
"deleteSkills": "Eliminar skills…"
},
"SkillShareSelectionControls": {
"01c5a15e02": "Compartir skills"
},
"SkillRow": {
"updatedUnknown": "Sin fecha",
"pathCopied": "Ruta copiada",
"copyPath": "Copiar ruta",
"detailPath": "Ruta",
"skillActions": "Acciones para {{value0}}",
"viewDetails": "Ver detalles",
"deleteSkill": "Eliminar…"
},
"SkillsList": {
"listLabel": "Skills"
},
"sourceStatus": {
"missing": "Carpeta no encontrada",
"remoteRepo": "Repositorio remoto — sin analizar",
"unavailable": "Sin analizar"
},
"sources": {
"heading": "Carpetas de skills"
},
"sourceKind": {
"home": "Inicio",
"workspace": "Espacio de trabajo",
"bundled": "Incluida",
"plugin": "Plugin"
},
"count": {
"skillOne": "{{count}} skill",
"skillOther": "{{count}} skills",
"sourceOne": "{{count}} origen",
"sourceOther": "{{count}} orígenes",
"fileOne": "{{count}} archivo",
"fileOther": "{{count}} archivos",
"resultOne": "{{count}} resultado",
"resultOther": "{{count}} resultados",
"selected": "{{count}} seleccionadas",
"shareOne": "Compartir {{count}} skill",
"shareOther": "Compartir {{count}} skills",
"linkOne": "{{count}} enlace",
"linkOther": "{{count}} enlaces"
},
"filter": {
"allAgents": "Todos los agentes",
"sharedAgent": "Compartido (.agents)"
},
"SkillsSelectionHeader": {
"exit": "Salir de la selección",
"exitTooltip": "Salir de la selección · Esc",
"title": "Seleccionar skills para compartir",
"selectAll": "Seleccionar las {{count}} aptas",
"clear": "Borrar",
"deleteTitle": "Seleccionar skills para eliminar"
},
"SkillDetailDialog": {
"agents": "Agentes",
"updated": "Actualizado",
"copy": "Copiar"
},
"SkillFreshnessNudge": {
"titleOne": "Una skill de Orca instalada está desactualizada",
"titleMany": "{{value0}} skills de Orca instaladas están desactualizadas",
@@ -4064,10 +3973,6 @@
"SkillUpdateRow": {
"oneLocation": "1 location",
"manyLocations": "{{value0}} locations"
},
"host": {
"local": "Esta máquina",
"remote": "Entorno conectado"
}
},
"sidebar": {
@@ -6177,9 +6082,10 @@
"compareBaseBranchUpstream": "Upstream de la rama"
},
"HiddenExperimentalGroup": {
"7c4e18d2f6": "Activa los gestos de captura del fallo de negrita en esta máquina: {{samplingShortcut}} inicia una ráfaga de muestreo; {{captureShortcut}} captura evidencia del panel en disco.",
"b09f24a51d": "Diagnósticos de renderizado del terminal",
"f2c81d904a": "Configuración experimental oculta"
"d0f914a528": "Alternar marcador de posición",
"1014ddbfaf": "No hace nada hoy. Reservado como primer espacio para opciones experimentales ocultas.",
"232cf83de8": "Alternadores no listados para pruebas internas. Aquí no se admite nada.",
"3e9e827ca5": "experimental oculto"
},
"InputPane": {
"db15068196": "Habilitado de forma predeterminada en Linux y macOS. Linux usa el portapapeles de selección del sistema; otras plataformas utilizan un búfer privado.",
@@ -9882,7 +9788,8 @@
"defaultTitle": "Agente predeterminado",
"nullDefaultAttention": "Elige un agente predeterminado. Hasta que lo hagas, los inicios atendidos no podrán comenzar y te pedirán que elijas uno.",
"migrationBlockedTitle": "Los ajustes de agentes son temporalmente de solo lectura",
"migrationBlockedDescription": "Orca no pudo hacer una copia de seguridad de tus ajustes de agentes existentes antes de actualizarlos, por lo que los cambios están bloqueados para proteger tus datos. Resuelve el problema que aparece abajo y vuelve a intentarlo."
"migrationBlockedDescription": "Orca no pudo hacer una copia de seguridad de tus ajustes de agentes existentes antes de actualizarlos, por lo que los cambios están bloqueados para proteger tus datos. Resuelve el problema que aparece abajo y vuelve a intentarlo.",
"unavailable": "Custom agents are managed on the desktop host."
},
"AgentCatalogStatusBadge": {
"enabled": "Activado",
@@ -10915,8 +10822,6 @@
"7a09d7f9d2": "base",
"383cf92c73": "árbol",
"d7ae61269b": "Commits en la rama",
"branchFilesChangedVsBaseOne": "1 archivo cambiado respecto a {{ref}}",
"branchFilesChangedVsBaseOther": "{{count}} archivos cambiados respecto a {{ref}}",
"48a003c1b1": "Cambios preparados",
"d4ef4bafc5": "Cambios",
"522f44dce5": "Archivos sin seguimiento",
@@ -10991,10 +10896,10 @@
"b3c8f1a902": "Filtrar archivos por nombre",
"d4f8c2a901": "Limpiar y cerrar filtro",
"e8a1c4b203": "vs",
"compareBaseCommitsAheadOne": "1 commit por delante de {{ref}}",
"compareBaseCommitsAheadOther": "{{count}} commits por delante de {{ref}}",
"compareBaseCommitsBehindOne": "1 commit por detrás de {{ref}}",
"compareBaseCommitsBehindOther": "{{count}} commits por detrás de {{ref}}",
"f9b2441bb6": "1 commit por delante de {{value0}}",
"b715ef615b": "{{value0}} commits por delante de {{value1}}",
"c1a8f3e204": "1 commit por detrás de {{value0}}",
"d2b9g4f315": "{{value0}} commits por detrás de {{value1}}",
"4b4a7de138": "Abrir página de revisión en el navegador",
"createPrIntentCommitBlockedSummary": "Commit bloqueado: {{value0}} Corrige el issue y reintenta crear PR.",
"pushRecovery": {
@@ -11017,6 +10922,7 @@
"a4e93c21d7": "Rama actual: {{value0}}",
"c7d4e2f801": "Cambiar ref base: {{value0}}",
"f3a1b8c204": "upstream",
"createPrIntentEmptyGeneratedBody": "Los detalles de revisión generados no incluyeron una descripción. Reintentar Crear PR.",
"createPrIntentGenerateDetailsFailed": "No se pudieron generar los detalles de revisión. Reintentar Crear PR."
},
"SourceControlAgentActionDialog": {
@@ -13003,9 +12909,8 @@
"7e7ca60816": "archivos cambiados",
"b6c3b84476": "Mostrar árbol de archivos",
"39f8007549": "Revisar conflictos",
"skippedConflictsExcluded": "Se excluyeron {{count}} conflictos no resueltos de esta vista de diff.",
"skippedConflictsExcluded_one": "Se excluyó {{count}} conflicto no resuelto de esta vista de diff.",
"skippedConflictsExcluded_other": "Se excluyeron {{count}} conflictos no resueltos de esta vista de diff.",
"39e73e7181": "se excluyeron de esta vista de diff.",
"689b99f8ad": "conflicto no resuelto",
"820ec01f24": "Los archivos en conflicto se revisan por separado",
"fd8892b120": "No hay cambios para mostrar",
"eb5f40e49c": "Esta vista de diff excluye los conflictos sin resolver porque el pipeline normal de diff de dos vías no es seguro para conflictos.",
@@ -13967,7 +13872,6 @@
"showMore": "Mostrar más"
},
"AutomationDetail": {
"host": "Host",
"007c8ad874": "Inmediato",
"a1d52c2189": "Cobertura de uso",
"449fc83bf7": "Fichas",
@@ -14131,9 +14035,6 @@
"b11170a008": "No se pudo guardar la automatización.",
"2a20596d6b": "Automatización guardada.",
"244727e655": "Automatización actualizada.",
"moved": "Automatización movida a {host}.",
"moveOriginalKept": "Se creó en {host}, pero no se pudo eliminar la original. Elimínela en el host anterior.",
"moveOriginalUnverified": "Se creó en {host}, pero no se pudo verificar la eliminación de la original. Compruebe el host anterior antes de volver a intentarlo.",
"77b81bc4ac": "Se crea la automatización de Hermes.",
"08efc3ae12": "Automatización Hermes actualizada.",
"e431bb85d4": "Elija un espacio de trabajo en el mismo host que esta automatización de Hermes.",
@@ -14177,7 +14078,6 @@
"backToList": "Todas las automatizaciones",
"tableName": "Nombre",
"tableProject": "Proyecto",
"tableHost": "Host",
"tableStatus": "Estado",
"tableActions": "Acciones",
"newAutomation": "Nueva automatización",
@@ -14330,19 +14230,6 @@
"filters": "Filtros",
"clear": "Limpiar filtros",
"removeFilter": "Eliminar filtro de {{value0}}"
},
"createDestination": {
"label": "Host",
"placeholder": "Seleccionar un host",
"storedOn": "Almacenada y programada por {authority}.",
"unselected": "Elija el host que almacena y programa esta automatización.",
"orphan": "Las automatizaciones sin host no pueden alojar otras nuevas. Elija un host en el que crear esta automatización.",
"unavailable": "Ese host todavía no puede alojar una nueva automatización. Elija otro host para crear esta.",
"stale": "{host} cambió mientras este formulario estaba abierto. Elíjalo de nuevo antes de guardar.",
"projectMismatch": "Ese proyecto no está en {host}. Elija un proyecto en ese host, u otro host.",
"noProjects": "No hay proyectos configurados en {host}. Agregue uno allí o elija otro host.",
"updateRequired": "Actualice el servidor de Orca en {hosts} para almacenar automatizaciones allí.",
"move": "Al guardar se crea esta automatización en {host} y se eliminan la original y su historial de ejecuciones."
}
},
"agent": {
@@ -14697,15 +14584,6 @@
"delete": "Eliminar"
}
},
<<<<<<< HEAD
"ComposerParentWorktreePicker": {
"label": "Worktree padre",
"noParent": "Sin worktree padre",
"description": "Anida este espacio de trabajo bajo otro en la barra lateral. No cambia la rama base.",
"searchPlaceholder": "Buscar espacios de trabajo...",
"noMatches": "No hay coincidencias."
||||||| parent of ebaa81ab2f (Rebase custom-agents onto main (2/4): renderer)
=======
"AgentLaunchNotices": {
"disabledFallback": "{{value0}} está deshabilitado. Se inició {{value1}} estándar sin ejecutable personalizado, argumentos personalizados ni entorno de agente personalizado.",
"missingFallback": "{{value0}} se eliminó. Se inició {{value1}} estándar sin ejecutable personalizado, argumentos personalizados ni entorno de agente personalizado.",
@@ -14728,7 +14606,31 @@
"openTerminal": "Abrir terminal",
"forgetLaunch": "Olvidar lanzamiento…",
"manageAgents": "Administrar agentes"
>>>>>>> ebaa81ab2f (Rebase custom-agents onto main (2/4): renderer)
},
"dataRecovery": {
"pointAgentCatalogPreV1Title": "Before the custom-agents update (agent catalog v1)",
"pointAgentCatalogPreV1Loss": "Restoring discards all settings and workspace metadata saved after this point, including any custom agents.",
"title": "Data recovery",
"description": "Restore this profile from a recovery point created before a data migration. Restoring never deletes the recovery point.",
"loading": "Looking for recovery points…",
"empty": "No recovery points exist for this profile.",
"createdAt": "Created {{date}}",
"createdUnknown": "Creation time unknown",
"previousBinary": "After restoring, Orca quits. Install the previous Orca version before opening it again.",
"restoring": "Restoring…",
"confirmRestore": "Restore and quit",
"cancel": "Cancel",
"prepareDowngrade": "Prepare downgrade…",
"restoreFailed": "Restore failed and no changes were made: {{error}}",
"close": "Close",
"noticeTitle": "Orca could not back up your data before a required update",
"noticeBody": "Your existing data is unchanged and built-in agents keep working, but custom-agent settings are read-only until this is resolved.",
"retrying": "Retrying…",
"retryMigration": "Retry migration",
"openDataRecovery": "Open Data recovery",
"copyDetails": "Copy details",
"settingsRowHint": "A pre-update recovery point exists for this profile.",
"settingsRowOpen": "Data recovery…"
}
},
"i18n": {
+46 -144
View File
@@ -185,6 +185,10 @@
"07f788de83": "ウェブソケット"
}
}
},
"worktreeCreate": {
"hostPredatesDefaultLaunch": "This Orca host predates default-agent launch. Pick a specific built-in agent or update Orca on the host.",
"hostPredatesCustomAgents": "This Orca host predates custom agents. Pick a built-in agent or update Orca on the host."
}
},
"store": {
@@ -263,10 +267,7 @@
"3b57982bf6": "ワークスペース\"{{value1}}\"を削除した後、Git はブランチ\"{{value0}}\"を安全に削除できなかったため、Orca はローカルコミットを失わないように保持しました。",
"81f13f48d2": "ワークツリー\"{{value1}}\"を削除した後、Git はブランチ\"{{value0}}\"を安全に削除できなかったため、Orca はローカルコミットを失わないように保持しました。",
"runtimeScopeForbiddenTitle": "この接続は制限付き(モバイル)アクセスです",
"runtimeScopeForbiddenDescription": "モバイル向けペアリングではワークスペースを使用できません。[設定] → [リモート Orca サーバー] → [この Orca サーバーを共有する] から、ブラウザ用リンクで再接続してください。",
"createdWithoutParentNesting": "「{{value0}}」の配下にネストせずに作成しました",
"createdWithoutParentNestingUnnamed": "選択した親ワークツリーの配下にネストせずに作成しました",
"createdWithoutParentNestingDetail": "親ワークスペースは利用できなくなりました。ワークスペースメニューから設定できます。"
"runtimeScopeForbiddenDescription": "モバイル向けペアリングではワークスペースを使用できません。[設定] → [リモート Orca サーバー] → [この Orca サーバーを共有する] から、ブラウザ用リンクで再接続してください。"
},
"repos": {
"2975400634": "Orca サーバーを更新して、このランタイムで Git 以外のフォルダーを開くようにします。",
@@ -3013,8 +3014,7 @@
"25dc1cd653": "ファイルを開く",
"7cdf8ee0c8": "URLを開く",
"b27864279e": "Agent を起動",
"omniboxPlaceholderWithHistory": "開いているタブ、履歴、ファイル、URL、Agent を検索…",
"openPage": "ページを開く",
"0e5b7a3f16": "開いているタブ、ファイル、URL、Agent を検索…",
"8f0a1c4d92": "タブに切り替える",
"2c38630a01": "ワークスペースは存在しません",
"4f0d9a71c2": "タブは存在しません",
@@ -3060,6 +3060,7 @@
"classifier": {
"42e6262ae9": "利用可能な操作はありません。",
"097a982ee0": "ファイルを読み込み中…",
"c41f8d20b7": "開いているタブ、ファイル、URL、Agent を検索…",
"90eb94dc48": "http:// または https:// URL を入力します。",
"5553b283ce": "URL またはファイルパスを入力します。",
"queryTooLarge": "検索テキストが大きすぎます。",
@@ -3505,8 +3506,7 @@
"resource": {
"memory": {
"metric": {
"workingSetDescription": "合計ワーキングセット(WS): 現在 RAM に常駐しているページです。共有ページが複数のプロセスに表示されることがあり、Windows がページアウトしたメモリはここには含まれません。",
"privateBytesDescription": "合計プライベートバイト: これらのプロセスがコミットしたメモリで、常駐中かページアウト済みかを問わず計上されます。ホストがコミット制限に対して計上する値であるため、ページングによって上のワーキングセットが縮小しても増え続けます。",
"workingSetDescription": "合計ワーキングセット(WS)。共有ページが複数のプロセスに表示されることがあります。",
"rssDescription": "合計レジデントセットサイズ(RSS)。共有またはエイリアスされたページが複数のプロセスに表示されることがあります。"
}
},
@@ -3877,97 +3877,6 @@
}
},
"skills": {
"SkillsPage": {
"cb142070b4": "更新",
"a68dee6a32": "スキルを検索",
"f43ad6edf3": "スキル",
"ea72d6185b": "スキルをスキャンできませんでした",
"dc4c3328ee": "ファイルを表示",
"9963dff6d3": "説明が見つかりません。",
"995fde8337": "スキルファイルを表示できませんでした",
"4acd6d68ec": "スキルが見つかりません",
"6a62a0168c": "一致する項目はありません",
"08a321a984": "現在の検索条件とフィルターに一致するスキルはありません。",
"cd7893fbc1": "スキルをスキャン中",
"35b9a724a0": "利用可能",
"c13b82793c": "インストールを管理",
"aee7b99cc6": "リンクからインストール",
"filterProvider": "Agent で絞り込み",
"filterSource": "ソースで絞り込み",
"allSources": "すべて",
"clearFilters": "フィルターをクリア",
"closeSkills": "スキルを閉じる",
"closeTooltip": "閉じる · Esc",
"moreActions": "その他の操作",
"sharedLinks": "共有リンク",
"emptyCopy": "スキャンしたスキルフォルダーは空です。共有バンドルをインストールするか、スキルを追加後に更新してください。",
"retry": "再試行",
"remoteShareNotice": "これらのスキルは {{host}} にあります。共有するには、そのマシンでスキルを開いてください。",
"viewSwitch": "表示",
"searchLinks": "リンクを検索",
"deleteSkills": "スキルを削除…"
},
"SkillShareSelectionControls": {
"01c5a15e02": "スキルを共有"
},
"SkillRow": {
"updatedUnknown": "日付なし",
"pathCopied": "パスをコピーしました",
"copyPath": "パスをコピー",
"detailPath": "パス",
"skillActions": "{{value0}} の操作",
"viewDetails": "詳細を表示",
"deleteSkill": "削除…"
},
"SkillsList": {
"listLabel": "スキル"
},
"sourceStatus": {
"missing": "フォルダーが見つかりません",
"remoteRepo": "リモートリポジトリ — 未スキャン",
"unavailable": "未スキャン"
},
"sources": {
"heading": "スキルフォルダー"
},
"sourceKind": {
"home": "ホーム",
"workspace": "ワークスペース",
"bundled": "バンドル済み",
"plugin": "プラグイン"
},
"count": {
"skillOne": "{{count}} 件のスキル",
"skillOther": "{{count}} 件のスキル",
"sourceOne": "{{count}} 件のソース",
"sourceOther": "{{count}} 件のソース",
"fileOne": "{{count}} 個のファイル",
"fileOther": "{{count}} 個のファイル",
"resultOne": "{{count}} 件の結果",
"resultOther": "{{count}} 件の結果",
"selected": "{{count}} 件を選択",
"shareOne": "{{count}} 件のスキルを共有",
"shareOther": "{{count}} 件のスキルを共有",
"linkOne": "{{count}} 件のリンク",
"linkOther": "{{count}} 件のリンク"
},
"filter": {
"allAgents": "すべての Agent",
"sharedAgent": "共有 (.agents)"
},
"SkillsSelectionHeader": {
"exit": "選択を終了",
"exitTooltip": "選択を終了 · Esc",
"title": "共有するスキルを選択",
"selectAll": "対象の {{count}} 件をすべて選択",
"clear": "クリア",
"deleteTitle": "削除するスキルを選択"
},
"SkillDetailDialog": {
"agents": "Agent",
"updated": "更新日",
"copy": "コピー"
},
"SkillFreshnessNudge": {
"titleOne": "インストール済みの Orca スキルが古くなっています",
"titleMany": "インストール済みの Orca スキル {{value0}} 件が古くなっています",
@@ -4064,10 +3973,6 @@
"SkillUpdateRow": {
"oneLocation": "1 件の場所",
"manyLocations": "{{value0}} 件の場所"
},
"host": {
"local": "このマシン",
"remote": "接続済みランタイム"
}
},
"sidebar": {
@@ -6199,9 +6104,10 @@
"compareBaseBranchUpstream": "ブランチの upstream"
},
"HiddenExperimentalGroup": {
"7c4e18d2f6": "このマシンで太字グリッチのキャプチャジェスチャーを有効にします。{{samplingShortcut}} でサンプリングバーストを開始し、{{captureShortcut}} でペインの証拠をディスクに保存します。",
"b09f24a51d": "ターミナル描画診断",
"f2c81d904a": "隠された実験的機能の設定"
"d0f914a528": "プレースホルダーの切り替え",
"1014ddbfaf": "今日は何もしません。隠された実験的機能オプションの最初のスロットとして予約されています。",
"232cf83de8": "内部テスト用の非公開トグル。ここでは何もサポートされていません。",
"3e9e827ca5": "隠された実験的機能"
},
"InputPane": {
"db15068196": "Linux および macOS ではデフォルトで有効になっています。 Linux はシステム選択クリップボードを使用します。他のプラットフォームはプライベートバッファを使用します。",
@@ -9882,7 +9788,8 @@
"defaultTitle": "デフォルトエージェント",
"nullDefaultAttention": "デフォルトのエージェントを選択してください。選択するまで、対話的な起動は開始できず、選択を求められます。",
"migrationBlockedTitle": "エージェント設定は一時的に読み取り専用です",
"migrationBlockedDescription": "Orca は既存のエージェント設定を更新する前にバックアップを作成できなかったため、データを保護するために変更がブロックされています。以下の問題を解決してから、もう一度お試しください。"
"migrationBlockedDescription": "Orca は既存のエージェント設定を更新する前にバックアップを作成できなかったため、データを保護するために変更がブロックされています。以下の問題を解決してから、もう一度お試しください。",
"unavailable": "Custom agents are managed on the desktop host."
},
"AgentCatalogStatusBadge": {
"enabled": "有効",
@@ -10915,8 +10822,6 @@
"7a09d7f9d2": "ベース",
"383cf92c73": "木",
"d7ae61269b": "コミット先のブランチ",
"branchFilesChangedVsBaseOne": "{{ref}} と比べて 1 ファイルが変更されています",
"branchFilesChangedVsBaseOther": "{{ref}} と比べて {{count}} ファイルが変更されています",
"48a003c1b1": "ステージ済みの変更",
"d4ef4bafc5": "変更点",
"522f44dce5": "追跡されていないファイル",
@@ -10991,10 +10896,10 @@
"b3c8f1a902": "ファイル名でフィルター",
"d4f8c2a901": "フィルターをクリアして閉じる",
"e8a1c4b203": "vs",
"compareBaseCommitsAheadOne": "{{ref}} より 1 コミット進んでいます",
"compareBaseCommitsAheadOther": "{{ref}}より{{count}}コミット進んでいます",
"compareBaseCommitsBehindOne": "{{ref}} より 1 コミット遅れています",
"compareBaseCommitsBehindOther": "{{ref}}より{{count}}コミット遅れています",
"f9b2441bb6": "{{value0}} より 1 コミット進んでいます",
"b715ef615b": "{{value1}}より{{value0}}コミット進んでいます",
"c1a8f3e204": "{{value0}} より 1 コミット遅れています",
"d2b9g4f315": "{{value1}}より{{value0}}コミット遅れています",
"4b4a7de138": "ブラウザでレビューページを開く",
"createPrIntentCommitBlockedSummary": "コミットがブロックされました:{{value0}} Issue を修正して、PR 作成を再試行してください。",
"pushRecovery": {
@@ -11017,6 +10922,7 @@
"a4e93c21d7": "現在のブランチ: {{value0}}",
"c7d4e2f801": "ベース ref を変更: {{value0}}",
"f3a1b8c204": "upstream",
"createPrIntentEmptyGeneratedBody": "生成されたレビューの詳細に説明が含まれていませんでした。PR の作成を再試行してください。",
"createPrIntentGenerateDetailsFailed": "レビューの詳細を生成できませんでした。PR の作成を再試行してください。"
},
"SourceControlAgentActionDialog": {
@@ -13003,9 +12909,8 @@
"7e7ca60816": "変更されたファイル",
"b6c3b84476": "ファイルツリーを表示",
"39f8007549": "競合をレビューする",
"skippedConflictsExcluded": "未解決の競合 {{count}} 件がこの差分ビューから除外されました。",
"skippedConflictsExcluded_one": "未解決の競合 {{count}} 件がこの差分ビューから除外されました。",
"skippedConflictsExcluded_other": "未解決の競合 {{count}} 件がこの差分ビューから除外されました。",
"39e73e7181": "はこの差分ビューから除外されました。",
"689b99f8ad": "未解決の競合",
"820ec01f24": "競合したファイルは個別にレビューされます",
"fd8892b120": "表示に変更はありません",
"eb5f40e49c": "通常の双方向 diff パイプラインは競合に対して安全ではないため、この diff ビューでは未解決の競合が除外されます。",
@@ -13967,7 +13872,6 @@
"showMore": "さらに表示"
},
"AutomationDetail": {
"host": "ホスト",
"007c8ad874": "プロンプト",
"a1d52c2189": "使用範囲",
"449fc83bf7": "トークン",
@@ -14131,9 +14035,6 @@
"b11170a008": "オートメーションを保存できませんでした。",
"2a20596d6b": "オートメーションが保存されました。",
"244727e655": "自動化が更新されました。",
"moved": "自動化を {host} に移動しました。",
"moveOriginalKept": "{host} に作成しましたが、元の自動化を削除できませんでした。元のホストで削除してください。",
"moveOriginalUnverified": "{host} に作成しましたが、元の自動化の削除を確認できませんでした。再試行する前に元のホストを確認してください。",
"77b81bc4ac": "Hermes オートメーションが作成されました。",
"08efc3ae12": "Hermes オートメーションが更新されました。",
"e431bb85d4": "この Hermes オートメーションと同じホスト上のワークスペースを選択します。",
@@ -14177,7 +14078,6 @@
"backToList": "すべての自動化",
"tableName": "名前",
"tableProject": "プロジェクト",
"tableHost": "ホスト",
"tableStatus": "ステータス",
"tableActions": "操作",
"newAutomation": "新しい自動化",
@@ -14330,19 +14230,6 @@
"filters": "フィルター",
"clear": "フィルターをクリア",
"removeFilter": "{{value0}} フィルターを解除"
},
"createDestination": {
"label": "ホスト",
"placeholder": "ホストを選択",
"storedOn": "{authority} が保存してスケジュールします。",
"unselected": "この自動化を保存してスケジュールするホストを選択します。",
"orphan": "ホストのない自動化は新しい自動化を保持できません。この自動化を作成するホストを選択してください。",
"unavailable": "そのホストはまだ新しい自動化を保持できません。これを作成する別のホストを選択してください。",
"stale": "このフォームを開いている間に {host} が変更されました。保存する前にもう一度選択してください。",
"projectMismatch": "そのプロジェクトは {host} にありません。そのホスト上のプロジェクト、または別のホストを選択してください。",
"noProjects": "{host} にはプロジェクトが設定されていません。そこに追加するか、別のホストを選択してください。",
"updateRequired": "{hosts} の Orca サーバーを更新すると、そこに自動化を保存できます。",
"move": "保存すると、この自動化は {host} に作成され、元の自動化と実行履歴は削除されます。"
}
},
"agent": {
@@ -14697,15 +14584,6 @@
"delete": "削除"
}
},
<<<<<<< HEAD
"ComposerParentWorktreePicker": {
"label": "親ワークツリー",
"noParent": "親なし",
"description": "このワークスペースをサイドバー内の別のワークスペースの配下にネストします。ベースブランチは変更されません。",
"searchPlaceholder": "ワークスペースを検索...",
"noMatches": "一致する項目がありません。"
||||||| parent of ebaa81ab2f (Rebase custom-agents onto main (2/4): renderer)
=======
"AgentLaunchNotices": {
"disabledFallback": "{{value0}} は無効です。カスタム実行ファイル、カスタム引数、カスタムエージェント環境なしで標準の {{value1}} を起動しました。",
"missingFallback": "{{value0}} は削除されました。カスタム実行ファイル、カスタム引数、カスタムエージェント環境なしで標準の {{value1}} を起動しました。",
@@ -14728,7 +14606,31 @@
"openTerminal": "ターミナルを開く",
"forgetLaunch": "起動を破棄…",
"manageAgents": "エージェントを管理"
>>>>>>> ebaa81ab2f (Rebase custom-agents onto main (2/4): renderer)
},
"dataRecovery": {
"pointAgentCatalogPreV1Title": "Before the custom-agents update (agent catalog v1)",
"pointAgentCatalogPreV1Loss": "Restoring discards all settings and workspace metadata saved after this point, including any custom agents.",
"title": "Data recovery",
"description": "Restore this profile from a recovery point created before a data migration. Restoring never deletes the recovery point.",
"loading": "Looking for recovery points…",
"empty": "No recovery points exist for this profile.",
"createdAt": "Created {{date}}",
"createdUnknown": "Creation time unknown",
"previousBinary": "After restoring, Orca quits. Install the previous Orca version before opening it again.",
"restoring": "Restoring…",
"confirmRestore": "Restore and quit",
"cancel": "Cancel",
"prepareDowngrade": "Prepare downgrade…",
"restoreFailed": "Restore failed and no changes were made: {{error}}",
"close": "Close",
"noticeTitle": "Orca could not back up your data before a required update",
"noticeBody": "Your existing data is unchanged and built-in agents keep working, but custom-agent settings are read-only until this is resolved.",
"retrying": "Retrying…",
"retryMigration": "Retry migration",
"openDataRecovery": "Open Data recovery",
"copyDetails": "Copy details",
"settingsRowHint": "A pre-update recovery point exists for this profile.",
"settingsRowOpen": "Data recovery…"
}
},
"i18n": {
+84 -191
View File
@@ -185,6 +185,10 @@
"07f788de83": "웹소켓"
}
}
},
"worktreeCreate": {
"hostPredatesDefaultLaunch": "This Orca host predates default-agent launch. Pick a specific built-in agent or update Orca on the host.",
"hostPredatesCustomAgents": "This Orca host predates custom agents. Pick a built-in agent or update Orca on the host."
}
},
"store": {
@@ -266,10 +270,7 @@
"3b57982bf6": "\"{{value1}}\" 워크스페이스를 삭제한 후 Git이 \"{{value0}}\" 브랜치를 안전하게 삭제할 수 없어 Orca가 로컬 커밋 손실을 방지하기 위해 유지했습니다.",
"81f13f48d2": "\"{{value1}}\" 워크트리를 삭제한 후 Git이 \"{{value0}}\" 브랜치를 안전하게 삭제할 수 없어 Orca가 로컬 커밋 손실을 방지하기 위해 유지했습니다.",
"runtimeScopeForbiddenTitle": "이 연결은 제한된(모바일) 액세스입니다",
"runtimeScopeForbiddenDescription": "모바일 범위 페어링에서는 워크스페이스를 사용할 수 없습니다. 설정 → 원격 Orca 서버 → 이 Orca 서버 공유에서 브라우저 액세스 링크로 다시 연결하세요.",
"createdWithoutParentNesting": "\"{{value0}}\" 아래에 중첩하지 않고 생성했습니다",
"createdWithoutParentNestingUnnamed": "선택한 상위 워크트리 아래에 중첩하지 않고 생성했습니다",
"createdWithoutParentNestingDetail": "상위 워크스페이스를 더 이상 사용할 수 없습니다. 워크스페이스 메뉴에서 설정할 수 있습니다."
"runtimeScopeForbiddenDescription": "모바일 범위 페어링에서는 워크스페이스를 사용할 수 없습니다. 설정 → 원격 Orca 서버 → 이 Orca 서버 공유에서 브라우저 액세스 링크로 다시 연결하세요."
},
"repos": {
"2975400634": "이 런타임에서 Git이 아닌 폴더를 열려면 Orca 서버를 업데이트하세요.",
@@ -3018,8 +3019,7 @@
"25dc1cd653": "파일 열기",
"7cdf8ee0c8": "URL 열기",
"b27864279e": "agent 실행",
"omniboxPlaceholderWithHistory": "열린 탭, 방문 기록, 파일, URL, agent 검색…",
"openPage": "페이지 열기",
"0e5b7a3f16": "열린 탭, 파일, URL, agent 검색…",
"8f0a1c4d92": "탭으로 전환",
"2c38630a01": "워크스페이스가 더 이상 존재하지 않습니다",
"4f0d9a71c2": "탭이 더 이상 존재하지 않습니다",
@@ -3065,6 +3065,7 @@
"classifier": {
"42e6262ae9": "사용할 수 있는 작업이 없습니다.",
"097a982ee0": "파일 로드 중...",
"c41f8d20b7": "열린 탭, 파일, URL, agent 검색…",
"90eb94dc48": "http:// 또는 https:// URL을 입력하세요.",
"5553b283ce": "URL 또는 파일 경로를 입력하세요.",
"queryTooLarge": "검색 텍스트가 너무 큽니다.",
@@ -3510,8 +3511,7 @@
"resource": {
"memory": {
"metric": {
"workingSetDescription": "합산된 워킹 세트(WS): 지금 RAM에 상주 중인 페이지입니다. 공유 페이지가 둘 이상의 프로세스에 나타날 수 있으며, Windows가 페이지 아웃한 메모리는 여기에 포함되지 않습니다.",
"privateBytesDescription": "합산된 프라이빗 바이트: 이 프로세스들이 커밋한 메모리로, 상주 여부와 관계없이 계산됩니다. 호스트가 커밋 한도에 반영하는 값이므로, 페이징으로 위의 워킹 세트가 줄어드는 동안에도 계속 늘어납니다.",
"workingSetDescription": "합산된 워킹 세트(WS). 공유 페이지가 둘 이상의 프로세스에 나타날 수 있습니다.",
"rssDescription": "합산된 레지던트 세트 크기(RSS). 공유 또는 별칭된 페이지가 둘 이상의 프로세스에 나타날 수 있습니다."
}
},
@@ -3882,84 +3882,6 @@
}
},
"skills": {
"SkillsPage": {
"cb142070b4": "새로 고침",
"a68dee6a32": "스킬 검색",
"f43ad6edf3": "스킬",
"ea72d6185b": "스킬을 검색하지 못했습니다",
"dc4c3328ee": "파일 표시",
"9963dff6d3": "설명을 찾을 수 없습니다.",
"995fde8337": "스킬 파일을 표시하지 못했습니다",
"4acd6d68ec": "스킬을 찾을 수 없습니다",
"6a62a0168c": "일치하는 항목 없음",
"08a321a984": "현재 검색 및 필터와 일치하는 스킬이 없습니다.",
"cd7893fbc1": "스킬 검색 중",
"35b9a724a0": "사용 가능",
"c13b82793c": "설치 관리",
"aee7b99cc6": "링크에서 설치",
"filterProvider": "에이전트별 필터",
"filterSource": "소스별 필터",
"allSources": "전체",
"clearFilters": "필터 지우기",
"closeSkills": "스킬 닫기",
"closeTooltip": "닫기 · Esc",
"moreActions": "추가 작업",
"sharedLinks": "공유 링크",
"emptyCopy": "검색한 스킬 폴더가 비어 있습니다. 공유 번들을 설치하거나 스킬을 추가한 후 새로 고치세요.",
"retry": "다시 시도",
"remoteShareNotice": "이 스킬은 {{host}}에 있습니다. 공유하려면 해당 머신에서 스킬을 여세요.",
"viewSwitch": "표시",
"searchLinks": "링크 검색",
"deleteSkills": "스킬 삭제…"
},
"SkillShareSelectionControls": { "01c5a15e02": "스킬 공유" },
"SkillRow": {
"updatedUnknown": "날짜 없음",
"pathCopied": "경로 복사됨",
"copyPath": "경로 복사",
"detailPath": "경로",
"skillActions": "{{value0}} 작업",
"viewDetails": "세부 정보 보기",
"deleteSkill": "삭제…"
},
"SkillsList": { "listLabel": "스킬" },
"sourceStatus": {
"missing": "폴더를 찾을 수 없음",
"remoteRepo": "원격 리포지토리 — 검색 안 됨",
"unavailable": "검색 안 됨"
},
"sources": { "heading": "스킬 폴더" },
"sourceKind": {
"home": "홈",
"workspace": "워크스페이스",
"bundled": "번들",
"plugin": "플러그인"
},
"count": {
"skillOne": "스킬 {{count}}개",
"skillOther": "스킬 {{count}}개",
"sourceOne": "소스 {{count}}개",
"sourceOther": "소스 {{count}}개",
"fileOne": "파일 {{count}}개",
"fileOther": "파일 {{count}}개",
"resultOne": "결과 {{count}}개",
"resultOther": "결과 {{count}}개",
"selected": "{{count}}개 선택됨",
"shareOne": "스킬 {{count}}개 공유",
"shareOther": "스킬 {{count}}개 공유",
"linkOne": "링크 {{count}}개",
"linkOther": "링크 {{count}}개"
},
"filter": { "allAgents": "모든 에이전트", "sharedAgent": "공유됨 (.agents)" },
"SkillsSelectionHeader": {
"exit": "선택 나가기",
"exitTooltip": "선택 나가기 · Esc",
"title": "공유할 스킬 선택",
"selectAll": "가능한 {{count}}개 모두 선택",
"clear": "지우기",
"deleteTitle": "삭제할 스킬 선택"
},
"SkillDetailDialog": { "agents": "에이전트", "updated": "업데이트됨", "copy": "복사" },
"SkillFreshnessNudge": {
"titleOne": "설치된 Orca 스킬이 오래되었습니다",
"titleMany": "설치된 Orca 스킬 {{value0}}개가 오래되었습니다",
@@ -4056,10 +3978,6 @@
"SkillUpdateRow": {
"oneLocation": "1 location",
"manyLocations": "{{value0}} locations"
},
"host": {
"local": "이 컴퓨터",
"remote": "연결된 런타임"
}
},
"sidebar": {
@@ -4371,7 +4289,7 @@
"489d1c8c9f": "프로젝트 검색...",
"ee240a39eb": "필터 편집",
"automationCreated": "자동화로 생성된 워크스페이스 숨기기",
"cliCreated": "CLI로 생성된 워크스페이스 숨기기",
"cliCreated": "Hide CLI-created",
"detachedHead": "분리된 HEAD 숨기기",
"keepDefaultBranch": "기본 브랜치는 제외",
"keepDefaultBranchAria": "슬립 중인 워크스페이스를 숨겨도 기본 브랜치는 계속 표시"
@@ -4440,7 +4358,7 @@
"ed1611b65b": "슬립 중인 항목 숨기기",
"82594419ba": "필터",
"automationCreated": "자동화로 생성된 워크스페이스 숨기기",
"cliCreated": "CLI로 생성된 워크스페이스 숨기기",
"cliCreated": "Hide CLI-created",
"detachedHead": "분리된 HEAD 숨기기",
"keepDefaultBranch": "기본 브랜치는 제외",
"keepDefaultBranchAria": "슬립 중인 워크스페이스를 숨겨도 기본 브랜치는 계속 표시"
@@ -4613,10 +4531,10 @@
"issueLinkLabel": "이슈 링크",
"reviewLinkLabel": "{{value0}} 링크",
"cliHeader": "Orca CLI",
"cliCreatedFromAgent": "Agent가 `orca worktree create`로 생성",
"cliCreatedFromShell": "`orca worktree create`로 생성",
"cliStartupAgent": "{{value0}}(으)로 시작됨",
"cliCreated": "Orca CLI로 생성됨",
"cliCreatedFromAgent": "Created by an agent via `orca worktree create`",
"cliCreatedFromShell": "Created via `orca worktree create`",
"cliStartupAgent": "Started with {{value0}}",
"cliCreated": "Created by Orca CLI",
"jiraIssue": "Jira {{value0}}",
"viewOnJira": "Jira에서 보기",
"linkedJira": "연결된 Jira {{value0}}"
@@ -6154,9 +6072,10 @@
"compareBaseBranchUpstream": "브랜치 업스트림"
},
"HiddenExperimentalGroup": {
"7c4e18d2f6": "이 컴퓨터에서 굵게 표시 결함 캡처 제스처를 활성화합니다. {{samplingShortcut}}하면 샘플링 버스트가 시작되고, {{captureShortcut}}하면 창 증거를 디스크에 캡처합니다.",
"b09f24a51d": "터미널 렌더링 진단",
"f2c81d904a": "숨겨진 실험 설정"
"d0f914a528": "자리 표시자 토글",
"1014ddbfaf": "오늘은 아무것도 하지 않습니다. 숨겨진 실험 옵션을 위한 첫 번째 슬롯으로 예약되어 있습니다.",
"232cf83de8": "내부 테스트를 위한 비공개 토글입니다. 여기에는 아무것도 지원되지 않습니다.",
"3e9e827ca5": "숨겨진 실험"
},
"InputPane": {
"db15068196": "Linux 및 macOS에서는 기본적으로 활성화되어 있습니다. Linux는 시스템 선택 클립보드를 사용합니다. 다른 플랫폼은 개인 버퍼를 사용합니다.",
@@ -9874,7 +9793,8 @@
"defaultTitle": "기본 에이전트",
"nullDefaultAttention": "기본 에이전트를 선택하세요. 선택하기 전에는 대화형 실행을 시작할 수 없으며 에이전트 선택을 요청받습니다.",
"migrationBlockedTitle": "에이전트 설정이 일시적으로 읽기 전용입니다",
"migrationBlockedDescription": "Orca가 기존 에이전트 설정을 업데이트하기 전에 백업할 수 없었기 때문에 데이터를 보호하기 위해 변경이 차단되었습니다. 아래 문제를 해결한 후 다시 시도하세요."
"migrationBlockedDescription": "Orca가 기존 에이전트 설정을 업데이트하기 전에 백업할 수 없었기 때문에 데이터를 보호하기 위해 변경이 차단되었습니다. 아래 문제를 해결한 후 다시 시도하세요.",
"unavailable": "Custom agents are managed on the desktop host."
},
"AgentCatalogStatusBadge": {
"enabled": "활성화됨",
@@ -10923,8 +10843,6 @@
"7a09d7f9d2": "베이스",
"383cf92c73": "트리",
"d7ae61269b": "브랜치에 Commit 됨",
"branchFilesChangedVsBaseOne": "{{ref}} 대비 파일 1개 변경됨",
"branchFilesChangedVsBaseOther": "{{ref}} 대비 파일 {{count}}개 변경됨",
"48a003c1b1": "스테이징된 변경 사항",
"d4ef4bafc5": "변경 사항",
"522f44dce5": "추적되지 않은 파일",
@@ -10999,10 +10917,10 @@
"b3c8f1a902": "파일 이름으로 필터링",
"d4f8c2a901": "필터 지우고 닫기",
"e8a1c4b203": "vs",
"compareBaseCommitsAheadOne": "{{ref}}보다 1 commit 앞서 있음",
"compareBaseCommitsAheadOther": "{{ref}}보다 {{count}} commits 앞서 있음",
"compareBaseCommitsBehindOne": "{{ref}}보다 1 commit 뒤처짐",
"compareBaseCommitsBehindOther": "{{ref}}보다 {{count}} commits 뒤처짐",
"f9b2441bb6": "{{value0}}보다 1 commit 앞서 있음",
"b715ef615b": "{{value1}}보다 {{value0}} commits 앞서 있음",
"c1a8f3e204": "{{value0}}보다 1 commit 뒤처짐",
"d2b9g4f315": "{{value1}}보다 {{value0}} commits 뒤처짐",
"4b4a7de138": "브라우저에서 검토 페이지 열기",
"createPrIntentCommitBlockedSummary": "커밋이 차단됨: {{value0}} 이슈를 수정한 후 PR 만들기를 다시 시도하세요.",
"pushRecovery": {
@@ -11025,6 +10943,7 @@
"a4e93c21d7": "현재 브랜치: {{value0}}",
"c7d4e2f801": "베이스 ref 변경: {{value0}}",
"f3a1b8c204": "upstream",
"createPrIntentEmptyGeneratedBody": "생성된 검토 세부 정보에 설명이 포함되지 않았습니다. PR 생성을 다시 시도하세요.",
"createPrIntentGenerateDetailsFailed": "검토 세부 정보를 생성할 수 없습니다. PR 생성을 다시 시도하세요."
},
"SourceControlAgentActionDialog": {
@@ -12647,30 +12566,6 @@
},
"feature": {
"wall": {
"setup": {
"checklist": {
"localized": {
"copy": {
"ec0a363633": "동시 작업",
"62bac8f43c": "동시에 2개의 서로 다른 워크트리에서 작업하세요. 같은 프로젝트 내에서도 각각 완벽히 격리되어 있어 2개의 기능을 동시에 작업하기에 적합합니다.",
"908898c3ee": "Orca 브라우저 사용",
"43781563c3": "Orca를 벗어나지 않고 웹 앱을 탐색하세요. 원하는 요소를 선택하고 클릭 한 번으로 정확한 소스와 스타일을 에이전트에 전달할 수 있습니다.",
"29aa2c2077": "알림 켜기",
"71bd9a8c95": "에이전트가 작업을 완료했거나, 확인이 필요하거나, 차단된 순간을 즉시 알 수 있습니다.",
"46db810da8": "기본 에이전트 선택",
"b8e5bae17f": "선호하는 에이전트를 미리 선택하여 새로운 작업을 더 빠르게 시작하세요.",
"fee5557b02": "Orca CLI 활성화",
"7bcb4097fa": "Orca 셸 명령을 등록하고 브라우저, 컴퓨터 및 오케스트레이션 워크플로를 위한 에이전트 스킬을 설치하세요.",
"ad342dd4c6": "서비스 연동",
"06fe30fdb0": "클릭 한 번으로 작업에서 에이전트를 시작하고 PR 상태를 한눈에 확인하세요.",
"eddc532e58": "워크스페이스 설정 자동화",
"56049b74c2": "설치 및 설정 명령을 자동으로 실행하여 모든 새 워크트리가 에이전트 작업을 바로 수행할 수 있도록 준비합니다.",
"2cf795433b": "여러 저장소에서 작업 시작",
"42525ba8a4": "주요 저장소를 Orca로 가져와 폴더를 일일이 찾을 필요 없이 에이전트 작업을 시작하세요."
}
}
}
},
"usage": {
"tracking": {
"b94ec70eda": "추적이 설정되지 않았습니다.",
@@ -13035,9 +12930,8 @@
"7e7ca60816": "변경된 파일",
"b6c3b84476": "파일 트리 표시",
"39f8007549": "충돌 리뷰",
"skippedConflictsExcluded": "미해결 충돌 {{count}}개가 이 차이점 보기에서 제외되었습니다.",
"skippedConflictsExcluded_one": "미해결 충돌 {{count}}개가 이 차이점 보기에서 제외되었습니다.",
"skippedConflictsExcluded_other": "미해결 충돌 {{count}}개가 이 차이점 보기에서 제외되었습니다.",
"39e73e7181": "이 차이점 보기에서 제외되었습니다.",
"689b99f8ad": "미해결 충돌",
"820ec01f24": "충돌하는 파일은 별도로 리뷰됩니다.",
"fd8892b120": "표시할 변경사항이 없습니다.",
"eb5f40e49c": "일반적인 양방향 diff 파이프라인은 충돌로부터 안전하지 않기 때문에 이 diff 보기는 해결되지 않은 충돌을 제외합니다.",
@@ -14010,7 +13904,6 @@
"showMore": "더 보기"
},
"AutomationDetail": {
"host": "호스트",
"007c8ad874": "프롬프트",
"a1d52c2189": "사용 범위",
"449fc83bf7": "토큰",
@@ -14174,9 +14067,6 @@
"b11170a008": "자동화를 저장하지 못했습니다.",
"2a20596d6b": "자동화가 저장되었습니다.",
"244727e655": "자동화가 업데이트되었습니다.",
"moved": "자동화를 {host}(으)로 이동했습니다.",
"moveOriginalKept": "{host}에 생성했지만 원본을 삭제하지 못했습니다. 이전 호스트에서 삭제하세요.",
"moveOriginalUnverified": "{host}에 생성했지만 원본 삭제를 확인할 수 없습니다. 다시 시도하기 전에 이전 호스트를 확인하세요.",
"77b81bc4ac": "Hermes 자동화가 생성되었습니다.",
"08efc3ae12": "Hermes 자동화가 업데이트되었습니다.",
"e431bb85d4": "이 Hermes 자동화와 동일한 호스트에서 워크스페이스를 선택하세요.",
@@ -14220,7 +14110,6 @@
"backToList": "모든 자동화",
"tableName": "이름",
"tableProject": "프로젝트",
"tableHost": "호스트",
"tableStatus": "상태",
"tableActions": "작업",
"newAutomation": "새 자동화",
@@ -14373,19 +14262,6 @@
"filters": "필터",
"clear": "필터 지우기",
"removeFilter": "{{value0}} 필터 제거"
},
"createDestination": {
"label": "호스트",
"placeholder": "호스트 선택",
"storedOn": "{authority}에서 저장하고 예약합니다.",
"unselected": "이 자동화를 저장하고 예약할 호스트를 선택하세요.",
"orphan": "호스트가 없는 자동화는 새 자동화를 보유할 수 없습니다. 이 자동화를 만들 호스트를 선택하세요.",
"unavailable": "해당 호스트는 아직 새 자동화를 보유할 수 없습니다. 이 자동화를 만들 다른 호스트를 선택하세요.",
"stale": "이 양식이 열려 있는 동안 {host}이(가) 변경되었습니다. 저장하기 전에 다시 선택하세요.",
"projectMismatch": "해당 프로젝트는 {host}에 없습니다. 그 호스트의 프로젝트를 선택하거나 다른 호스트를 선택하세요.",
"noProjects": "{host}에 설정된 프로젝트가 없습니다. 거기에 추가하거나 다른 호스트를 선택하세요.",
"updateRequired": "{hosts}의 Orca 서버를 업데이트하면 거기에 자동화를 저장할 수 있습니다.",
"move": "저장하면 이 자동화가 {host}에 생성되고 원본과 실행 기록은 삭제됩니다."
}
},
"agent": {
@@ -14740,15 +14616,6 @@
"delete": "삭제"
}
},
<<<<<<< HEAD
"ComposerParentWorktreePicker": {
"label": "상위 워크트리",
"noParent": "상위 워크트리 없음",
"description": "이 워크스페이스를 사이드바의 다른 워크스페이스 아래에 중첩합니다. 기준 브랜치는 변경되지 않습니다.",
"searchPlaceholder": "워크스페이스 검색...",
"noMatches": "일치하는 항목이 없습니다."
||||||| parent of ebaa81ab2f (Rebase custom-agents onto main (2/4): renderer)
=======
"AgentLaunchNotices": {
"disabledFallback": "{{value0}}이(가) 비활성화되어 있습니다. 사용자 지정 실행 파일, 사용자 지정 인수 또는 사용자 지정 에이전트 환경 없이 기본 {{value1}}을(를) 시작했습니다.",
"missingFallback": "{{value0}}이(가) 삭제되었습니다. 사용자 지정 실행 파일, 사용자 지정 인수 또는 사용자 지정 에이전트 환경 없이 기본 {{value1}}을(를) 시작했습니다.",
@@ -14771,7 +14638,31 @@
"openTerminal": "터미널 열기",
"forgetLaunch": "실행 지우기…",
"manageAgents": "에이전트 관리"
>>>>>>> ebaa81ab2f (Rebase custom-agents onto main (2/4): renderer)
},
"dataRecovery": {
"pointAgentCatalogPreV1Title": "Before the custom-agents update (agent catalog v1)",
"pointAgentCatalogPreV1Loss": "Restoring discards all settings and workspace metadata saved after this point, including any custom agents.",
"title": "Data recovery",
"description": "Restore this profile from a recovery point created before a data migration. Restoring never deletes the recovery point.",
"loading": "Looking for recovery points…",
"empty": "No recovery points exist for this profile.",
"createdAt": "Created {{date}}",
"createdUnknown": "Creation time unknown",
"previousBinary": "After restoring, Orca quits. Install the previous Orca version before opening it again.",
"restoring": "Restoring…",
"confirmRestore": "Restore and quit",
"cancel": "Cancel",
"prepareDowngrade": "Prepare downgrade…",
"restoreFailed": "Restore failed and no changes were made: {{error}}",
"close": "Close",
"noticeTitle": "Orca could not back up your data before a required update",
"noticeBody": "Your existing data is unchanged and built-in agents keep working, but custom-agent settings are read-only until this is resolved.",
"retrying": "Retrying…",
"retryMigration": "Retry migration",
"openDataRecovery": "Open Data recovery",
"copyDetails": "Copy details",
"settingsRowHint": "A pre-update recovery point exists for this profile.",
"settingsRowOpen": "Data recovery…"
}
},
"i18n": {
@@ -14968,16 +14859,29 @@
},
"workspace": {
"cleanup": {
"browse": {
"selectionWithheldOne": "선택한 워크스페이스 1개가 현재 필터에 의해 숨겨져 선택이 해제되었습니다.",
"selectionWithheld": "선택한 워크스페이스 {{value0}}개가 현재 필터에 의해 숨겨져 선택이 해제되었습니다.",
"selectAllCountOne": "안전 검사를 통과한 워크스페이스 1개 선택",
"selectAllCount": "안전 검사를 통과한 워크스페이스 {{value0}}개 모두 선택"
},
"scan": {
"readyOne": "워크스페이스 1개를 찾았습니다.",
"readyMany": "워크스페이스 {{value0}}개를 찾았습니다."
},
"presentationFixtures": {
"reviewAlphaCleanup": "알파 정리 검토"
},
"presentation": {
"gitlabMergeRequestNumber": "MR #{{value0}}",
"githubPullRequestNumber": "PR #{{value0}}"
},
"browse": {
"gitStatusCheckFailed": "Git 상태 확인 실패",
"gitStatusUnverified": "Git 상태를 확인할 수 없음",
"deleteAnyway": "그래도 삭제",
"forceDeleteProjectionOne": "현재 워크스페이스 {{count}}개에 위험 요소가 표시되며 강제 삭제가 필요할 수 있습니다",
"forceDeleteProjectionMany": "현재 워크스페이스 {{count}}개에 위험 요소가 표시되며 강제 삭제가 필요할 수 있습니다",
"selectionWithheldOne": "선택한 워크스페이스 1개가 현재 필터에 의해 숨겨져 선택이 해제되었습니다.",
"selectionWithheld": "선택한 워크스페이스 {{value0}}개가 현재 필터에 의해 숨겨져 선택이 해제되었습니다.",
"selectAllCountOne": "안전 검사를 통과한 워크스페이스 1개 선택",
"selectAllCount": "안전 검사를 통과한 워크스페이스 {{value0}}개 모두 선택",
"appliedFilters": "적용된 필터",
"removeFilter": "{{value0}} 필터 제거",
"chip": {
@@ -15024,17 +14928,6 @@
"retainedAgents": "완료된 에이전트"
}
}
},
"scan": {
"readyOne": "워크스페이스 1개를 찾았습니다.",
"readyMany": "워크스페이스 {{value0}}개를 찾았습니다."
},
"presentationFixtures": {
"reviewAlphaCleanup": "알파 정리 검토"
},
"presentation": {
"gitlabMergeRequestNumber": "MR #{{value0}}",
"githubPullRequestNumber": "PR #{{value0}}"
}
}
},
@@ -15088,24 +14981,24 @@
"agentCount": "에이전트 {{count}}개"
},
"placeholder": {
"title": "에이전트 대시보드",
"description": "모든 에이전트를 한눈에 볼 수 있는 곳입니다. 보드는 곧 제공됩니다."
"title": "Agent dashboard",
"description": "This is where all your agents will show up at a glance. The board is coming soon."
},
"recoverableError": {
"title": "Orca 대시보드에서 오류가 발생했습니다.",
"description": "대시보드 렌더링을 완료하지 못했습니다. 다시 시도해 다시 마운트하거나 다시 열어 보세요."
"title": "Orca dashboard hit an error.",
"description": "The dashboard could not finish rendering. Retry to remount it, or reopen it."
},
"bucket": {
"attention": "확인 필요",
"working": "작업 중",
"idle": "유휴",
"empty": "없음",
"attention": "Needs You",
"working": "Working",
"idle": "Idle",
"empty": "None",
"done": "완료"
},
"title": "에이전트",
"total": "총 {{count}}개",
"title": "Agents",
"total": "{{count}} total",
"card": {
"you": "나",
"you": "You",
"time": {
"justNow": "방금",
"minutes": "{{count}}분",
@@ -15122,11 +15015,11 @@
"subagents_other": "서브에이전트 {{count}}개"
},
"terminal": {
"closed": "실시간 터미널이 없습니다 — 이 에이전트의 창이 닫혔습니다.",
"focusWorktree": "워크트리 열기",
"close": "닫기"
"closed": "No live terminal — this agent's pane has closed.",
"focusWorktree": "Open worktree",
"close": "Close"
},
"close": "대시보드 닫기",
"close": "Close dashboard",
"settingsTooltip": "보드 설정",
"filters": {
"remove": "{{label}} 필터 제거",
+72 -188
View File
@@ -185,6 +185,10 @@
"07f788de83": "WebSocket"
}
}
},
"worktreeCreate": {
"hostPredatesDefaultLaunch": "This Orca host predates default-agent launch. Pick a specific built-in agent or update Orca on the host.",
"hostPredatesCustomAgents": "This Orca host predates custom agents. Pick a built-in agent or update Orca on the host."
}
},
"store": {
@@ -266,10 +270,7 @@
"3b57982bf6": "删除工作区\"{{value1}}\"后,Git 无法安全删除分支\"{{value0}}\",因此 Orca 将其保留以避免丢失本地提交。",
"81f13f48d2": "删除工作树\"{{value1}}\"后,Git 无法安全删除分支\"{{value0}}\",因此 Orca 将其保留以避免丢失本地提交。",
"runtimeScopeForbiddenTitle": "此连接具有受限(移动端)访问权限",
"runtimeScopeForbiddenDescription": "移动端范围的配对无法使用工作区。请通过设置 → 远程 Orca 服务器 → 分享此 Orca 服务器中的浏览器访问链接重新连接。",
"createdWithoutParentNesting": "已创建,但未嵌套在“{{value0}}”下",
"createdWithoutParentNestingUnnamed": "已创建,但未嵌套在所选父工作树下",
"createdWithoutParentNestingDetail": "父工作树已不可用。您可以从工作区菜单中重新设置。"
"runtimeScopeForbiddenDescription": "移动端范围的配对无法使用工作区。请通过设置 → 远程 Orca 服务器 → 分享此 Orca 服务器中的浏览器访问链接重新连接。"
},
"repos": {
"2975400634": "请更新 Orca 服务器,以便在此运行时打开非 Git 文件夹。",
@@ -3028,8 +3029,7 @@
"25dc1cd653": "打开文件",
"7cdf8ee0c8": "打开网址",
"b27864279e": "启动智能体",
"omniboxPlaceholderWithHistory": "搜索打开的标签页、历史记录、文件、URL 和智能体…",
"openPage": "打开页面",
"0e5b7a3f16": "搜索打开的标签页、文件、URL 和智能体…",
"8f0a1c4d92": "切换到标签页",
"2c38630a01": "工作区已不存在",
"4f0d9a71c2": "标签页已不存在",
@@ -3075,6 +3075,7 @@
"classifier": {
"42e6262ae9": "没有可用的操作。",
"097a982ee0": "正在加载文件...",
"c41f8d20b7": "搜索打开的标签页、文件、URL 和智能体…",
"90eb94dc48": "输入 http:// 或 https:// URL。",
"5553b283ce": "输入 URL 或文件路径。",
"queryTooLarge": "搜索文本过长。",
@@ -3520,8 +3521,7 @@
"resource": {
"memory": {
"metric": {
"workingSetDescription": "工作集 (WS) 的总和:当前驻留在 RAM 中的页。共享页可能会出现在多个进程中,被 Windows 换出到页面文件的内存不计入其中。",
"privateBytesDescription": "专用字节的总和:这些进程已提交的内存,无论驻留还是已换出都会计入。这是主机计入提交限制的数值,因此在分页导致上方工作集缩小时它仍会继续上升。",
"workingSetDescription": "工作集 (WS) 的总和。共享页可能会出现在多个进程中。",
"rssDescription": "驻留集大小 (RSS) 的总和。共享页或别名映射页可能会出现在多个进程中。"
}
},
@@ -3892,92 +3892,6 @@
}
},
"skills": {
"SkillsPage": {
"cb142070b4": "刷新",
"a68dee6a32": "搜索技能",
"f43ad6edf3": "技能",
"ea72d6185b": "无法扫描技能",
"dc4c3328ee": "显示文件",
"9963dff6d3": "未找到说明。",
"995fde8337": "无法显示技能文件",
"4acd6d68ec": "未找到技能",
"6a62a0168c": "无匹配项",
"08a321a984": "没有技能匹配当前搜索和筛选条件。",
"cd7893fbc1": "正在扫描技能",
"35b9a724a0": "可用",
"c13b82793c": "管理安装",
"aee7b99cc6": "从链接安装",
"filterProvider": "按 Agent 筛选",
"filterSource": "按来源筛选",
"allSources": "全部",
"clearFilters": "清除筛选",
"closeSkills": "关闭技能",
"closeTooltip": "关闭 · Esc",
"moreActions": "更多操作",
"sharedLinks": "共享链接",
"emptyCopy": "扫描的技能文件夹为空。请安装共享包,或添加技能后刷新。",
"retry": "重试",
"remoteShareNotice": "这些技能位于 {{host}}。请在该机器上打开“技能”以进行共享。",
"viewSwitch": "显示",
"searchLinks": "搜索链接",
"deleteSkills": "删除技能…"
},
"SkillShareSelectionControls": { "01c5a15e02": "共享技能" },
"SkillRow": {
"updatedUnknown": "无日期",
"pathCopied": "路径已复制",
"copyPath": "复制路径",
"detailPath": "路径",
"skillActions": "{{value0}} 的操作",
"viewDetails": "查看详情",
"deleteSkill": "删除…"
},
"SkillsList": { "listLabel": "技能" },
"sourceStatus": {
"missing": "未找到文件夹",
"remoteRepo": "远程仓库 — 未扫描",
"unavailable": "未扫描"
},
"sources": { "heading": "技能文件夹" },
"sourceKind": {
"home": "主目录",
"workspace": "工作区",
"bundled": "内置",
"plugin": "插件"
},
"count": {
"skillOne": "{{count}} 个技能",
"skillOther": "{{count}} 个技能",
"sourceOne": "{{count}} 个来源",
"sourceOther": "{{count}} 个来源",
"fileOne": "{{count}} 个文件",
"fileOther": "{{count}} 个文件",
"resultOne": "{{count}} 个结果",
"resultOther": "{{count}} 个结果",
"selected": "已选择 {{count}} 个",
"shareOne": "共享 {{count}} 个技能",
"shareOther": "共享 {{count}} 个技能",
"linkOne": "{{count}} 个链接",
"linkOther": "{{count}} 个链接",
"deleteOne": "删除 {{count}} 个技能",
"deleteOther": "删除 {{count}} 个技能",
"deletedOne": "已删除 {{count}} 个技能",
"deletedOther": "已删除 {{count}} 个技能",
"deleteFolderOne": "{{count}} 个文件夹",
"deleteFolderOther": "{{count}} 个文件夹",
"deleteLinkOne": "{{count}} 个链接",
"deleteLinkOther": "{{count}} 个链接"
},
"filter": { "allAgents": "所有 Agent", "sharedAgent": "共享 (.agents)" },
"SkillsSelectionHeader": {
"exit": "退出选择",
"exitTooltip": "退出选择 · Esc",
"title": "选择要共享的技能",
"selectAll": "选择全部 {{count}} 个符合条件的项目",
"clear": "清除",
"deleteTitle": "选择要删除的技能"
},
"SkillDetailDialog": { "agents": "Agent", "updated": "已更新", "copy": "复制" },
"SkillFreshnessNudge": {
"titleOne": "已安装的 Orca 技能已过期",
"titleMany": "{{value0}} 个已安装的 Orca 技能已过期",
@@ -4074,35 +3988,6 @@
"SkillUpdateRow": {
"oneLocation": "1 location",
"manyLocations": "{{value0}} locations"
},
"host": {
"local": "此机器",
"remote": "已连接的运行时"
},
"SkillDelete": {
"dismissResults": "关闭",
"reasonBundled": "Orca 内置技能 — 删除后会恢复",
"reasonPlugin": "由插件安装 — 请改为移除插件",
"reasonUnowned": "此技能位于 Orca 技能文件夹之外 — 请在其存储位置删除",
"reasonMissing": "此技能已不在磁盘上",
"reasonStale": "此技能在列表加载后发生了变化 — 请刷新后重试",
"placementSummaryParts": "将在 {{roots}} 中删除 {{parts}}。",
"placementJoin": " 和 ",
"retainedSource": "技能本体仍保留在 {{path}}。",
"blockedLine": "{{count}} × {{reason}}",
"resultLine": "{{count}} × {{label}}",
"statusSkipped": "已跳过",
"statusBusy": "忙碌 — 另一项技能操作正在进行",
"statusPartial": "部分移除 — 某些文件仍以隐藏名称保留在磁盘上",
"statusFailed": "失败 — 未进行任何更改",
"statusDeleted": "已删除",
"confirmHost": "位于 {{host}}。",
"confirmPermanent": "此操作无法撤销。",
"failed": "无法删除技能",
"hostUnresolved": "仍在查找存储这些技能的计算机。",
"hostUpdateRequired": "请更新所选机器上的 Orca 以删除技能。",
"nothingOne": "无法从这里删除该技能。",
"nothingOther": "所选的 {{count}} 个技能均无法从这里删除。"
}
},
"sidebar": {
@@ -6020,7 +5905,7 @@
"80c454e8a6": "将此与您的提供商的缓存 TTL 相匹配。"
},
"GeneralEditorSettingsSection": {
"f80603d293": "在富文本编辑器模式和智能体切换操作中显示本地 Markdown 注释控件。",
"f80603d293": "在丰富的编辑器模式和智能体切换操作中显示本地 Markdown 注释控件。",
"4edc104f0f": "Markdown 评审笔记",
"5f02e6fb21": "在富文本编辑器模式下显示本地 Markdown 评审笔记控件。",
"51161d1647": "编辑文件时显示小地图概述。",
@@ -6197,9 +6082,10 @@
"compareBaseBranchUpstream": "分支上游"
},
"HiddenExperimentalGroup": {
"7c4e18d2f6": "在本机启用粗体故障捕获手势:{{samplingShortcut}} 开始采样突发;{{captureShortcut}} 将窗格证据捕获到磁盘。",
"b09f24a51d": "终端渲染诊断",
"f2c81d904a": "隐藏实验设置"
"d0f914a528": "占位符切换",
"1014ddbfaf": "今天什么也不做。保留作为隐藏实验选项的第一个插槽。",
"232cf83de8": "用于内部测试的未列出的切换。这里什么都不支持。",
"3e9e827ca5": "隐藏实验"
},
"InputPane": {
"db15068196": "在 Linux 和 macOS 上默认启用。 Linux使用系统选择剪贴板;其他平台使用私有缓冲区。",
@@ -9917,7 +9803,8 @@
"defaultTitle": "默认智能体",
"nullDefaultAttention": "请选择一个默认智能体。在此之前,需人工干预的启动将无法开始,并会要求你选择一个。",
"migrationBlockedTitle": "智能体设置暂时为只读状态",
"migrationBlockedDescription": "Orca 在更新前无法备份你现有的智能体设置,因此变更已被阻止以保护你的数据。请先解决下面的问题,然后重试。"
"migrationBlockedDescription": "Orca 在更新前无法备份你现有的智能体设置,因此变更已被阻止以保护你的数据。请先解决下面的问题,然后重试。",
"unavailable": "Custom agents are managed on the desktop host."
},
"AgentCatalogStatusBadge": {
"enabled": "已启用",
@@ -10958,8 +10845,6 @@
"7a09d7f9d2": "根据",
"383cf92c73": "树",
"d7ae61269b": "已提交的更改",
"branchFilesChangedVsBaseOne": "与 {{ref}} 相比更改了 1 个文件",
"branchFilesChangedVsBaseOther": "与 {{ref}} 相比更改了 {{count}} 个文件",
"48a003c1b1": "已暂存的更改",
"d4ef4bafc5": "更改",
"522f44dce5": "未跟踪文件",
@@ -11034,10 +10919,10 @@
"b3c8f1a902": "按文件名筛选",
"d4f8c2a901": "清除并关闭筛选器",
"e8a1c4b203": "vs",
"compareBaseCommitsAheadOne": "领先 {{ref}} 1 个提交",
"compareBaseCommitsAheadOther": "领先 {{ref}} {{count}} 个提交",
"compareBaseCommitsBehindOne": "落后 {{ref}} 1 个提交",
"compareBaseCommitsBehindOther": "落后 {{ref}} {{count}} 个提交",
"f9b2441bb6": "领先 {{value0}} 1 个提交",
"b715ef615b": "领先 {{value1}} {{value0}} 个提交",
"c1a8f3e204": "落后 {{value0}} 1 个提交",
"d2b9g4f315": "落后 {{value1}} {{value0}} 个提交",
"4b4a7de138": "在浏览器中打开审查页面",
"createPrIntentCommitBlockedSummary": "提交被阻止:{{value0}} 修复问题后重试创建 PR。",
"pushRecovery": {
@@ -11060,6 +10945,7 @@
"a4e93c21d7": "当前分支:{{value0}}",
"c7d4e2f801": "更改基引用:{{value0}}",
"f3a1b8c204": "upstream",
"createPrIntentEmptyGeneratedBody": "生成的评审详情未包含描述。请重试创建 PR。",
"createPrIntentGenerateDetailsFailed": "无法生成评审详情。请重试创建 PR。"
},
"SourceControlAgentActionDialog": {
@@ -13046,9 +12932,8 @@
"7e7ca60816": "更改的文件",
"b6c3b84476": "显示文件树",
"39f8007549": "评审冲突",
"skippedConflictsExcluded": "{{count}} 个未解决的冲突已被排除在此差异视图之外。",
"skippedConflictsExcluded_one": "{{count}} 个未解决的冲突已被排除在此差异视图之外。",
"skippedConflictsExcluded_other": "{{count}} 个未解决的冲突已被排除在此差异视图之外。",
"39e73e7181": "被排除在此差异视图之外。",
"689b99f8ad": "未解决的冲突",
"820ec01f24": "冲突文件单独评审",
"fd8892b120": "没有显示任何变化",
"eb5f40e49c": "此差异视图排除了未解决的冲突,因为普通的双向差异管道不是冲突安全的。",
@@ -13116,9 +13001,9 @@
"8915726e93": "复制路径"
},
"EditorContent": {
"56dba34e1a": "(在源码模式下编辑)",
"56dba34e1a": "(在源模式下编辑)",
"e4b074749d": "前言",
"9640d1d3db": "预览此差异的修改版本。切换到源码模式以检查更改。",
"9640d1d3db": "预览此差异的修改版本。切换到源模式以检查更改。",
"78541e254e": "二进制文件已更改",
"c88c73a0d3": "加载差异...",
"b9de81ba52": "二进制文件——无法显示",
@@ -13142,12 +13027,12 @@
"84cdc0794b": "重命名",
"1bb1e226ec": "重命名文件 {{value0}}",
"5447c4f68f": "目录",
"146cb5473c": "目录可在富文本模式或预览模式下使用",
"146cb5473c": "目录可在丰富模式或预览模式下使用",
"e836faacfa": "切换到并排差异",
"94756f08ba": "切换到内联差异",
"c98ce191da": "此差异没有可打开的修改端文件",
"9b80bbe1de": "打开文件选项卡",
"f0fd4174b5": "打开文件选项卡以使用富文本 Markdown 编辑",
"f0fd4174b5": "打开文件选项卡以使用丰富的 Markdown 编辑",
"a10d9b8337": "打开文件",
"2076ecfc9c": "上一个更改",
"631dab0df3": "下一个更改"
@@ -13169,8 +13054,8 @@
"4837f3f578": "更改",
"ac3bb87913": "编辑",
"0d193dc03c": "预览",
"aff15f94f5": "富文本编辑器",
"4d6ccb7ba6": "源码"
"aff15f94f5": "丰富的编辑器",
"4d6ccb7ba6": "来源"
},
"ImageDiffViewer": {
"a651be62b0": "修改的",
@@ -13342,8 +13227,8 @@
},
"RichMarkdownErrorBoundary": {
"aad0998127": "重试",
"4a5de9f2f0": "切换到源码模式,或单击“重试”以重新加载富文本视图。",
"dfdf1cacd4": "富文本 Markdown 编辑器遇到了意外错误,并被重置以保持 Orca 的其余部分响应。"
"4a5de9f2f0": "切换到源模式,或单击“重试”以重新加载丰富视图。",
"dfdf1cacd4": "丰富的 Markdown 编辑器遇到了意外错误,并被重置以保持 Orca 的其余部分响应。"
},
"RichMarkdownLinkBubble": {
"1c99b726e0": "删除链接",
@@ -14010,7 +13895,6 @@
"showMore": "显示更多"
},
"AutomationDetail": {
"host": "主机",
"007c8ad874": "提示词",
"a1d52c2189": "使用范围",
"449fc83bf7": "token",
@@ -14174,9 +14058,6 @@
"b11170a008": "无法保存自动化。",
"2a20596d6b": "自动化已保存。",
"244727e655": "自动化已更新。",
"moved": "自动化已移动到 {host}。",
"moveOriginalKept": "已在 {host} 上创建,但无法删除原有自动化。请在原主机上删除它。",
"moveOriginalUnverified": "已在 {host} 上创建,但无法确认是否删除了原有自动化。重试前请检查原主机。",
"77b81bc4ac": "Hermes 自动化创建。",
"08efc3ae12": "Hermes 自动化已更新。",
"e431bb85d4": "选择与此 Hermes 自动化位于同一主机上的工作区。",
@@ -14220,7 +14101,6 @@
"backToList": "全部自动化",
"tableName": "名称",
"tableProject": "项目",
"tableHost": "主机",
"tableStatus": "状态",
"tableActions": "操作",
"newAutomation": "新建自动化",
@@ -14373,19 +14253,6 @@
"filters": "筛选器",
"clear": "清除筛选器",
"removeFilter": "移除 {{value0}} 筛选器"
},
"createDestination": {
"label": "主机",
"placeholder": "选择主机",
"storedOn": "由 {authority} 存储和调度。",
"unselected": "选择存储和调度此自动化的主机。",
"orphan": "没有主机的自动化无法容纳新的自动化。请选择要在其上创建此自动化的主机。",
"unavailable": "该主机尚无法容纳新的自动化。请选择另一台主机来创建它。",
"stale": "此表单打开期间 {host} 已更改。保存前请重新选择。",
"projectMismatch": "该项目不在 {host} 上。请选择该主机上的项目,或选择另一台主机。",
"noProjects": "{host} 上未设置任何项目。请在该主机上添加一个,或选择另一台主机。",
"updateRequired": "更新 {hosts} 上的 Orca 服务器即可在其中存储自动化。",
"move": "保存后会在 {host} 上创建此自动化,并删除原有自动化及其运行历史。"
}
},
"agent": {
@@ -14740,15 +14607,6 @@
"delete": "删除"
}
},
<<<<<<< HEAD
"ComposerParentWorktreePicker": {
"label": "父工作树",
"noParent": "无父工作树",
"description": "在侧边栏中将此工作区嵌套到另一个工作树下。不会更改基础分支。",
"searchPlaceholder": "搜索工作树...",
"noMatches": "无匹配项。"
||||||| parent of ebaa81ab2f (Rebase custom-agents onto main (2/4): renderer)
=======
"AgentLaunchNotices": {
"disabledFallback": "{{value0}} 已禁用。已启动标准 {{value1}},未使用自定义可执行文件、自定义参数或自定义代理环境。",
"missingFallback": "{{value0}} 已删除。已启动标准 {{value1}},未使用自定义可执行文件、自定义参数或自定义代理环境。",
@@ -14771,7 +14629,31 @@
"openTerminal": "打开终端",
"forgetLaunch": "忘记启动…",
"manageAgents": "管理代理"
>>>>>>> ebaa81ab2f (Rebase custom-agents onto main (2/4): renderer)
},
"dataRecovery": {
"pointAgentCatalogPreV1Title": "Before the custom-agents update (agent catalog v1)",
"pointAgentCatalogPreV1Loss": "Restoring discards all settings and workspace metadata saved after this point, including any custom agents.",
"title": "Data recovery",
"description": "Restore this profile from a recovery point created before a data migration. Restoring never deletes the recovery point.",
"loading": "Looking for recovery points…",
"empty": "No recovery points exist for this profile.",
"createdAt": "Created {{date}}",
"createdUnknown": "Creation time unknown",
"previousBinary": "After restoring, Orca quits. Install the previous Orca version before opening it again.",
"restoring": "Restoring…",
"confirmRestore": "Restore and quit",
"cancel": "Cancel",
"prepareDowngrade": "Prepare downgrade…",
"restoreFailed": "Restore failed and no changes were made: {{error}}",
"close": "Close",
"noticeTitle": "Orca could not back up your data before a required update",
"noticeBody": "Your existing data is unchanged and built-in agents keep working, but custom-agent settings are read-only until this is resolved.",
"retrying": "Retrying…",
"retryMigration": "Retry migration",
"openDataRecovery": "Open Data recovery",
"copyDetails": "Copy details",
"settingsRowHint": "A pre-update recovery point exists for this profile.",
"settingsRowOpen": "Data recovery…"
}
},
"i18n": {
@@ -14968,16 +14850,29 @@
},
"workspace": {
"cleanup": {
"browse": {
"selectionWithheldOne": "当前筛选条件隐藏了 1 个已选工作区,已取消选择。",
"selectionWithheld": "当前筛选条件隐藏了 {{value0}} 个已选工作区,已取消选择。",
"selectAllCountOne": "选择 1 个通过安全检查的工作区",
"selectAllCount": "选择全部 {{value0}} 个通过安全检查的工作区"
},
"scan": {
"readyOne": "找到 1 个工作区。",
"readyMany": "找到 {{value0}} 个工作区。"
},
"presentationFixtures": {
"reviewAlphaCleanup": "评审 Alpha 清理"
},
"presentation": {
"gitlabMergeRequestNumber": "MR #{{value0}}",
"githubPullRequestNumber": "PR #{{value0}}"
},
"browse": {
"gitStatusCheckFailed": "Git 状态检查失败",
"gitStatusUnverified": "无法验证 Git 状态",
"deleteAnyway": "仍然删除",
"forceDeleteProjectionOne": "当前有 {{count}} 个工作区显示风险,可能需要强制删除",
"forceDeleteProjectionMany": "当前有 {{count}} 个工作区显示风险,可能需要强制删除",
"selectionWithheldOne": "当前筛选条件隐藏了 1 个已选工作区,已取消选择。",
"selectionWithheld": "当前筛选条件隐藏了 {{value0}} 个已选工作区,已取消选择。",
"selectAllCountOne": "选择 1 个通过安全检查的工作区",
"selectAllCount": "选择全部 {{value0}} 个通过安全检查的工作区",
"appliedFilters": "已应用的筛选",
"removeFilter": "移除筛选 {{value0}}",
"chip": {
@@ -15024,17 +14919,6 @@
"retainedAgents": "已完成的代理"
}
}
},
"scan": {
"readyOne": "找到 1 个工作区。",
"readyMany": "找到 {{value0}} 个工作区。"
},
"presentationFixtures": {
"reviewAlphaCleanup": "评审 Alpha 清理"
},
"presentation": {
"gitlabMergeRequestNumber": "MR #{{value0}}",
"githubPullRequestNumber": "PR #{{value0}}"
}
}
},
@@ -1,5 +1,6 @@
import type { PreloadApi } from '../../../../preload/api-types'
import type {
CreateWorktreeResult,
ForceDeleteWorktreeBranchResult,
RemoveWorktreeResult
} from '../../../../shared/worktree/create-types'
@@ -25,6 +26,28 @@ import type {
WorktreeRetryAgentLaunchResult
} from '../../../../shared/agent-launch-worktree-recovery'
import type { PendingAgentLaunchSummary } from '../../../../shared/agent-launch-pending-summary'
import type { AgentLaunchSpawnRequest } from '../../../../shared/agent-launch-spawn-request'
import { AGENT_LAUNCH_IDENTITY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import {
resolveRemoteWorktreeCreateLaunchParams,
type RemoteWorktreeCreateLaunchParams
} from '../worktree-create-launch-compat'
import { getRemoteRuntimeStatus } from './web-runtime-calls'
async function remoteHostSupportsAgentLaunchIdentity(): Promise<boolean> {
try {
const status = await getRemoteRuntimeStatus()
return status.capabilities?.includes(AGENT_LAUNCH_IDENTITY_RUNTIME_CAPABILITY) === true
} catch {
return false
}
}
function resolveWebWorktreeCreateLaunchParams(
agentLaunch: AgentLaunchSpawnRequest | undefined
): Promise<RemoteWorktreeCreateLaunchParams> {
return resolveRemoteWorktreeCreateLaunchParams(agentLaunch, remoteHostSupportsAgentLaunchIdentity)
}
export function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees']> {
return {
@@ -53,7 +76,9 @@ export function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees
listAll: () => listAllRuntimeWorktrees(),
create: async (args) => {
invalidateRuntimeWorktreeCaches()
const owned = await callRuntimeResultWithOwner<{ worktree: Worktree }>('worktree.create', {
const launchParams = await resolveWebWorktreeCreateLaunchParams(args.agentLaunch)
const dropEmptyStartupCommand = 'startupAgent' in launchParams && args.startup?.command === ''
const owned = await callRuntimeResultWithOwner<CreateWorktreeResult>('worktree.create', {
repo: args.repoId,
name: args.name,
// Absent means user-typed, which is what the host must assume — so send it only when true.
@@ -80,7 +105,7 @@ export function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees
pendingFirstAgentMessageRename: args.pendingFirstAgentMessageRename,
...(args.startup
? {
startupCommand: args.startup.command,
...(dropEmptyStartupCommand ? {} : { startupCommand: args.startup.command }),
...(args.startup.env ? { startupEnv: args.startup.env } : {}),
...(args.startup.launchConfig
? { startupLaunchConfig: args.startup.launchConfig }
@@ -97,8 +122,11 @@ export function createWorktreesApi(): NonNullable<Partial<PreloadApi>['worktrees
workspaceStatus: args.workspaceStatus,
manualOrder: args.manualOrder,
automationProvenanceRequest: args.automationProvenanceRequest,
...(args.agentLaunch ? { agentLaunch: args.agentLaunch } : {})
...launchParams
})
if (!('worktree' in owned.result)) {
return owned.result
}
return {
...owned.result,
worktree: withRuntimeWorktreeOwner(owned.result.worktree, owned.hostId)
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { resolveRemoteWorktreeCreateLaunchParams } from './worktree-create-launch-compat'
import type { AgentLaunchSpawnRequest } from '../../../shared/agent-launch-spawn-request'
const supported = () => Promise.resolve(true)
const unsupported = () => Promise.resolve(false)
function identityLaunch(
selection: AgentLaunchSpawnRequest['selection'],
prompt?: string
): AgentLaunchSpawnRequest {
return { selection, ...(prompt !== undefined ? { prompt } : {}) }
}
describe('resolveRemoteWorktreeCreateLaunchParams', () => {
it('passes no launch through untouched', async () => {
expect(await resolveRemoteWorktreeCreateLaunchParams(undefined, unsupported)).toEqual({})
})
it('sends identity-only agentLaunch to a capable host', async () => {
const launch = identityLaunch({ kind: 'agent', agent: 'claude' }, 'hello')
expect(await resolveRemoteWorktreeCreateLaunchParams(launch, supported)).toEqual({
agentLaunch: launch
})
})
it('falls back to the legacy startupAgent id for a built-in on a pre-identity host', async () => {
const launch = identityLaunch({ kind: 'agent', agent: 'claude' }, 'hello')
expect(await resolveRemoteWorktreeCreateLaunchParams(launch, unsupported)).toEqual({
startupAgent: 'claude',
startupPrompt: 'hello'
})
})
it('fails fast for a stored-default selection on a pre-identity host', async () => {
await expect(
resolveRemoteWorktreeCreateLaunchParams(identityLaunch({ kind: 'default' }), unsupported)
).rejects.toThrow(/predates default-agent launch/)
})
it('fails fast for a custom id on a pre-identity host — never a client-assembled command', async () => {
await expect(
resolveRemoteWorktreeCreateLaunchParams(
identityLaunch({ kind: 'agent', agent: 'custom-agent:claude:abc' }),
unsupported
)
).rejects.toThrow(/predates custom agents/)
})
})
@@ -0,0 +1,45 @@
// Client-side skew contract for worktree.create against a remote host (compat
// design: "old host × new client"). A pre-identity host's schema silently
// strips the unknown `agentLaunch` key (bare-terminal worktree), so the client
// probes `agent-launch.identity.v1` and falls back to the legacy host-resolved
// `startupAgent` id, mirroring the CLI's resolveWorktreeCreateLaunchParams.
import { isBuiltInTuiAgent } from '../../../shared/tui-agent-config'
import { translate } from '@/i18n/i18n'
import type { AgentLaunchSpawnRequest } from '../../../shared/agent-launch-spawn-request'
export type RemoteWorktreeCreateLaunchParams =
| Record<string, never>
| { agentLaunch: AgentLaunchSpawnRequest }
| { startupAgent: string; startupPrompt: string }
export async function resolveRemoteWorktreeCreateLaunchParams(
agentLaunch: AgentLaunchSpawnRequest | undefined,
hostSupportsIdentity: () => Promise<boolean>
): Promise<RemoteWorktreeCreateLaunchParams> {
if (!agentLaunch) {
return {}
}
if (await hostSupportsIdentity()) {
return { agentLaunch }
}
const selection = agentLaunch.selection
// FAIL-FAST: a pre-identity host cannot resolve a stored default or a custom id.
if (selection.kind !== 'agent') {
throw new Error(
translate(
'auto.web.worktreeCreate.hostPredatesDefaultLaunch',
'This Orca host predates default-agent launch. Pick a specific built-in agent or update Orca on the host.'
)
)
}
if (!isBuiltInTuiAgent(selection.agent)) {
throw new Error(
translate(
'auto.web.worktreeCreate.hostPredatesCustomAgents',
'This Orca host predates custom agents. Pick a built-in agent or update Orca on the host.'
)
)
}
return { startupAgent: selection.agent, startupPrompt: agentLaunch.prompt ?? '' }
}
+8
View File
@@ -60,6 +60,10 @@ export type AgentCatalogSnapshot = {
disabledAgents: TuiAgent[]
customAgents: SyncedCustomTuiAgent[]
deletedCustomAgents: DeletedCustomTuiAgent[]
/** Set while the host's v1 migration is fail-closed behind a failed pinned
* backup: catalog/reference writes are read-only pending recovery ON THE
* HOST. Boolean only — the error text stays host-local, never on the wire. */
migrationBlocked?: true
}
export type AgentProjectionStatus =
@@ -96,6 +100,10 @@ export type LocalAgentCatalogSnapshot = Omit<AgentCatalogSnapshot, 'customAgents
repairIssues: AgentCatalogRepairIssue[]
projection: AgentProjectionStatus
localStorage: LocalAgentCatalogStorageStatus
// Present while the pinned pre-v1 backup is failing and all catalog/reference
// writes are fail-closed; lets Settings surface the block on load, not only
// after a rejected mutation.
migrationBlockedError?: string
}
export const MAX_LOCAL_AGENT_DRAFT_BYTES = 1_048_576
+23
View File
@@ -0,0 +1,23 @@
// Data-recovery DTOs shared by main, preload, and renderer. Metadata only:
// no filesystem paths and no backup contents ever cross this boundary.
export type RecoveryPointId = 'agent-catalog-pre-v1'
export type RecoveryPointDto = {
id: RecoveryPointId
/** 'previous-binary' points require installing the prior Orca release after
* restore (Prepare downgrade); 'current-build' points restart in place. */
compatibility: 'previous-binary' | 'current-build'
createdAtMs: number | null
sizeBytes: number | null
}
export type RestoreRecoveryPointMode = 'prepare-downgrade' | 'restore-and-restart'
export type DataRecoveryOperationResult = { ok: true } | { ok: false; error: string }
export type DataRecoveryMigrationStatus = {
/** Non-null while the agent-catalog v1 migration is blocked by a failed
* pinned backup; catalog/reference writes are fail-closed until it clears. */
agentCatalogMigrationError: string | null
}