From 1b52be62550535563d9fedec49f164fd3307e526 Mon Sep 17 00:00:00 2001 From: Tomonori Arai Date: Mon, 5 Oct 2026 21:49:32 -0500 Subject: [PATCH 01/48] fix(claude-accounts): preserve shared MCP OAuth credentials across an account switch (#21931) * fix(claude-accounts): preserve shared MCP OAuth credentials across an account switch Orca's account switch writes the target managed account's own stored credential verbatim to the global "Claude Code-credentials" Keychain item. That credential never carries mcpOAuth/mcpOAuthClientConfig/mcpXaaIdp/mcpXaaIdpConfig/pluginSecrets (they are machine-shared MCP connector state, not per-account), so every switch silently drops any MCP connections the previous session had. Merge the live credential's copy of these shared fields into the target credential right before the Keychain write, live-wins (absence included), mirroring how the third-party claude-swap tool already treats this exact shared Keychain item. Fixes #16098 * fix(claude-accounts): skip reformatting shared credential merge when nothing changed mergeSharedClaudeCredentialFields always re-serialized via JSON.stringify, even when the shared-key set was identical on both sides. The reformatted-but-semantically-equal JSON (e.g. missing the original trailing newline) then read as an external Claude Code refresh to the read-back byte-equality check in runtime-auth-sync, causing every subsequent sync to wrongly adopt it into managed credential storage. Return the original string when the merge changes nothing. * fix(claude-accounts): validate OAuth shape, preserve key order, and degrade Keychain read failures Address CodeRabbit/pullfrog follow-up review on the shared-credential merge: - parseCredentialObject accepted claudeAiOauth as null or a primitive; the merge would then serialize the malformed target instead of passing it through unchanged. Validate it is a non-null object (hasClaudeOauthObject) before merging. - The no-op guard compared whole-object JSON.stringify output, so an existing shared key interleaved among target-only keys got moved to the end of the rebuilt object even when its value did not change, producing a formatting-only rewrite. Compare per shared key and keep the target's own key order via object spread instead. - runtime-auth-sync read the live Keychain credential for the merge with the raw (non-best-effort) function, so a transient Keychain read error aborted the whole account switch instead of just skipping the merge. Use the inherited readAggregateClaudeKeychainCredentialsBestEffort, matching every other Keychain read in this subsystem. - Fixed a dead citation URL (scaryghost/claude-swap 404s; the real repo is realiti4/claude-swap, verified to contain the cited SHARED_CREDENTIAL_KEYS / merge_shared_credential_fields). - Added docstrings to every function touched by this change. Tests: 5 new cases (null/primitive claudeAiOauth, interleaved shared key no-op and update, Keychain read failure does not abort the switch). * fix(claude-accounts): preserve connector state across all runtime credential writes Keep live MCP grants in both runtime files and keychain items, excluding shared secrets from managed account capture and refresh read-back. Preserve rotations and revocations through default restoration and reject unreadable live state before destructive writes. * fix(claude-accounts): retain grants from all live stores on startup Before Orca has a last-written baseline, a missing shared field in one store cannot prove revocation. Carry present fields from the other live stores, prioritizing a proved newer refresh candidate when available. * fix(claude-accounts): reconcile connector updates without guessing token freshness * test(preflight): freeze clock for Windows PATH probe assertion --------- Co-authored-by: tomarai85 Co-authored-by: Neil --- .../claude-managed-auth-storage.ts | 4 + ...ime-auth-service-account-switching.test.ts | 6 +- ...ntime-auth-service-materialization.test.ts | 7 +- ...service-shared-credential-failures.test.ts | 85 +++++ ...me-auth-service-shared-credentials.test.ts | 292 ++++++++++++++++++ ...runtime-auth-shared-credentials-fixture.ts | 51 +++ .../runtime-auth-credential-identity.ts | 21 ++ .../runtime-auth-keychain-snapshots.ts | 6 +- .../runtime-auth-managed-credentials.ts | 12 +- .../runtime-auth/runtime-auth-readback.ts | 16 +- .../runtime-auth-runtime-state.ts | 60 +++- .../runtime-auth-snapshot-capture.ts | 2 +- .../runtime-auth-snapshot-restore.ts | 76 ++++- .../runtime-auth/runtime-auth-state.ts | 2 + .../runtime-auth/runtime-auth-sync.ts | 9 +- .../shared-credential-fields.test.ts | 137 ++++++++ .../shared-credential-fields.ts | 166 ++++++++++ .../shared-credential-reconciliation.test.ts | 129 ++++++++ .../ipc/preflight-host-cli-status.test.ts | 2 + 19 files changed, 1048 insertions(+), 35 deletions(-) create mode 100644 src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts create mode 100644 src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts create mode 100644 src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts create mode 100644 src/main/claude-accounts/shared-credential-fields.test.ts create mode 100644 src/main/claude-accounts/shared-credential-fields.ts create mode 100644 src/main/claude-accounts/shared-credential-reconciliation.test.ts diff --git a/src/main/claude-accounts/claude-managed-auth-storage.ts b/src/main/claude-accounts/claude-managed-auth-storage.ts index 970202c8210..b430979655c 100644 --- a/src/main/claude-accounts/claude-managed-auth-storage.ts +++ b/src/main/claude-accounts/claude-managed-auth-storage.ts @@ -3,6 +3,7 @@ import { join, relative, resolve, sep } from 'node:path' import { parseWslUncPath } from '../../shared/wsl-paths' import { toWindowsWslPath } from '../wsl' import { runWslProcess } from '../wsl/wsl-runner' +import { stripSharedClaudeCredentialFields } from './shared-credential-fields' import { getClaudeManagedAccountsRoot, readClaudeManagedAuthFile, @@ -74,6 +75,9 @@ export class ClaudeManagedAuthStorage { credentialsJson: string ): Promise { const trustedPath = await this.assertOwned(managedAuthPath, accountId) + if (!parseWslUncPath(trustedPath)) { + credentialsJson = stripSharedClaudeCredentialFields(credentialsJson) + } if (process.platform === 'darwin') { await writeManagedClaudeKeychainCredentials(accountId, credentialsJson) } else { diff --git a/src/main/claude-accounts/runtime-auth-service-account-switching.test.ts b/src/main/claude-accounts/runtime-auth-service-account-switching.test.ts index 177d8f9c013..249e403b075 100644 --- a/src/main/claude-accounts/runtime-auth-service-account-switching.test.ts +++ b/src/main/claude-accounts/runtime-auth-service-account-switching.test.ts @@ -41,7 +41,7 @@ describe('ClaudeRuntimeAuthService', () => { cleanupRuntimeAuthTestState() }) - it('reads back refreshed file credentials when keychain reads fail', async () => { + it('saves a verified file refresh but refuses to overwrite unreadable keychain state', async () => { const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json') const originalCredentials = createClaudeCredentialsJson('user@example.com', 'original') const refreshedCredentials = createClaudeCredentialsJson('user@example.com', 'refreshed') @@ -64,10 +64,12 @@ describe('ClaudeRuntimeAuthService', () => { writeFileSync(runtimeCredentialsPath, refreshedCredentials, 'utf-8') testState.throwScopedKeychainRead = true testState.throwLegacyKeychainRead = true - await service.syncForCurrentSelection() + await expect(service.syncForCurrentSelection()).rejects.toThrow('scoped keychain read failed') expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(refreshedCredentials) expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCredentials) + expect(testState.scopedKeychainCredentials).toBe(originalCredentials) + expect(testState.legacyKeychainCredentials).toBe(originalCredentials) warn.mockRestore() }) diff --git a/src/main/claude-accounts/runtime-auth-service-materialization.test.ts b/src/main/claude-accounts/runtime-auth-service-materialization.test.ts index 2b5c96cf136..94bdab9b4d8 100644 --- a/src/main/claude-accounts/runtime-auth-service-materialization.test.ts +++ b/src/main/claude-accounts/runtime-auth-service-materialization.test.ts @@ -421,7 +421,7 @@ describe('ClaudeRuntimeAuthService', () => { } }) - it('falls back to atomic write when the unchanged check cannot read the target', async () => { + it('preserves unreadable runtime credentials instead of overwriting unknown connector grants', async () => { if (hostPlatform === 'win32') { return } @@ -449,7 +449,7 @@ describe('ClaudeRuntimeAuthService', () => { writeFileSync(join(managedAuthPath, '.credentials.json'), rotatedCredentials, 'utf-8') chmodSync(runtimeCredentialsPath, 0o000) try { - await service.syncForCurrentSelection() + await expect(service.syncForCurrentSelection()).rejects.toMatchObject({ code: 'EACCES' }) } finally { if (existsSync(runtimeCredentialsPath)) { chmodSync(runtimeCredentialsPath, 0o600) @@ -457,7 +457,8 @@ describe('ClaudeRuntimeAuthService', () => { warn.mockRestore() } - expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(rotatedCredentials) + expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(managedCredentials) + expect(testState.scopedKeychainCredentials).toBe(managedCredentials) }) it('tightens credential file permissions when unchanged content is already present', async () => { diff --git a/src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts b/src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts new file mode 100644 index 00000000000..4066063c562 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-shared-credential-failures.test.ts @@ -0,0 +1,85 @@ +import { + cleanupRuntimeAuthTestState, + createElectronMock, + createKeychainMock, + createOauthRefreshMock, + resetRuntimeAuthTestState, + testState +} from './runtime-auth-service-test-harness' +import { + createSharedCredentialRuntime, + sharedFields, + withSharedFields +} from './runtime-auth-shared-credentials-fixture' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { readFileSync, writeFileSync } from 'node:fs' + +vi.mock('electron', () => createElectronMock()) +vi.mock('./oauth-refresh', () => createOauthRefreshMock()) +vi.mock('./keychain', () => createKeychainMock()) +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { ...actual, homedir: () => testState.fakeHomeDir } +}) + +describe('shared connector credential write failures', () => { + beforeEach(resetRuntimeAuthTestState) + afterEach(cleanupRuntimeAuthTestState) + + it('keeps the committed baseline across a partial rollback so a rotated grant can be retried', async () => { + const { service, settings, runtimePath, first } = await createSharedCredentialRuntime() + settings.activeClaudeManagedAccountId = 'first' + await service.syncForCurrentSelection() + const rotated = { + ...sharedFields, + mcpOAuth: { figma: { accessToken: 'new-access', refreshToken: 'new-refresh' } } + } + testState.scopedKeychainCredentials = withSharedFields(first, rotated) + settings.activeClaudeManagedAccountId = 'second' + testState.throwLegacyRuntimeKeychainWrite = true + await expect(service.syncForCurrentSelection()).rejects.toThrow( + 'legacy runtime keychain write failed' + ) + expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated) + testState.throwLegacyRuntimeKeychainWrite = false + settings.activeClaudeManagedAccountId = 'first' + await service.forceMaterializeCurrentSelectionForRollback() + expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated) + expect(JSON.parse(readFileSync(runtimePath, 'utf-8')).claudeAiOauth.accessToken).toBe('first') + expect(testState.scopedKeychainCredentials).toBe(readFileSync(runtimePath, 'utf-8')) + expect(testState.legacyKeychainCredentials).toBe(testState.scopedKeychainCredentials) + }) + + it('preserves disjoint live grants when the first switch fails after writing only the scoped item', async () => { + const { service, settings, runtimePath, system } = await createSharedCredentialRuntime() + const figma = sharedFields.mcpOAuth.figma + testState.scopedKeychainCredentials = withSharedFields(system, { + ...sharedFields, + mcpOAuth: { figma } + }) + testState.legacyKeychainCredentials = withSharedFields(system, { + ...sharedFields, + mcpOAuth: { notion: { accessToken: 'notion-access', refreshToken: 'notion-refresh' } } + }) + writeFileSync(runtimePath, system) + settings.activeClaudeManagedAccountId = 'first' + testState.throwLegacyRuntimeKeychainWrite = true + await expect(service.syncForCurrentSelection()).rejects.toThrow( + 'legacy runtime keychain write failed' + ) + expect(JSON.parse(readFileSync(runtimePath, 'utf-8')).mcpOAuth).toEqual({ + figma, + notion: { accessToken: 'notion-access', refreshToken: 'notion-refresh' } + }) + testState.throwLegacyRuntimeKeychainWrite = false + await service.syncForCurrentSelection() + const runtime = JSON.parse(readFileSync(runtimePath, 'utf-8')) + expect(runtime.mcpOAuth).toEqual({ + figma, + notion: { accessToken: 'notion-access', refreshToken: 'notion-refresh' } + }) + expect(runtime.claudeAiOauth.accessToken).toBe('first') + expect(testState.scopedKeychainCredentials).toBe(readFileSync(runtimePath, 'utf-8')) + expect(testState.legacyKeychainCredentials).toBe(testState.scopedKeychainCredentials) + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts b/src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts new file mode 100644 index 00000000000..f3bb1f00137 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-service-shared-credentials.test.ts @@ -0,0 +1,292 @@ +import { + cleanupRuntimeAuthTestState, + createClaudeCredentialsJson, + createElectronMock, + createKeychainMock, + createOauthRefreshMock, + createStore, + readManagedCredentialsForTest, + resetRuntimeAuthTestState, + testState +} from './runtime-auth-service-test-harness' +import { + createSharedCredentialRuntime, + sharedFields, + withSharedFields +} from './runtime-auth-shared-credentials-fixture' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +vi.mock('electron', () => createElectronMock()) +vi.mock('./oauth-refresh', () => createOauthRefreshMock()) +vi.mock('./keychain', () => createKeychainMock()) +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() + return { ...actual, homedir: () => testState.fakeHomeDir } +}) + +describe('shared Claude connector credentials', () => { + beforeEach(resetRuntimeAuthTestState) + afterEach(cleanupRuntimeAuthTestState) + + it.each(['scoped', 'legacy', 'file'] as const)( + 'preserves connector grants stored only in %s when there is no previous Orca write', + async (surface) => { + const { service, settings, runtimePath, system } = await createSharedCredentialRuntime() + testState.scopedKeychainCredentials = system + testState.legacyKeychainCredentials = system + writeFileSync(runtimePath, system) + if (surface === 'scoped') { + testState.scopedKeychainCredentials = withSharedFields(system) + } else if (surface === 'legacy') { + testState.legacyKeychainCredentials = withSharedFields(system) + } else { + writeFileSync(runtimePath, withSharedFields(system)) + } + settings.activeClaudeManagedAccountId = 'first' + await service.syncForCurrentSelection() + expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(sharedFields) + } + ) + + it.each(['darwin', 'linux', 'win32'] as const)( + 'excludes connector secrets when capturing a managed account on %s', + async (platform) => { + const { firstPath, first } = await createSharedCredentialRuntime(platform) + const { ClaudeManagedAuthStorage } = await import('./claude-managed-auth-storage') + await new ClaudeManagedAuthStorage().writeCredentials( + 'first', + firstPath, + withSharedFields(first) + ) + expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual( + JSON.parse(first) + ) + } + ) + + it.each(['scoped', 'legacy', 'file'] as const)( + 'propagates connector revocations from the %s surface and does not resurrect frozen account grants', + async (surface) => { + const { service, settings, runtimePath, first, secondPath, second } = + await createSharedCredentialRuntime() + settings.activeClaudeManagedAccountId = 'first' + await service.syncForCurrentSelection() + if (surface === 'scoped') { + testState.scopedKeychainCredentials = first + } else if (surface === 'legacy') { + testState.legacyKeychainCredentials = first + } else { + writeFileSync(runtimePath, first) + } + testState.managedKeychainCredentials.set('second', withSharedFields(second)) + writeFileSync(join(secondPath, '.credentials.json'), withSharedFields(second)) + settings.activeClaudeManagedAccountId = 'second' + await service.syncForCurrentSelection() + expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toEqual(JSON.parse(second)) + expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toEqual(JSON.parse(second)) + expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toEqual(JSON.parse(second)) + } + ) + + it('preserves grants refreshed only in the keychain when returning to the system default', async () => { + const { service, settings, runtimePath, first, system } = await createSharedCredentialRuntime() + settings.activeClaudeManagedAccountId = 'first' + await service.syncForCurrentSelection() + const rotated = { + ...sharedFields, + mcpOAuth: { figma: { accessToken: 'rotated', refreshToken: 'rotated' } } + } + testState.legacyKeychainCredentials = withSharedFields(first, rotated) + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated) + expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toMatchObject(rotated) + expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(rotated) + const nextRotation = { + ...sharedFields, + mcpOAuth: { figma: { accessToken: 'rotated-again', refreshToken: 'rotated-again' } } + } + testState.scopedKeychainCredentials = withSharedFields(system, nextRotation) + settings.activeClaudeManagedAccountId = 'first' + await service.syncForCurrentSelection() + expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(nextRotation) + expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(nextRotation) + }) + + it.each(['darwin', 'linux', 'win32'] as const)( + 'keeps connector grants through account switches, syncs, restart, and deselect on %s', + async (platform) => { + const state = await createSharedCredentialRuntime(platform) + const { service, settings, runtimePath, first, second, firstPath, secondPath } = state + for (const id of ['first', 'second', 'first']) { + settings.activeClaudeManagedAccountId = id + await service.syncForCurrentSelection() + await service.syncForCurrentSelection() + const runtime = JSON.parse(readFileSync(runtimePath, 'utf-8')) + expect(runtime).toMatchObject(sharedFields) + expect(runtime.claudeAiOauth.accessToken).toBe(id) + if (platform === 'darwin') { + expect(testState.scopedKeychainCredentials).toBe(readFileSync(runtimePath, 'utf-8')) + expect(testState.legacyKeychainCredentials).toBe(testState.scopedKeychainCredentials) + } + } + expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual( + JSON.parse(first) + ) + expect(JSON.parse(readManagedCredentialsForTest('second', secondPath) ?? '')).toEqual( + JSON.parse(second) + ) + const rotated = { + ...sharedFields, + mcpOAuth: { figma: { accessToken: 'rotated-access', refreshToken: 'rotated-refresh' } } + } + const live = withSharedFields(first, rotated) + writeFileSync(runtimePath, live) + testState.scopedKeychainCredentials = live + testState.legacyKeychainCredentials = live + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime auth uses only getSettings/updateSettings from this store mock. + const restarted = new ClaudeRuntimeAuthService(createStore(settings) as never) + await restarted.syncForCurrentSelection() + settings.activeClaudeManagedAccountId = null + await restarted.syncForCurrentSelection() + const restored = JSON.parse(readFileSync(runtimePath, 'utf-8')) + expect(restored).toMatchObject(rotated) + expect(restored.claudeAiOauth.accessToken).toBe('system') + if (platform === 'darwin') { + expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toMatchObject(rotated) + expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(rotated) + } + } + ) + + it.each(['scoped', 'legacy', 'file'] as const)( + 'preserves MCP rotations written only to the %s surface while adopting a Claude refresh', + async (surface) => { + const { service, settings, runtimePath, firstPath } = await createSharedCredentialRuntime() + settings.activeClaudeManagedAccountId = 'first' + await service.syncForCurrentSelection() + const refreshed = createClaudeCredentialsJson( + 'first@example.com', + 'refreshed', + null, + Date.now() + 120_000 + ) + const rotated = { + ...sharedFields, + mcpOAuth: { figma: { accessToken: 'new-access', refreshToken: 'new-refresh' } } + } + const live = withSharedFields(refreshed, rotated) + if (surface === 'scoped') { + testState.scopedKeychainCredentials = live + } + if (surface === 'legacy') { + testState.legacyKeychainCredentials = live + } + if (surface === 'file') { + writeFileSync(runtimePath, live) + } + await service.syncForCurrentSelection() + expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(rotated) + expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual( + JSON.parse(refreshed) + ) + } + ) + + it('preserves conflicting MCP grants after restart even when the Claude account token is newer', async () => { + const { service, settings, runtimePath, firstPath } = await createSharedCredentialRuntime() + settings.activeClaudeManagedAccountId = 'first' + await service.syncForCurrentSelection() + const refreshed = createClaudeCredentialsJson( + 'first@example.com', + 'refreshed', + null, + Date.now() + 120_000 + ) + const rotated = { + ...sharedFields, + mcpOAuth: { figma: { accessToken: 'new-access', refreshToken: 'new-refresh' } } + } + testState.legacyKeychainCredentials = withSharedFields(refreshed, rotated) + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime auth uses only getSettings/updateSettings from this store mock. + const restarted = new ClaudeRuntimeAuthService(createStore(settings) as never) + await expect(restarted.syncForCurrentSelection()).rejects.toThrow( + 'live connector credentials conflict' + ) + expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(sharedFields) + expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toMatchObject(sharedFields) + expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toMatchObject(rotated) + expect(JSON.parse(readManagedCredentialsForTest('first', firstPath) ?? '')).toEqual( + JSON.parse(refreshed) + ) + }) + + it('leaves all credentials untouched when the active keychain cannot be read', async () => { + const { service, settings, runtimePath } = await createSharedCredentialRuntime() + settings.activeClaudeManagedAccountId = 'first' + await service.syncForCurrentSelection() + const before = readFileSync(runtimePath, 'utf-8') + settings.activeClaudeManagedAccountId = 'second' + testState.throwScopedKeychainRead = true + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await expect(service.syncForCurrentSelection()).rejects.toThrow('scoped keychain read failed') + expect(readFileSync(runtimePath, 'utf-8')).toBe(before) + expect(testState.scopedKeychainCredentials).toBe(before) + expect(testState.legacyKeychainCredentials).toBe(before) + testState.throwScopedKeychainRead = false + await service.syncForCurrentSelection() + expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toMatchObject(sharedFields) + expect(JSON.parse(readFileSync(runtimePath, 'utf-8')).claudeAiOauth.accessToken).toBe('second') + warn.mockRestore() + }) + + it.each(['scoped', 'legacy', 'file'] as const)( + 'refuses to overwrite malformed live credentials in the %s surface', + async (surface) => { + const { service, settings, runtimePath } = await createSharedCredentialRuntime() + settings.activeClaudeManagedAccountId = 'first' + await service.syncForCurrentSelection() + if (surface === 'scoped') { + testState.scopedKeychainCredentials = '{broken' + } else if (surface === 'legacy') { + testState.legacyKeychainCredentials = '{broken' + } else { + writeFileSync(runtimePath, '{broken') + } + const fileBefore = readFileSync(runtimePath, 'utf-8') + const scopedBefore = testState.scopedKeychainCredentials + const legacyBefore = testState.legacyKeychainCredentials + settings.activeClaudeManagedAccountId = 'second' + await expect(service.syncForCurrentSelection()).rejects.toThrow( + 'Cannot preserve malformed Claude runtime credentials' + ) + expect(readFileSync(runtimePath, 'utf-8')).toBe(fileBefore) + expect(testState.scopedKeychainCredentials).toBe(scopedBefore) + expect(testState.legacyKeychainCredentials).toBe(legacyBefore) + } + ) + + it('keeps newly authorized MCP grants when returning to a signed-out system default', async () => { + const { service, settings, runtimePath, first } = await createSharedCredentialRuntime() + // A missing system credential is a signed-out default, with no connector grants yet. + rmSync(runtimePath) + testState.scopedKeychainCredentials = null + testState.legacyKeychainCredentials = null + settings.activeClaudeManagedAccountId = 'first' + await service.syncForCurrentSelection() + const live = withSharedFields(first) + writeFileSync(runtimePath, live) + testState.scopedKeychainCredentials = live + testState.legacyKeychainCredentials = live + settings.activeClaudeManagedAccountId = null + await service.syncForCurrentSelection() + expect(existsSync(runtimePath)).toBe(true) + expect(JSON.parse(readFileSync(runtimePath, 'utf-8'))).toEqual(sharedFields) + expect(JSON.parse(testState.scopedKeychainCredentials ?? '')).toEqual(sharedFields) + expect(JSON.parse(testState.legacyKeychainCredentials ?? '')).toEqual(sharedFields) + }) +}) diff --git a/src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts b/src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts new file mode 100644 index 00000000000..8cb00420dc6 --- /dev/null +++ b/src/main/claude-accounts/runtime-auth-shared-credentials-fixture.ts @@ -0,0 +1,51 @@ +import { + createClaudeAccount, + createClaudeCredentialsJson, + createManagedClaudeAuth, + createSettings, + createStore, + setPlatform, + testState +} from './runtime-auth-service-test-harness' +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' + +export const sharedFields = { + mcpOAuth: { figma: { accessToken: 'mcp-access', refreshToken: 'mcp-refresh' } }, + mcpOAuthClientConfig: { figma: { clientId: 'figma-client' } }, + mcpXaaIdp: { token: 'idp-token' }, + mcpXaaIdpConfig: { issuer: 'idp-issuer' }, + pluginSecrets: { plugin: 'secret' } +} + +export function withSharedFields( + credentials: string, + fields: Record = sharedFields +): string { + return JSON.stringify({ ...JSON.parse(credentials), ...fields }) +} + +export async function createSharedCredentialRuntime(platform: NodeJS.Platform = 'darwin') { + setPlatform(platform) + const runtimePath = join(testState.fakeHomeDir, '.claude', '.credentials.json') + const system = createClaudeCredentialsJson('system@example.com', 'system') + const first = createClaudeCredentialsJson('first@example.com', 'first') + const second = createClaudeCredentialsJson('second@example.com', 'second') + const firstPath = createManagedClaudeAuth(testState.userDataDir, 'first', first) + const secondPath = createManagedClaudeAuth(testState.userDataDir, 'second', second) + writeFileSync(runtimePath, withSharedFields(system)) + testState.scopedKeychainCredentials = withSharedFields(system) + testState.legacyKeychainCredentials = withSharedFields(system) + const settings = createSettings({ + claudeManagedAccounts: [ + createClaudeAccount('first', firstPath, { email: 'first@example.com' }), + createClaudeAccount('second', secondPath, { email: 'second@example.com' }) + ] + }) + const store = createStore(settings) + const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Runtime auth uses only getSettings/updateSettings from this store mock. + const service = new ClaudeRuntimeAuthService(store as never) + await service.syncForCurrentSelection() + return { service, settings, runtimePath, first, second, system, firstPath, secondPath } +} diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts index e94801a8eec..d029ae0d699 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-credential-identity.ts @@ -1,4 +1,5 @@ import { ClaudeRuntimeAuthFileStorage } from './runtime-auth-file-storage' +import { stripSharedClaudeCredentialFields } from '../shared-credential-fields' import type { ClaudeAuthIdentity, ClaudeReadBackMatch, @@ -6,6 +7,26 @@ import type { } from './runtime-auth-types' export class ClaudeRuntimeAuthCredentialIdentity extends ClaudeRuntimeAuthFileStorage { + protected accountCredentialFieldsEqual(left: string | null, right: string | null): boolean { + if (left === right) { + return true + } + if (left === null || right === null) { + return false + } + try { + const leftAccount = this.asRecord(JSON.parse(stripSharedClaudeCredentialFields(left))) + const rightAccount = this.asRecord(JSON.parse(stripSharedClaudeCredentialFields(right))) + return ( + leftAccount !== null && + rightAccount !== null && + this.jsonValuesEqual(leftAccount, rightAccount) + ) + } catch { + return false + } + } + protected readIdentityFromCredentials(credentialsJson: string): ClaudeAuthIdentity | null { let parsed: Record try { diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts index 25afacc9942..ef4c54acab8 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-keychain-snapshots.ts @@ -41,7 +41,11 @@ export class ClaudeRuntimeAuthKeychainSnapshots extends ClaudeRuntimeAuthManaged service: 'scoped' | 'legacy', managedCredentialsJson: string | undefined ): string | null { - if (managedCredentialsJson && credentialsJson === managedCredentialsJson && previousSnapshot) { + if ( + managedCredentialsJson && + this.accountCredentialFieldsEqual(credentialsJson, managedCredentialsJson) && + previousSnapshot + ) { const previousValue = this.readKeychainSnapshotValue(previousSnapshot, service) if (previousValue.status === 'captured') { return previousValue.credentialsJson diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts index ad68d59eadb..3a93f63d4dd 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-managed-credentials.ts @@ -14,6 +14,7 @@ import { writeManagedClaudeKeychainCredentials } from '../keychain' import { ClaudeRuntimeAuthCredentialIdentity } from './runtime-auth-credential-identity' +import { stripSharedClaudeCredentialFields } from '../shared-credential-fields' const OWNERSHIP_PROBE_TIMEOUT = 'orca-wsl-ownership-probe-timeout' @@ -28,9 +29,13 @@ export class ClaudeRuntimeAuthManagedCredentials extends ClaudeRuntimeAuthCreden return null } if (process.platform === 'darwin') { - return readManagedClaudeKeychainCredentials(account.id) + const credentials = await readManagedClaudeKeychainCredentials(account.id) + return credentials === null ? null : stripSharedClaudeCredentialFields(credentials) } - return readClaudeManagedAuthFile(managedAuthPath, '.credentials.json') + const credentials = readClaudeManagedAuthFile(managedAuthPath, '.credentials.json') + return credentials === null || account.managedAuthRuntime === 'wsl' + ? credentials + : stripSharedClaudeCredentialFields(credentials) } protected async writeManagedCredentials( @@ -41,6 +46,9 @@ export class ClaudeRuntimeAuthManagedCredentials extends ClaudeRuntimeAuthCreden if (!managedAuthPath) { throw new Error('Managed Claude auth storage is not owned by Orca.') } + if (account.managedAuthRuntime !== 'wsl') { + credentialsJson = stripSharedClaudeCredentialFields(credentialsJson) + } if (process.platform === 'darwin') { await writeManagedClaudeKeychainCredentials(account.id, credentialsJson) return diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts index 77dfea27184..848ee093dfb 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-readback.ts @@ -22,7 +22,11 @@ export class ClaudeRuntimeAuthReadback extends ClaudeRuntimeAuthCredentialMatchi this.lastWrittenCredentialsJson === null ? candidates : candidates.filter( - (candidate) => candidate.credentialsJson !== this.lastWrittenCredentialsJson + (candidate) => + !this.accountCredentialFieldsEqual( + candidate.credentialsJson, + this.lastWrittenCredentialsJson + ) ) if (changedCandidates.length === 0) { return { status: 'unchanged' } @@ -97,12 +101,13 @@ export class ClaudeRuntimeAuthReadback extends ClaudeRuntimeAuthCredentialMatchi await this.writeManagedCredentials(match.account, runtimeContents) if (options.updateLastWrittenCredentialsJson) { - this.writeRuntimeCredentials(runtimeContents) - this.lastWrittenCredentialsJson = runtimeContents + const merged = await this.mergeLiveRuntimeSharedCredentials(runtimeContents) + this.writeRuntimeCredentials(merged) if (process.platform === 'darwin') { const paths = this.pathResolver.getRuntimePaths() - await writeActiveClaudeKeychainCredentialsForRuntime(runtimeContents, paths.configDir) + await writeActiveClaudeKeychainCredentialsForRuntime(merged, paths.configDir) } + this.lastWrittenSharedCredentialsJson = merged } return { status: 'persisted' } } catch (error) { @@ -145,7 +150,8 @@ export class ClaudeRuntimeAuthReadback extends ClaudeRuntimeAuthCredentialMatchi pushCandidate(legacyKeychainCredentials) pushCandidate(fileCredentials) return candidates.filter( - (candidate) => candidate.credentialsJson !== baselineCredentialsJson + (candidate) => + !this.accountCredentialFieldsEqual(candidate.credentialsJson, baselineCredentialsJson) ) } pushCandidate(scopedKeychainCredentials) diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts index d99af570557..40d4d45395a 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-runtime-state.ts @@ -2,8 +2,13 @@ import { existsSync, readFileSync, rmSync } from 'node:fs' import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' import { deleteActiveClaudeKeychainCredentialsStrict, + readActiveClaudeKeychainCredentialsStrict, writeActiveClaudeKeychainCredentials } from '../keychain' +import { + mergeSharedClaudeCredentialFields, + reconcileSharedClaudeCredentialFields +} from '../shared-credential-fields' import { ClaudeRuntimeAuthKeychainSnapshots } from './runtime-auth-keychain-snapshots' import { RUNTIME_OAUTH_ACCOUNT_PARSE_ERROR, @@ -11,6 +16,31 @@ import { } from './runtime-auth-types' export class ClaudeRuntimeAuthRuntimeState extends ClaudeRuntimeAuthKeychainSnapshots { + protected async mergeLiveRuntimeSharedCredentials(credentialsJson: string): Promise { + const paths = this.pathResolver.getRuntimePaths() + const candidates: string[] = [] + if (process.platform === 'darwin') { + // A failed read must stop the switch before any shared tokens are overwritten. + const scoped = await readActiveClaudeKeychainCredentialsStrict(paths.configDir) + const legacy = await readActiveClaudeKeychainCredentialsStrict() + if (scoped !== null) { + candidates.push(scoped) + } + if (legacy !== null) { + candidates.push(legacy) + } + } + const file = this.readRuntimeCredentialsFile() + if (file !== null) { + candidates.push(file) + } + const shared = reconcileSharedClaudeCredentialFields( + candidates, + this.lastWrittenSharedCredentialsJson + ) + return mergeSharedClaudeCredentialFields(credentialsJson, shared) + } + protected readRuntimeCredentialsFile(): string | null { const credentialsPath = this.pathResolver.getRuntimePaths().credentialsPath return existsSync(credentialsPath) ? readFileSync(credentialsPath, 'utf-8') : null @@ -58,7 +88,10 @@ export class ClaudeRuntimeAuthRuntimeState extends ClaudeRuntimeAuthKeychainSnap const currentCredentialsJson = existsSync(paths.credentialsPath) ? readFileSync(paths.credentialsPath, 'utf-8') : null - return currentCredentialsJson === previouslyWrittenCredentialsJson + return this.accountCredentialFieldsEqual( + currentCredentialsJson, + previouslyWrittenCredentialsJson + ) } protected runtimeCredentialsChangedSinceLastWrite(baselineCredentialsJson: string): boolean { @@ -76,10 +109,17 @@ export class ClaudeRuntimeAuthRuntimeState extends ClaudeRuntimeAuthKeychainSnap } } - protected restoreRuntimeCredentials(credentialsJson: string | null): void { + protected restoreRuntimeCredentials( + credentialsJson: string | null, + sharedCredentialsJson?: string + ): void { const paths = this.pathResolver.getRuntimePaths() - if (credentialsJson !== null) { - this.writeRuntimeCredentials(credentialsJson) + const restored = mergeSharedClaudeCredentialFields( + credentialsJson ?? '{}', + sharedCredentialsJson ?? this.readRuntimeCredentialsFile() + ) + if (credentialsJson !== null || restored !== '{}') { + this.writeRuntimeCredentials(restored) } else { rmSync(paths.credentialsPath, { force: true }) } @@ -122,16 +162,20 @@ export class ClaudeRuntimeAuthRuntimeState extends ClaudeRuntimeAuthKeychainSnap await this.readActiveClaudeKeychainCredentialsBestEffort(configDir) return ( previouslyWrittenCredentialsJson !== null && - currentCredentialsJson === previouslyWrittenCredentialsJson + this.accountCredentialFieldsEqual(currentCredentialsJson, previouslyWrittenCredentialsJson) ) } protected async restoreActiveClaudeKeychainCredentials( credentialsJson: string | null, - configDir?: string + configDir?: string, + sharedCredentialsJson?: string ): Promise { - await (credentialsJson !== null - ? writeActiveClaudeKeychainCredentials(credentialsJson, configDir) + const live = + sharedCredentialsJson ?? (await readActiveClaudeKeychainCredentialsStrict(configDir)) + const restored = mergeSharedClaudeCredentialFields(credentialsJson ?? '{}', live) + await (credentialsJson !== null || restored !== '{}' + ? writeActiveClaudeKeychainCredentials(restored, configDir) : deleteActiveClaudeKeychainCredentialsStrict(configDir)) } diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts index eb1aea611e1..e184ec4e751 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-capture.ts @@ -12,7 +12,7 @@ export class ClaudeRuntimeAuthSnapshotCapture extends ClaudeRuntimeAuthReadback ): Promise { const snapshotPath = this.getSystemDefaultSnapshotPath() const existingSnapshot = this.readSystemDefaultSnapshot(snapshotPath) - if (runtimeCredentialsJson !== managedCredentialsJson) { + if (!this.accountCredentialFieldsEqual(runtimeCredentialsJson, managedCredentialsJson)) { await this.captureSystemDefaultSnapshot({ force: true, previousSnapshot: existingSnapshot, diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts index 76ccd342d84..bb2158d9464 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-snapshot-restore.ts @@ -1,6 +1,4 @@ -import { rmSync } from 'node:fs' import type { ClaudeManagedAccount } from '../../../shared/managed-account-types' -import { deleteActiveClaudeKeychainCredentialsStrict } from '../keychain' import { ClaudeRuntimeAuthSnapshotCapture } from './runtime-auth-snapshot-capture' import type { ClaudeKeychainSnapshotValue } from './runtime-auth-types' @@ -40,25 +38,39 @@ export class ClaudeRuntimeAuthSnapshotRestore extends ClaudeRuntimeAuthSnapshotC hasCredentialSurfaceOwnership = fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned } + const sharedCredentialsJson = hasCredentialSurfaceOwnership + ? await this.mergeLiveRuntimeSharedCredentials('{}') + : undefined this.restoreRuntimeOauthAccountIfOwned( snapshot?.configOauthAccount ?? null, this.getOwnedRuntimeOauthBaseline(ownedOauthAccount, hasCredentialSurfaceOwnership), { allowCredentialSurfaceOwnership: hasCredentialSurfaceOwnership } ) if (fileCredentialsOwned) { - this.restoreRuntimeCredentials(snapshot?.credentialsJson ?? null) + this.restoreRuntimeCredentials(snapshot?.credentialsJson ?? null, sharedCredentialsJson) } if (process.platform === 'darwin') { if (scopedSnapshot?.status === 'captured' && scopedKeychainOwned) { await this.restoreActiveClaudeKeychainCredentials( scopedSnapshot.credentialsJson, - paths.configDir + paths.configDir, + sharedCredentialsJson ) } if (legacySnapshot?.status === 'captured' && legacyKeychainOwned) { - await this.restoreActiveClaudeKeychainCredentials(legacySnapshot.credentialsJson) + await this.restoreActiveClaudeKeychainCredentials( + legacySnapshot.credentialsJson, + undefined, + sharedCredentialsJson + ) } } + this.recordRestoredSharedCredentials( + sharedCredentialsJson, + fileCredentialsOwned, + scopedSnapshot?.status === 'captured' && scopedKeychainOwned, + legacySnapshot?.status === 'captured' && legacyKeychainOwned + ) this.lastWrittenCredentialsJson = null this.lastWrittenOauthAccount = null this.hasLastWrittenOauthAccount = false @@ -79,6 +91,21 @@ export class ClaudeRuntimeAuthSnapshotRestore extends ClaudeRuntimeAuthSnapshotC return null } + private recordRestoredSharedCredentials( + credentialsJson: string | undefined, + fileRestored: boolean, + scopedRestored: boolean, + legacyRestored: boolean + ): void { + if ( + credentialsJson !== undefined && + fileRestored && + (process.platform !== 'darwin' || (scopedRestored && legacyRestored)) + ) { + this.lastWrittenSharedCredentialsJson = credentialsJson + } + } + protected async clearRuntimeAuthForAccount( account: ClaudeManagedAccount, managedOauthAccount: unknown @@ -104,6 +131,9 @@ export class ClaudeRuntimeAuthSnapshotRestore extends ClaudeRuntimeAuthSnapshotC } const hasCredentialSurfaceOwnership = fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned + const sharedCredentialsJson = hasCredentialSurfaceOwnership + ? await this.mergeLiveRuntimeSharedCredentials('{}') + : undefined this.restoreRuntimeOauthAccountIfOwned( null, this.getOwnedRuntimeOauthBaseline(managedOauthAccount, hasCredentialSurfaceOwnership), @@ -112,16 +142,26 @@ export class ClaudeRuntimeAuthSnapshotRestore extends ClaudeRuntimeAuthSnapshotC } ) if (fileCredentialsOwned) { - rmSync(paths.credentialsPath, { force: true }) + this.restoreRuntimeCredentials(null, sharedCredentialsJson) } if (process.platform === 'darwin') { if (scopedKeychainOwned) { - await deleteActiveClaudeKeychainCredentialsStrict(paths.configDir) + await this.restoreActiveClaudeKeychainCredentials( + null, + paths.configDir, + sharedCredentialsJson + ) } if (legacyKeychainOwned) { - await deleteActiveClaudeKeychainCredentialsStrict() + await this.restoreActiveClaudeKeychainCredentials(null, undefined, sharedCredentialsJson) } } + this.recordRestoredSharedCredentials( + sharedCredentialsJson, + fileCredentialsOwned, + scopedKeychainOwned, + legacyKeychainOwned + ) } protected async restoreSystemDefaultSnapshotForMissingManagedCredentials( @@ -159,6 +199,9 @@ export class ClaudeRuntimeAuthSnapshotRestore extends ClaudeRuntimeAuthSnapshotC } const hasCredentialSurfaceOwnership = fileCredentialsOwned || scopedKeychainOwned || legacyKeychainOwned + const sharedCredentialsJson = hasCredentialSurfaceOwnership + ? await this.mergeLiveRuntimeSharedCredentials('{}') + : undefined this.restoreRuntimeOauthAccountIfOwned( snapshot.configOauthAccount, this.getOwnedRuntimeOauthBaseline(managedOauthAccount, hasCredentialSurfaceOwnership), @@ -167,19 +210,30 @@ export class ClaudeRuntimeAuthSnapshotRestore extends ClaudeRuntimeAuthSnapshotC } ) if (fileCredentialsOwned) { - this.restoreRuntimeCredentials(snapshot.credentialsJson) + this.restoreRuntimeCredentials(snapshot.credentialsJson, sharedCredentialsJson) } if (process.platform === 'darwin') { if (scopedSnapshot?.status === 'captured' && scopedKeychainOwned) { await this.restoreActiveClaudeKeychainCredentials( scopedSnapshot.credentialsJson, - paths.configDir + paths.configDir, + sharedCredentialsJson ) } if (legacySnapshot?.status === 'captured' && legacyKeychainOwned) { - await this.restoreActiveClaudeKeychainCredentials(legacySnapshot.credentialsJson) + await this.restoreActiveClaudeKeychainCredentials( + legacySnapshot.credentialsJson, + undefined, + sharedCredentialsJson + ) } } + this.recordRestoredSharedCredentials( + sharedCredentialsJson, + fileCredentialsOwned, + scopedSnapshot?.status === 'captured' && scopedKeychainOwned, + legacySnapshot?.status === 'captured' && legacyKeychainOwned + ) this.clearLastWrittenRuntimeState() } } diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-state.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-state.ts index d25ee84b44a..6d6e5e47a48 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-state.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-state.ts @@ -7,6 +7,8 @@ export class ClaudeRuntimeAuthState { protected lastSyncedAccountId: string | null = null // Why: creds Orca last wrote to the shared file; a mismatch on managed→default transition means an external login overwrote it, so adopt it as the new default. protected lastWrittenCredentialsJson: string | null = null + // Connector revocations require a baseline that reached every runtime store, not a partial file write. + protected lastWrittenSharedCredentialsJson: string | null = null protected hasMaterializedRuntimeAuth = false protected hasLastWrittenOauthAccount = false protected lastWrittenOauthAccount: unknown = null diff --git a/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts b/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts index f191689a446..23e9112d7ed 100644 --- a/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts +++ b/src/main/claude-accounts/runtime-auth/runtime-auth-sync.ts @@ -257,11 +257,15 @@ export class ClaudeRuntimeAuthSync extends ClaudeRuntimeAuthPreparationService { } const paths = this.pathResolver.getRuntimePaths() - this.writeRuntimeCredentials(credentialsJson) + const runtimeCredentialsJson = await this.mergeLiveRuntimeSharedCredentials(credentialsJson) + this.writeRuntimeCredentials(runtimeCredentialsJson) if (process.platform === 'darwin') { // Why: Claude Code 2.1+ reads the scoped service, older builds the legacy unsuffixed one; runtime switching must satisfy both. try { - await writeActiveClaudeKeychainCredentialsForRuntime(credentialsJson, paths.configDir) + await writeActiveClaudeKeychainCredentialsForRuntime( + runtimeCredentialsJson, + paths.configDir + ) } catch (error) { await this.restoreSystemDefaultSnapshot( credentialsJson, @@ -270,6 +274,7 @@ export class ClaudeRuntimeAuthSync extends ClaudeRuntimeAuthPreparationService { throw error } } + this.lastWrittenSharedCredentialsJson = runtimeCredentialsJson const managedOauthAccount = await this.readManagedOauthAccount(activeAccount) if (this.writeRuntimeOauthAccount(managedOauthAccount)) { this.lastWrittenOauthAccount = managedOauthAccount diff --git a/src/main/claude-accounts/shared-credential-fields.test.ts b/src/main/claude-accounts/shared-credential-fields.test.ts new file mode 100644 index 00000000000..097e1725d12 --- /dev/null +++ b/src/main/claude-accounts/shared-credential-fields.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import { + mergeSharedClaudeCredentialFields, + SHARED_CLAUDE_CREDENTIAL_KEYS +} from './shared-credential-fields' + +describe('mergeSharedClaudeCredentialFields', () => { + it('merges the live credential shared fields into the target credential', () => { + const target = JSON.stringify({ claudeAiOauth: { accessToken: 'target-token' } }) + const live = JSON.stringify({ + claudeAiOauth: { accessToken: 'live-token' }, + mcpOAuth: { conn1: 'v1' }, + pluginSecrets: { s: 1 } + }) + const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live)) + expect(result.claudeAiOauth).toEqual({ accessToken: 'target-token' }) + expect(result.mcpOAuth).toEqual({ conn1: 'v1' }) + expect(result.pluginSecrets).toEqual({ s: 1 }) + }) + + it('is absence-authoritative: a shared key missing on live is not carried from the target', () => { + const target = JSON.stringify({ + claudeAiOauth: { accessToken: 'target-token' }, + mcpOAuth: { stale: 'rotated-out' } + }) + const live = JSON.stringify({ claudeAiOauth: { accessToken: 'live-token' } }) + const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live)) + expect(result.mcpOAuth).toBeUndefined() + }) + + it('leaves account-scoped sibling keys on the target untouched', () => { + const target = JSON.stringify({ + claudeAiOauth: { accessToken: 'target-token' }, + trustedDeviceToken: 'target-device-token' + }) + const live = JSON.stringify({ + claudeAiOauth: { accessToken: 'live-token' }, + mcpOAuth: { conn1: 'v1' } + }) + const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live)) + expect(result.trustedDeviceToken).toBe('target-device-token') + expect(result.mcpOAuth).toEqual({ conn1: 'v1' }) + }) + + it('returns the target unchanged when there is no live credential to merge from', () => { + const target = JSON.stringify({ claudeAiOauth: { accessToken: 'target-token' } }) + expect(mergeSharedClaudeCredentialFields(target, null)).toBe(target) + }) + + it('returns the target unchanged when the live value is not a JSON object (e.g. a raw managed API key)', () => { + const target = JSON.stringify({ claudeAiOauth: { accessToken: 'target-token' } }) + expect(mergeSharedClaudeCredentialFields(target, 'sk-ant-api-not-json')).toBe(target) + }) + + it('returns the target unchanged when the target itself is not a Claude OAuth credential object (managed API key)', () => { + const target = 'sk-ant-api-a-raw-managed-key' + const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } }) + expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) + }) + + it('returns the target unchanged when the target JSON is malformed', () => { + const target = '{not valid json' + const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } }) + expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) + }) + + it('returns the target unchanged when claudeAiOauth is null rather than an object', () => { + const target = JSON.stringify({ claudeAiOauth: null }) + const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } }) + expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) + }) + + it('returns the target unchanged when claudeAiOauth is a primitive rather than an object', () => { + const target = JSON.stringify({ claudeAiOauth: 'not-an-object' }) + const live = JSON.stringify({ claudeAiOauth: {}, mcpOAuth: { conn1: 'v1' } }) + expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) + }) + + it('returns the target byte-for-byte unchanged when neither side has any shared key (no-op merge)', () => { + // Why: a no-op reformat (e.g. dropped trailing newline) reads as an external refresh downstream. + const target = `${JSON.stringify({ + claudeAiOauth: { accessToken: 'target-token', refreshToken: 'target-refresh' } + })}\n` + const live = JSON.stringify({ claudeAiOauth: { accessToken: 'live-token' } }) + expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) + }) + + it('covers every documented shared key, not just mcpOAuth', () => { + // Why: pins the actual key names, so removing one from the constant fails this test. + expect(SHARED_CLAUDE_CREDENTIAL_KEYS).toEqual([ + 'mcpOAuth', + 'mcpOAuthClientConfig', + 'mcpXaaIdp', + 'mcpXaaIdpConfig', + 'pluginSecrets' + ]) + const target = JSON.stringify({ claudeAiOauth: {} }) + const liveObj: Record = { claudeAiOauth: {} } + for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { + liveObj[key] = { present: true } + } + const result = JSON.parse(mergeSharedClaudeCredentialFields(target, JSON.stringify(liveObj))) + for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { + expect(result[key]).toEqual({ present: true }) + } + }) + + it('returns the target byte-for-byte unchanged when an existing shared key keeps its value (interleaved order)', () => { + // Why: rebuilding via key-order iteration used to move an existing shared key to the + // end even when its value did not change, causing a spurious formatting-only rewrite. + const target = `${JSON.stringify({ + claudeAiOauth: { accessToken: 'target-token' }, + mcpOAuth: { conn1: 'v1' }, + trustedDeviceToken: 'target-device-token' + })}\n` + const live = JSON.stringify({ + claudeAiOauth: { accessToken: 'live-token' }, + mcpOAuth: { conn1: 'v1' } + }) + expect(mergeSharedClaudeCredentialFields(target, live)).toBe(target) + }) + + it('still updates an interleaved shared key in place when its live value actually differs', () => { + const target = JSON.stringify({ + claudeAiOauth: { accessToken: 'target-token' }, + mcpOAuth: { conn1: 'stale' }, + trustedDeviceToken: 'target-device-token' + }) + const live = JSON.stringify({ + claudeAiOauth: { accessToken: 'live-token' }, + mcpOAuth: { conn1: 'fresh' } + }) + const result = JSON.parse(mergeSharedClaudeCredentialFields(target, live)) + expect(result.mcpOAuth).toEqual({ conn1: 'fresh' }) + expect(result.trustedDeviceToken).toBe('target-device-token') + }) +}) diff --git a/src/main/claude-accounts/shared-credential-fields.ts b/src/main/claude-accounts/shared-credential-fields.ts new file mode 100644 index 00000000000..e0014f25c4b --- /dev/null +++ b/src/main/claude-accounts/shared-credential-fields.ts @@ -0,0 +1,166 @@ +import { isDeepStrictEqual } from 'node:util' + +export const SHARED_CLAUDE_CREDENTIAL_KEYS = [ + 'mcpOAuth', + 'mcpOAuthClientConfig', + 'mcpXaaIdp', + 'mcpXaaIdpConfig', + 'pluginSecrets' +] as const + +function parseCredentialObject(credentialsJson: string | null): Record | null { + if (!credentialsJson) { + return null + } + let parsed: unknown + try { + parsed = JSON.parse(credentialsJson) + } catch { + return null + } + return isCredentialObject(parsed) ? parsed : null +} + +function isCredentialObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function stripSharedClaudeCredentialFields(credentialsJson: string): string { + const credential = parseCredentialObject(credentialsJson) + if (!credential) { + return credentialsJson + } + let changed = false + for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { + if (Object.hasOwn(credential, key)) { + delete credential[key] + changed = true + } + } + return changed ? JSON.stringify(credential) : credentialsJson +} + +// Shared connector state follows the live runtime, including revocations, rather than frozen account snapshots. +export function mergeSharedClaudeCredentialFields( + targetCredentialsJson: string, + liveCredentialsJson: string | null +): string { + const target = parseCredentialObject(targetCredentialsJson) + const live = parseCredentialObject(liveCredentialsJson) + if ( + !target || + !live || + (Object.hasOwn(target, 'claudeAiOauth') && !isCredentialObject(target.claudeAiOauth)) + ) { + return targetCredentialsJson + } + + let changed = false + const merged: Record = { ...target } + for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { + const targetHasKey = Object.hasOwn(target, key) + if (Object.hasOwn(live, key)) { + if (!targetHasKey || JSON.stringify(live[key]) !== JSON.stringify(target[key])) { + merged[key] = live[key] + changed = true + } + } else if (targetHasKey) { + delete merged[key] + changed = true + } + } + return changed ? JSON.stringify(merged) : targetCredentialsJson +} + +type CredentialField = { present: boolean; value: unknown } + +function credentialField(record: Record, key: string): CredentialField { + const present = Object.hasOwn(record, key) + return { present, value: present ? record[key] : undefined } +} + +function resolveCredentialField( + candidates: CredentialField[], + baseline: CredentialField | null +): CredentialField { + const changes = candidates.filter((candidate) => + baseline === null ? candidate.present : !isDeepStrictEqual(candidate, baseline) + ) + const first = changes[0] + if (first && changes.some((candidate) => !isDeepStrictEqual(candidate, first))) { + throw new Error( + 'Cannot switch Claude accounts: live connector credentials conflict; existing authorizations were preserved' + ) + } + return first ?? baseline ?? { present: false, value: undefined } +} + +function resolveServerGrants( + sources: Record[], + baseline: Record | null, + key: string +): CredentialField | null { + const fields = sources.map((source) => credentialField(source, key)) + const previous = baseline === null ? null : credentialField(baseline, key) + if ( + fields.some((field) => field.present && !isCredentialObject(field.value)) || + (previous?.present && !isCredentialObject(previous.value)) + ) { + return null + } + const maps = fields.map((field) => (isCredentialObject(field.value) ? field.value : {})) + const previousMap = + previous === null ? null : isCredentialObject(previous.value) ? previous.value : {} + const names = new Set([ + ...maps.flatMap((map) => Object.keys(map)), + ...Object.keys(previousMap ?? {}) + ]) + const entries: [string, unknown][] = [] + for (const name of names) { + // Keep a server's access/refresh token pair atomic while combining independent server updates. + const grant = resolveCredentialField( + maps.map((map) => credentialField(map, name)), + previousMap === null ? null : credentialField(previousMap, name) + ) + if (grant.present) { + entries.push([name, grant.value]) + } + } + const present = + entries.length > 0 || + (fields.some((field) => field.present) && + !(baseline !== null && fields.some((field) => !field.present))) + return { present, value: present ? Object.fromEntries(entries) : undefined } +} + +export function reconcileSharedClaudeCredentialFields( + liveCredentials: string[], + lastWrittenCredentials: string | null +): string { + const sources = liveCredentials.map((credentials) => { + const parsed = parseCredentialObject(credentials) + if (!parsed) { + throw new Error('Cannot preserve malformed Claude runtime credentials') + } + return parsed + }) + if (sources.length === 0) { + return '{}' + } + const baseline = parseCredentialObject(lastWrittenCredentials) + const entries: [string, unknown][] = [] + for (const key of SHARED_CLAUDE_CREDENTIAL_KEYS) { + const field = + (key === 'mcpOAuth' || key === 'mcpOAuthClientConfig' + ? resolveServerGrants(sources, baseline, key) + : null) ?? + resolveCredentialField( + sources.map((source) => credentialField(source, key)), + baseline === null ? null : credentialField(baseline, key) + ) + if (field.present) { + entries.push([key, field.value]) + } + } + return JSON.stringify(Object.fromEntries(entries)) +} diff --git a/src/main/claude-accounts/shared-credential-reconciliation.test.ts b/src/main/claude-accounts/shared-credential-reconciliation.test.ts new file mode 100644 index 00000000000..f772b5634c9 --- /dev/null +++ b/src/main/claude-accounts/shared-credential-reconciliation.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { reconcileSharedClaudeCredentialFields } from './shared-credential-fields' + +const original = { accessToken: 'access', refreshToken: 'refresh' } +const rotated = { accessToken: 'rotated-access', refreshToken: 'rotated-refresh' } +const baseline = JSON.stringify({ + mcpOAuth: { figma: original }, + pluginSecrets: { plugin: 'secret' } +}) + +describe('live Claude connector reconciliation', () => { + it('combines independent server grants on startup without importing account identity', () => { + const sources = [ + JSON.stringify({ claudeAiOauth: { accessToken: 'account' }, mcpOAuth: { figma: original } }), + JSON.stringify({ + mcpOAuth: { notion: rotated }, + mcpOAuthClientConfig: { notion: { clientId: 'client' } } + }), + '{}' + ] + const expected = { + mcpOAuth: { figma: original, notion: rotated }, + mcpOAuthClientConfig: { notion: { clientId: 'client' } } + } + expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources, null))).toEqual(expected) + expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources.toReversed(), null))).toEqual( + expected + ) + }) + + it('combines independent rotations and additions against the previous write', () => { + const sources = [ + JSON.stringify({ mcpOAuth: { figma: rotated }, pluginSecrets: { plugin: 'secret' } }), + JSON.stringify({ + mcpOAuth: { figma: original, notion: original }, + pluginSecrets: { plugin: 'new-secret' } + }), + baseline + ] + const expected = { + mcpOAuth: { figma: rotated, notion: original }, + pluginSecrets: { plugin: 'new-secret' } + } + expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources, baseline))).toEqual(expected) + expect( + JSON.parse(reconcileSharedClaudeCredentialFields(sources.toReversed(), baseline)) + ).toEqual(expected) + }) + + it('keeps a revocation while another store adds an unrelated server', () => { + const sources = [ + JSON.stringify({ mcpOAuth: {}, pluginSecrets: { plugin: 'secret' } }), + JSON.stringify({ + mcpOAuth: { figma: original, notion: rotated }, + pluginSecrets: { plugin: 'secret' } + }), + baseline + ] + expect(JSON.parse(reconcileSharedClaudeCredentialFields(sources, baseline))).toEqual({ + mcpOAuth: { notion: rotated }, + pluginSecrets: { plugin: 'secret' } + }) + }) + + it.each([null, baseline])( + 'rejects conflicting server token pairs with baseline %s', + (previous) => { + const sources = [ + JSON.stringify({ mcpOAuth: { figma: rotated } }), + JSON.stringify({ + mcpOAuth: { figma: { accessToken: 'other-access', refreshToken: 'other-refresh' } } + }) + ] + expect(() => reconcileSharedClaudeCredentialFields(sources, previous)).toThrow( + 'live connector credentials conflict' + ) + expect(() => reconcileSharedClaudeCredentialFields(sources.toReversed(), previous)).toThrow( + 'live connector credentials conflict' + ) + } + ) + + it('does not recombine access and refresh tokens from different writes', () => { + const sources = [ + JSON.stringify({ mcpOAuth: { figma: { ...original, accessToken: 'new-access' } } }), + JSON.stringify({ mcpOAuth: { figma: { ...original, refreshToken: 'new-refresh' } } }) + ] + expect(() => reconcileSharedClaudeCredentialFields(sources, baseline)).toThrow( + 'live connector credentials conflict' + ) + }) + + it('does not infer MCP freshness from Claude account-token expiry', () => { + const sources = [ + JSON.stringify({ claudeAiOauth: { expiresAt: 1 }, mcpOAuth: { figma: original } }), + JSON.stringify({ claudeAiOauth: { expiresAt: 9999999999999 }, mcpOAuth: { figma: rotated } }) + ] + expect(() => reconcileSharedClaudeCredentialFields(sources, null)).toThrow( + 'live connector credentials conflict' + ) + }) + + it('rejects conflicting non-server fields instead of combining their secrets', () => { + expect(() => + reconcileSharedClaudeCredentialFields( + [ + JSON.stringify({ pluginSecrets: { plugin: 'one' } }), + JSON.stringify({ pluginSecrets: { plugin: 'two' } }) + ], + null + ) + ).toThrow('live connector credentials conflict') + }) + + it('does not resurrect the last-written grants when every live store is missing', () => { + expect(reconcileSharedClaudeCredentialFields([], baseline)).toBe('{}') + }) + + it('treats an explicit null as an authoritative field removal after a known write', () => { + expect( + JSON.parse( + reconcileSharedClaudeCredentialFields( + [JSON.stringify({ mcpOAuth: null, pluginSecrets: { plugin: 'secret' } }), baseline], + baseline + ) + ) + ).toEqual({ mcpOAuth: null, pluginSecrets: { plugin: 'secret' } }) + }) +}) diff --git a/src/main/ipc/preflight-host-cli-status.test.ts b/src/main/ipc/preflight-host-cli-status.test.ts index b68c3e8afb5..7f6448a9bc3 100644 --- a/src/main/ipc/preflight-host-cli-status.test.ts +++ b/src/main/ipc/preflight-host-cli-status.test.ts @@ -129,6 +129,7 @@ describe('preflight', () => { }) afterEach(() => { + vi.restoreAllMocks() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform @@ -585,6 +586,7 @@ describe('preflight', () => { }) it('uses the persisted Windows Path when probing host CLIs', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_000) Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' From a259616f7c8b32cd299453e7cc0d1de17d272b97 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:04:48 +0000 Subject: [PATCH 02/48] feat(pi): show Pi and OMP background children as rows (#23201) * feat(pi): show Pi and OMP background children as rows Refs #22868 * fix(pi): derive the extension's roster cap from the host's The generated extension re-typed the 32-row limit as its own literal, so a change to AGENT_STATUS_MAX_SUBAGENTS would leave the extension posting rosters the host silently truncates on arrival. Interpolate the host constant instead, and cover the cap with a test that posts past it. * test(pi): pin the Pi cancel verdict beside a live child row Publishing a subagent roster for Pi puts its panes behind the same child-work guard Claude and Codex sit behind, so Ctrl+C beside a live child no longer settles a stopped row. The extension reports no main-agent state, so Orca cannot tell a cancelled turn from Ctrl+C at the idle prompt of a lead children alone hold open, and keeps the live row. Both halves of that are now pinned, as is the resume-placeholder guard on the model_select arm of the same code path. * fix(pi): end a run with its session and keep children across reload Pi re-runs the extension on a fresh event bus for /new, resume, fork and /reload, so the roster kept on that bus was lost and nothing ended the run the old session's children held open. * fix(pi): preserve pending completion and OMP pane ownership * test(pi): use the hook owner context type * fix(pi): retain completion timers and delivery across session boundaries * test(omp): preserve unsent completion at session boundaries * test(journal): retain provider handle import across main integration * test(startup): account for encoded Windows prompt lines * fix(pi): serialize status delivery across module reloads * test(compatibility): retain the released journal parser dependency --------- Co-authored-by: Neil Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- .../server-pi-normalization.test.ts | 184 ++++- .../agent-status-completion-delivery.test.ts | 59 +- ...t-status-extension-async-subagents.test.ts | 302 +++++-- ...ent-status-extension-omp-lifecycle.test.ts | 9 +- ...nt-status-extension-session-change.test.ts | 768 ++++++++++++++++++ src/main/pi/agent-status-extension-source.ts | 38 +- .../pi/agent-status-extension-test-harness.ts | 105 ++- src/main/pi/agent-status-handler-source.ts | 77 +- src/main/pi/agent-status-post-queue-source.ts | 100 ++- .../pi/agent-status-reload-delivery.test.ts | 87 ++ .../agent-status-session-boundary-source.ts | 110 +++ .../agent-status-subagent-event-fixtures.ts | 84 ++ .../pi/agent-status-subagent-roster-source.ts | 150 +++- src/main/pi/agent-status-ui-prompt-source.ts | 13 +- .../providers/pi-family-events.ts | 35 +- tests/tools/omp-completion-runtime-smoke.mjs | 57 +- 16 files changed, 1954 insertions(+), 224 deletions(-) create mode 100644 src/main/pi/agent-status-extension-session-change.test.ts create mode 100644 src/main/pi/agent-status-reload-delivery.test.ts create mode 100644 src/main/pi/agent-status-session-boundary-source.ts create mode 100644 src/main/pi/agent-status-subagent-event-fixtures.ts diff --git a/src/main/agent-hooks/server-pi-normalization.test.ts b/src/main/agent-hooks/server-pi-normalization.test.ts index 6c341f62815..d5e83d69f4f 100644 --- a/src/main/agent-hooks/server-pi-normalization.test.ts +++ b/src/main/agent-hooks/server-pi-normalization.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { agentHookServer, _internals } from './server' -import { buildBody } from './server.test-fixtures' +import { AgentHookServer, agentHookServer, _internals } from './server' +import { buildBody, PANE, postHookEvent } from './server.test-fixtures' const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({ getCohortAtEmitMock: vi.fn(), @@ -279,3 +279,183 @@ describe('Pi hook normalization', () => { expect(result).toBeNull() }) }) + +const SCOUT = { id: 'run-scout', state: 'working', startedAt: 1_000, agentType: 'scout' } +const REVIEWER = { + id: 'run-reviewer', + state: 'working', + startedAt: 2_000, + agentType: 'reviewer', + description: 'Review the diff' +} + +describe('Pi-family child rows through the hook lane', () => { + let server: AgentHookServer + + beforeEach(async () => { + server = new AgentHookServer() + await server.start({ env: 'production' }) + }) + + afterEach(() => { + server.stop() + }) + + async function post(source: 'pi' | 'omp', payload: Record): Promise { + const response = await postHookEvent(server, buildBody(payload), `/hook/${source}`) + expect(response.status).toBe(204) + } + + it.each(['pi', 'omp'] as const)( + 'publishes the %s extension roster as the row subagents', + async (source) => { + await post(source, { + hook_event_name: 'before_agent_start', + prompt: 'fan out', + subagents: [SCOUT, REVIEWER] + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'working', + agentType: source, + prompt: 'fan out', + subagents: [SCOUT, REVIEWER] + }) + ]) + } + ) + + it('restates the last row with the new roster when a child ends between lead events', async () => { + await post('pi', { + hook_event_name: 'before_agent_start', + prompt: 'fan out', + subagents: [SCOUT, REVIEWER] + }) + await post('pi', { hook_event_name: 'tool_execution_start', tool_name: 'bash' }) + await post('pi', { hook_event_name: 'subagents_update', subagents: [REVIEWER] }) + + const [row] = server.getStatusSnapshot() + expect(row).toMatchObject({ + state: 'working', + prompt: 'fan out', + toolName: 'bash', + subagents: [REVIEWER] + }) + + // Why: the roster is a full restatement, so an update without one clears every child row. + await post('pi', { hook_event_name: 'subagents_update' }) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'working', prompt: 'fan out' }) + expect(server.getStatusSnapshot()[0]?.subagents).toBeUndefined() + }) + + it('keeps an update from inventing a row or unhiding a resume placeholder', async () => { + await post('pi', { hook_event_name: 'subagents_update', subagents: [SCOUT] }) + expect(server.getStatusSnapshot()).toEqual([]) + + await post('pi', { + hook_event_name: 'session_start', + session_id: 'pi-session-1', + session_file: '/home/dev/.pi/agent/sessions/pi-session-1.jsonl' + }) + await post('pi', { hook_event_name: 'subagents_update', subagents: [SCOUT] }) + const [placeholder] = server.getStatusSnapshot() + expect(placeholder).toMatchObject({ providerSessionOnly: true, state: 'done' }) + expect(placeholder?.subagents).toBeUndefined() + + // Why: the same guard covers Pi's model_select, which shares this path. + await post('pi', { hook_event_name: 'model_select', model: 'anthropic/claude-opus-5' }) + expect(server.getStatusSnapshot()[0]).toMatchObject({ providerSessionOnly: true }) + expect(server.getStatusSnapshot()[0]?.model).toBeUndefined() + }) + + it('records a run its session ended as a session boundary, not a completion', async () => { + await post('pi', { hook_event_name: 'before_agent_start', prompt: 'fan out' }) + await post('pi', { hook_event_name: 'agent_end', session_boundary: true }) + expect(server.getStatusSnapshot()[0]).toMatchObject({ state: 'done', sessionBoundary: true }) + + await post('pi', { hook_event_name: 'before_agent_start', prompt: 'again' }) + await post('pi', { hook_event_name: 'agent_end' }) + expect(server.getStatusSnapshot()[0]?.sessionBoundary).toBeUndefined() + }) + + it('never lets one agent restate another agent row', async () => { + await post('pi', { hook_event_name: 'before_agent_start', prompt: 'pi turn' }) + await post('omp', { hook_event_name: 'subagents_update', subagents: [SCOUT] }) + + const [row] = server.getStatusSnapshot() + expect(row).toMatchObject({ agentType: 'pi', prompt: 'pi turn' }) + expect(row?.subagents).toBeUndefined() + }) +}) + +// Why: publishing a roster for Pi puts its panes on the same child-work guard Claude and Codex +// already sit behind, which changes what Ctrl+C records. Pinned here so the next change to the +// guard — or to the extension, once it reports a main-agent state of its own — has to face it. +describe('a Pi cancel beside a live child row', () => { + let server: AgentHookServer + + beforeEach(async () => { + server = new AgentHookServer() + await server.start({ env: 'production' }) + }) + + afterEach(() => { + server.stop() + }) + + async function startTurn(subagents?: Record[]): Promise { + const response = await postHookEvent( + server, + buildBody({ + hook_event_name: 'before_agent_start', + prompt: 'fan out', + ...(subagents ? { subagents } : {}) + }), + '/hook/pi' + ) + expect(response.status).toBe(204) + } + + function pressCtrlC(): boolean { + const baseline = server.getStatusSnapshotForPane(PANE)[0] + if (!baseline) { + throw new Error('the pane has no row') + } + return server.inferInterrupt({ + paneKey: PANE, + baselineUpdatedAt: baseline.receivedAt, + baselineStateStartedAt: baseline.stateStartedAt, + baselinePrompt: baseline.prompt, + baselineAgentType: 'pi', + intent: 'ctrl-c' + }) + } + + it('still settles a stopped row when the pane has no children', async () => { + await startTurn() + + expect(pressCtrlC()).toBe(true) + expect(server.getStatusSnapshotForPane(PANE)[0]).toMatchObject({ + state: 'done', + interrupted: true, + mainAgent: { state: 'done', outcome: 'cancellation' } + }) + }) + + // Why: without a main-agent state from the extension, Orca cannot tell a cancelled turn from + // Ctrl+C at the idle prompt of a lead that children alone hold open — where it cancels nothing. + // It keeps the live row rather than claiming a cancellation the children contradict. + it('leaves the working row alone while a child still runs', async () => { + await startTurn([{ id: 'run-scout', state: 'working', startedAt: 1_000, agentType: 'scout' }]) + + expect(pressCtrlC()).toBe(false) + const row = server.getStatusSnapshotForPane(PANE)[0] + expect(row).toMatchObject({ + state: 'working', + subagents: [expect.objectContaining({ id: 'run-scout' })] + }) + expect(row?.interrupted).toBeUndefined() + expect(row?.mainAgent).toBeUndefined() + }) +}) diff --git a/src/main/pi/agent-status-completion-delivery.test.ts b/src/main/pi/agent-status-completion-delivery.test.ts index 4c99563b6ed..0354235c938 100644 --- a/src/main/pi/agent-status-completion-delivery.test.ts +++ b/src/main/pi/agent-status-completion-delivery.test.ts @@ -46,8 +46,8 @@ describe('OMP completion delivery', () => { await vi.advanceTimersByTimeAsync(curlExitCode === null ? 11251 : 251) expect(harness.spawnMock).toHaveBeenCalledTimes(2) await harness.callHook('session_shutdown') - await vi.advanceTimersByTimeAsync(12000) - expect(harness.spawnMock).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(60_000) + expect(harness.spawnMock).toHaveBeenCalledTimes(4) expect(vi.getTimerCount()).toBe(0) }) @@ -87,7 +87,7 @@ describe('OMP completion delivery', () => { expect(vi.getTimerCount()).toBe(0) }) - it.each(['before_agent_start', 'agent_start', 'session_shutdown', 'session_switch'])( + it.each(['before_agent_start', 'agent_start'])( 'retires a failed completion at %s', async (boundary) => { const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) @@ -122,6 +122,59 @@ describe('OMP completion delivery', () => { ]) }) + it.each(['scheduled retry', 'in-flight failure', 'in-flight success'] as const)( + 'preserves completion delivery across a session boundary after %s', + async (delivery) => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + let id = 'A' + const ctx = { + isIdle: () => true, + sessionManager: { getSessionId: () => id, getSessionFile: () => `/sessions/${id}.jsonl` } + } + await harness.callHook('agent_start', {}, ctx) + await vi.advanceTimersByTimeAsync(0) + let acknowledge: ((value: { ok: boolean }) => void) | undefined + let fail: ((error: Error) => void) | undefined + if (delivery === 'scheduled retry') { + harness.fetchMock.mockRejectedValueOnce(new Error('offline')) + } else { + harness.fetchMock.mockImplementationOnce( + () => + new Promise((resolve, reject) => { + acknowledge = resolve + fail = reject + }) + ) + } + await harness.callHook('agent_end', {}, ctx) + await vi.advanceTimersByTimeAsync(0) + id = 'B' + await harness.callHook('session_switch', { reason: 'new' }, ctx) + await harness.callHook('agent_start', {}, ctx) + if (delivery === 'in-flight failure') { + fail?.(new Error('late failure')) + } else { + acknowledge?.({ ok: true }) + } + await vi.advanceTimersByTimeAsync(5_000) + + const payloads = events(harness.fetchMock) + const completions = payloads.filter( + (event) => + event && + typeof event === 'object' && + 'hook_event_name' in event && + event.hook_event_name === 'agent_end' + ) + expect(completions).toHaveLength(delivery === 'in-flight success' ? 1 : 2) + for (const completion of completions) { + expect(completion).toMatchObject({ session_id: 'A' }) + } + expect(payloads.at(-1)).toMatchObject({ hook_event_name: 'agent_start', session_id: 'B' }) + expect(vi.getTimerCount()).toBe(0) + } + ) + it('retries a timed out completion without blocking agent handlers', async () => { const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) harness.fetchMock.mockImplementationOnce(() => new Promise(() => {})) diff --git a/src/main/pi/agent-status-extension-async-subagents.test.ts b/src/main/pi/agent-status-extension-async-subagents.test.ts index 89d5eebcf72..00e375060ea 100644 --- a/src/main/pi/agent-status-extension-async-subagents.test.ts +++ b/src/main/pi/agent-status-extension-async-subagents.test.ts @@ -1,56 +1,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AGENT_STATUS_MAX_SUBAGENTS } from '../../shared/agent-status-types' +import { createAgentStatusExtensionHarness } from './agent-status-extension-test-harness' import { - AGENT_STATUS_EXTENSION_SELF_PID, - createAgentStatusExtensionHarness, - type AgentStatusExtensionHarness -} from './agent-status-extension-test-harness' - -// Event shapes and orderings mirror traces recorded from pi-subagents 0.71.0. -const WORKFLOW = 'workflow-1' -const idle = { isIdle: () => true } - -function postedHookNames(harness: AgentStatusExtensionHarness): string[] { - return harness.fetchMock.mock.calls.map((call) => { - const body: { payload?: { hook_event_name?: unknown } } = JSON.parse(String(call[1]?.body)) - return String(body.payload?.hook_event_name) - }) -} - -function agentEndCount(harness: AgentStatusExtensionHarness): number { - return postedHookNames(harness).filter((name) => name === 'agent_end').length -} - -function startWorkflow(harness: AgentStatusExtensionHarness): void { - harness.emitPiEvent('subagent:async-started', { - id: WORKFLOW, - mode: 'workflow', - pid: AGENT_STATUS_EXTENSION_SELF_PID - }) -} - -function startChild(harness: AgentStatusExtensionHarness, id: string, parent = WORKFLOW): void { - harness.emitPiEvent('subagent:async-started', { - id, - mode: 'single', - pid: 4000, - parentWorkflowRunId: parent - }) -} - -function exitRunner(harness: AgentStatusExtensionHarness, runId: string): void { - harness.emitPiEvent('subagent:process-terminal', { runId, state: 'observed' }) -} - -function complete(harness: AgentStatusExtensionHarness, id: string): void { - harness.emitPiEvent('subagent:async-complete', { id, runId: id, state: 'complete' }) -} - -async function endTurn(harness: AgentStatusExtensionHarness): Promise { - await harness.callHook('agent_end', {}, idle) - await harness.callHook('agent_settled', undefined, idle) - await vi.advanceTimersByTimeAsync(0) -} + agentEndCount, + childIds, + complete, + endTurn, + exitRunner, + postedHookNames, + posts, + startAsync, + startChild, + startWorkflow, + WORKFLOW +} from './agent-status-subagent-event-fixtures' describe('Pi async subagent roster', () => { beforeEach(() => { @@ -190,16 +154,242 @@ describe('Pi async subagent roster', () => { expect(agentEndCount(harness)).toBe(1) }) +}) - it('keeps one runner-exit subscription and the roster across reloads', async () => { +// Roster shapes also mirror OMP 18.3.2's task:subagent:lifecycle. +describe('Pi child rows', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('posts each running pi-subagents child with its agent name, never its redacted task', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'pi' }) + startAsync(harness, 'run-scout', 'scout') + await vi.advanceTimersByTimeAsync(0) + + expect(posts(harness)).toEqual([ + { + hook_event_name: 'agent_start', + subagents: [ + { id: 'run-scout', state: 'working', startedAt: expect.any(Number), agentType: 'scout' } + ] + } + ]) + }) + + it('posts an OMP task child with its description', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + await harness.callHook('session_start') + harness.emitPiEvent('task:subagent:lifecycle', { + id: '0-explore', + agent: 'explore', + description: 'Map the auth module', + detached: true, + status: 'started', + index: 0 + }) + await vi.advanceTimersByTimeAsync(0) + + expect(posts(harness).at(-1)?.subagents).toEqual([ + { + id: '0-explore', + state: 'working', + startedAt: expect.any(Number), + agentType: 'explore', + description: 'Map the auth module' + } + ]) + }) + + it('restates the roster when a child ends mid-turn', async () => { const harness = createAgentStatusExtensionHarness({ kind: 'pi' }) await harness.callHook('agent_start') - startChild(harness, 'child-a', 'tool-call-1') - harness.reload() - expect(harness.piEventListenerCount('subagent:process-terminal')).toBe(1) + startAsync(harness, 'run-a', 'scout') + startAsync(harness, 'run-b', 'reviewer') + await vi.advanceTimersByTimeAsync(0) + complete(harness, 'run-a') + await vi.advanceTimersByTimeAsync(0) - exitRunner(harness, 'child-a') + const last = posts(harness).at(-1) + expect(last?.hook_event_name).toBe('subagents_update') + expect(childIds(last)).toEqual(['run-b']) + }) + + it('restates the roster while the lead waits, then completes once the last child ends', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'pi' }) + await harness.callHook('agent_start') + startAsync(harness, 'run-a', 'scout') + startAsync(harness, 'run-b', 'reviewer') await endTurn(harness) - expect(agentEndCount(harness)).toBe(1) + complete(harness, 'run-a') + await vi.advanceTimersByTimeAsync(0) + expect(posts(harness).at(-1)).toMatchObject({ hook_event_name: 'subagents_update' }) + expect(childIds(posts(harness).at(-1))).toEqual(['run-b']) + + complete(harness, 'run-b') + await vi.advanceTimersByTimeAsync(0) + expect(posts(harness).at(-1)).toEqual({ hook_event_name: 'agent_end' }) + expect( + posts(harness).filter((post) => post.hook_event_name === 'subagents_update') + ).toHaveLength(1) + }) + + it('drops a child as soon as its runner exits, once per exit', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'pi' }) + await harness.callHook('agent_start') + startAsync(harness, 'run-a', 'scout') + startAsync(harness, 'run-b', 'reviewer') + await vi.advanceTimersByTimeAsync(0) + exitRunner(harness, 'run-a') + exitRunner(harness, 'run-a') + await vi.advanceTimersByTimeAsync(0) + + const updates = posts(harness).filter((post) => post.hook_event_name === 'subagents_update') + expect(updates).toHaveLength(1) + expect(childIds(updates[0])).toEqual(['run-b']) + }) + + it('lets a queued post carry a roster change instead of adding an update behind it', async () => { + const releases: (() => void)[] = [] + const harness = createAgentStatusExtensionHarness({ + kind: 'pi', + fetchImpl: () => + new Promise((resolve) => { + releases.push(() => resolve({ ok: true })) + }) + }) + await harness.callHook('agent_start') + startAsync(harness, 'run-a', 'scout') + startAsync(harness, 'run-b', 'reviewer') + complete(harness, 'run-a') + + releases.shift()?.() + await vi.advanceTimersByTimeAsync(0) + expect(posts(harness).map((post) => post.hook_event_name)).toEqual([ + 'agent_start', + 'agent_start' + ]) + expect(childIds(posts(harness)[1])).toEqual(['run-b']) + }) + + // Why: the generated cap is interpolated from the host's, so a drift would show up here + // as an over-long roster the host would silently truncate on arrival. + it('caps the posted roster at the host limit', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'pi' }) + await harness.callHook('agent_start') + for (let index = 0; index < AGENT_STATUS_MAX_SUBAGENTS + 4; index++) { + startAsync(harness, `run-${index}`, 'scout') + } + await vi.advanceTimersByTimeAsync(0) + + expect(childIds(posts(harness).at(-1))).toHaveLength(AGENT_STATUS_MAX_SUBAGENTS) + expect(childIds(posts(harness).at(-1))?.at(-1)).toBe(`run-${AGENT_STATUS_MAX_SUBAGENTS - 1}`) + }) + + it('posts nothing for the end of a run it is not tracking', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'pi' }) + await harness.callHook('agent_start') + complete(harness, 'unknown-run') + await vi.advanceTimersByTimeAsync(0) + + expect(postedHookNames(harness)).toEqual(['agent_start']) + }) + + it('labels a reused child id from its latest start, and keeps a running child’s first label', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + const start = (agent: string) => + harness.emitPiEvent('task:subagent:lifecycle', { id: '0-task', agent, status: 'started' }) + const labels = () => + posts(harness) + .at(-1) + ?.subagents?.map((child) => child.agentType) + await harness.callHook('agent_start') + start('explore') + start('review') + await vi.advanceTimersByTimeAsync(0) + expect(labels()).toEqual(['explore']) + + harness.emitPiEvent('task:subagent:lifecycle', { id: '0-task', status: 'completed' }) + start('review') + await vi.advanceTimersByTimeAsync(0) + expect(labels()).toEqual(['review']) + + await harness.callHook('session_switch', { reason: 'new' }, {}) + start('plan') + await vi.advanceTimersByTimeAsync(0) + expect(labels()).toEqual(['plan']) + }) + + it('posts no subagents field for a pane without children', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'pi' }) + await harness.callHook('before_agent_start', { prompt: 'plain turn' }) + await harness.callHook('tool_execution_start', { toolName: 'bash', args: {} }) + await endTurn(harness) + + expect(posts(harness).some((post) => 'subagents' in post)).toBe(false) + }) + + it('posts one roster update when a runner exit precedes its completion', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'pi' }) + await harness.callHook('agent_start') + startAsync(harness, 'run-a', 'scout') + startAsync(harness, 'run-b', 'reviewer') + await vi.advanceTimersByTimeAsync(0) + exitRunner(harness, 'run-a') + await vi.advanceTimersByTimeAsync(150) + complete(harness, 'run-a') + await vi.advanceTimersByTimeAsync(0) + + const updates = posts(harness).filter((post) => post.hook_event_name === 'subagents_update') + expect(updates.map(childIds)).toEqual([['run-b']]) + }) + + it('leaves a scheduled OMP retry to carry a roster change', async () => { + let attempts = 0 + const harness = createAgentStatusExtensionHarness({ + kind: 'omp', + fetchImpl: async () => { + attempts += 1 + if (attempts === 2) { + throw new Error('Orca restarting') + } + return { ok: true } + } + }) + await harness.callHook('agent_start') + await vi.advanceTimersByTimeAsync(0) + harness.emitPiEvent('task:subagent:lifecycle', { id: 'c1', agent: 'task', status: 'started' }) + await vi.advanceTimersByTimeAsync(0) + harness.emitPiEvent('task:subagent:lifecycle', { id: 'c1', status: 'completed' }) + await vi.advanceTimersByTimeAsync(250) + + expect(posts(harness).map((post) => post.hook_event_name)).toEqual([ + 'agent_start', + 'agent_start', + 'agent_start' + ]) + expect(childIds(posts(harness)[1])).toEqual(['c1']) + expect(posts(harness)[2]?.subagents).toBeUndefined() + }) + + it('keeps a workflow run out of the rows while it still holds the pane', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'pi' }) + await harness.callHook('agent_start') + startWorkflow(harness) + startAsync(harness, 'run-a', 'scout') + await endTurn(harness) + expect(childIds(posts(harness).at(-1))).toEqual(['run-a']) + + complete(harness, 'run-a') + await vi.advanceTimersByTimeAsync(0) + expect(posts(harness).at(-1)).toEqual({ hook_event_name: 'subagents_update' }) + + complete(harness, WORKFLOW) + await vi.advanceTimersByTimeAsync(0) + expect(postedHookNames(harness).slice(-1)).toEqual(['agent_end']) + expect(posts(harness).some((post) => childIds(post)?.includes(WORKFLOW))).toBe(false) }) }) diff --git a/src/main/pi/agent-status-extension-omp-lifecycle.test.ts b/src/main/pi/agent-status-extension-omp-lifecycle.test.ts index 03408a3ba6b..fe410842ba1 100644 --- a/src/main/pi/agent-status-extension-omp-lifecycle.test.ts +++ b/src/main/pi/agent-status-extension-omp-lifecycle.test.ts @@ -44,9 +44,16 @@ describe('OMP agent_end contract', () => { ) }) + it('subscribes once when the factory runs again on the same bus', () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + harness.reload() + expect(harness.piEventListenerCount('task:subagent:lifecycle')).toBe(1) + expect(harness.piEventListenerCount('subagent:process-terminal')).toBe(1) + }) + it('keeps one lifecycle subscription across extension reloads', async () => { const harness = createAgentStatusExtensionHarness({ kind: 'pi' }) - harness.reload() + await harness.reloadPi() expect(harness.piEventListenerCount('task:subagent:lifecycle')).toBe(1) expect(harness.piEventListenerCount('subagent:async-started')).toBe(1) expect(harness.piEventListenerCount('subagent:async-complete')).toBe(1) diff --git a/src/main/pi/agent-status-extension-session-change.test.ts b/src/main/pi/agent-status-extension-session-change.test.ts new file mode 100644 index 00000000000..1122349ae10 --- /dev/null +++ b/src/main/pi/agent-status-extension-session-change.test.ts @@ -0,0 +1,768 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + createAgentStatusExtensionHarness, + type AgentStatusExtensionHarness, + type HookContext +} from './agent-status-extension-test-harness' +import { + agentEndCount, + childIds, + complete, + endTurn, + exitRunner, + idle, + postedHookNames, + posts, + startAsync, + startChild, + startWorkflow, + WORKFLOW +} from './agent-status-subagent-event-fixtures' + +// Orderings mirror runs recorded from Pi 0.87.1 with pi-subagents 0.71.0: a session change or +// /reload shuts the old registration down and runs the factory again on a fresh `pi.events`. +function session(id: string) { + return { + isIdle: () => true, + sessionManager: { getSessionId: () => id, getSessionFile: () => `/sessions/${id}.jsonl` } + } +} + +function createPi(fetchImpl?: () => Promise): AgentStatusExtensionHarness { + return createAgentStatusExtensionHarness({ kind: 'pi', existsSync: () => true, fetchImpl }) +} + +async function holdRunOpen(harness: AgentStatusExtensionHarness, sessionId = 'A'): Promise { + await harness.callHook('session_start', { reason: 'startup' }, session(sessionId)) + await harness.callHook('agent_start', {}, session(sessionId)) + startAsync(harness, 'run-a', 'scout') + await endTurn(harness) +} + +describe('Pi session changes', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it.each([ + ['new', undefined], + ['resume', '/sessions/B.jsonl'], + ['fork', '/sessions/B.jsonl'] + ] as const)( + 'ends the run its children held open under the old session on %s, before the next one starts', + async (reason, target) => { + const harness = createPi() + await holdRunOpen(harness) + expect(agentEndCount(harness)).toBe(0) + + await harness.replacePiSession(reason, target) + await harness.callHook('session_start', { reason }, session('B')) + await vi.advanceTimersByTimeAsync(0) + + expect(posts(harness).slice(-2)).toEqual([ + { + hook_event_name: 'agent_end', + session_id: 'A', + session_file: '/sessions/A.jsonl', + session_boundary: true + }, + { hook_event_name: 'session_start', session_id: 'B', session_file: '/sessions/B.jsonl' } + ]) + } + ) + + it('ends the run for a Pi too old to say why it shut the session down', async () => { + const harness = createPi() + await holdRunOpen(harness) + await harness.callHook('session_shutdown') + await vi.advanceTimersByTimeAsync(0) + + expect(posts(harness).at(-1)).toMatchObject({ hook_event_name: 'agent_end', session_id: 'A' }) + }) + + it('posts nothing on quit, even with children still running', async () => { + const harness = createPi() + await holdRunOpen(harness) + const sent = posts(harness).length + await harness.callHook('session_shutdown', { reason: 'quit' }) + await vi.advanceTimersByTimeAsync(5_000) + + expect(posts(harness)).toHaveLength(sent) + }) + + it('re-opens the run for a child of the next session after a turn the old one cut off', async () => { + const harness = createPi() + await harness.callHook('session_start', { reason: 'startup' }, session('A')) + await harness.callHook('agent_start', {}, session('A')) + await harness.replacePiSession('new') + await harness.callHook('session_start', { reason: 'new' }, session('B')) + startAsync(harness, 'run-b', 'scout') + complete(harness, 'run-b') + await vi.advanceTimersByTimeAsync(0) + + expect(posts(harness).at(-1)).toMatchObject({ hook_event_name: 'agent_end', session_id: 'B' }) + expect(posts(harness).at(-1)).not.toHaveProperty('session_boundary') + }) + + it('keeps a completion that was still waiting to be sent when the session changed', async () => { + const releases: (() => void)[] = [] + const harness = createPi( + () => + new Promise((resolve) => { + releases.push(() => resolve({ ok: true })) + }) + ) + await harness.callHook('session_start', { reason: 'startup' }, session('A')) + await harness.callHook('agent_start', {}, session('A')) + // Pi aborts the turn before it shuts the session down; that completion queues behind the post in flight. + await endTurn(harness) + await harness.replacePiSession('new') + await harness.callHook('session_start', { reason: 'new' }, session('B')) + + while (releases.length > 0) { + releases.shift()?.() + await vi.advanceTimersByTimeAsync(0) + } + expect(posts(harness).slice(-2)).toMatchObject([ + { hook_event_name: 'agent_end', session_id: 'A' }, + { hook_event_name: 'session_start', session_id: 'B' } + ]) + // A turn that really finished is a completion, not a session boundary. + expect(posts(harness).at(-2)).not.toHaveProperty('session_boundary') + }) + + it('gives up on a completion Orca keeps refusing, then sends what came after', async () => { + let attempts = 0 + const harness = createPi(async () => { + attempts += 1 + if (attempts >= 3 && attempts <= 6) { + throw new Error('Orca down') + } + return { ok: true } + }) + await holdRunOpen(harness) + await harness.replacePiSession('new') + await harness.callHook('session_start', { reason: 'new' }, session('B')) + await vi.advanceTimersByTimeAsync(5_000) + + expect(agentEndCount(harness)).toBe(4) + expect(posts(harness).at(-1)).toMatchObject({ + hook_event_name: 'session_start', + session_id: 'B' + }) + }) + + it('posts nothing for an old session whose run already ended', async () => { + const harness = createPi() + await harness.callHook('agent_start', {}, session('A')) + await endTurn(harness) + expect(agentEndCount(harness)).toBe(1) + + await harness.replacePiSession('fork', '/sessions/B.jsonl') + await vi.advanceTimersByTimeAsync(0) + + expect(agentEndCount(harness)).toBe(1) + }) + + it('delivers the old session’s completion ahead of a new session posted behind an in-flight delivery', async () => { + const releases: (() => void)[] = [] + const harness = createPi( + () => + new Promise((resolve) => { + releases.push(() => resolve({ ok: true })) + }) + ) + await holdRunOpen(harness) + await harness.replacePiSession('new') + await harness.callHook('session_start', { reason: 'new' }, session('B')) + // A child of the new session must not ride on the old session's last post. + startAsync(harness, 'run-b', 'scout') + + while (releases.length > 0) { + releases.shift()?.() + await vi.advanceTimersByTimeAsync(0) + } + const completion = posts(harness).find((post) => post.hook_event_name === 'agent_end') + expect(completion).toMatchObject({ session_id: 'A' }) + expect(completion?.subagents).toBeUndefined() + expect(postedHookNames(harness).slice(-2)).toEqual(['agent_end', 'agent_start']) + expect(posts(harness).at(-1)).toMatchObject({ session_id: 'B' }) + expect(childIds(posts(harness).at(-1))).toEqual(['run-b']) + }) + + it('retries the old session’s completion before sending anything newer', async () => { + let attempts = 0 + const harness = createPi(async () => { + attempts += 1 + // The close-out follows session_start and agent_start; fail its first delivery. + if (attempts === 3) { + throw new Error('Orca restarting') + } + return { ok: true } + }) + await holdRunOpen(harness) + await harness.replacePiSession('new') + await harness.callHook('session_start', { reason: 'new' }, session('B')) + await vi.advanceTimersByTimeAsync(0) + // The new session's post waits out the backoff behind the old session's completion. + expect(postedHookNames(harness).at(-1)).toBe('agent_end') + expect(agentEndCount(harness)).toBe(1) + await vi.advanceTimersByTimeAsync(250) + + expect(posts(harness).slice(-3)).toMatchObject([ + { hook_event_name: 'agent_end', session_id: 'A' }, + { hook_event_name: 'agent_end', session_id: 'A' }, + { hook_event_name: 'session_start', session_id: 'B' } + ]) + }) + + it('stops listening once the old session is closed out', async () => { + const harness = createPi() + await holdRunOpen(harness) + await harness.callHook('session_shutdown', { reason: 'new' }) + await vi.advanceTimersByTimeAsync(0) + const sent = posts(harness).length + + // Another extension's shutdown handler can still be running while pi-subagents emits here. + startAsync(harness, 'run-late', 'scout') + await vi.advanceTimersByTimeAsync(0) + expect(posts(harness)).toHaveLength(sent) + + await harness.replacePiSession('new') + await harness.callHook('agent_start', {}, session('B')) + await endTurn(harness) + expect(posts(harness).at(-1)).toMatchObject({ hook_event_name: 'agent_end' }) + expect( + posts(harness) + .slice(sent) + .some((post) => post.subagents) + ).toBe(false) + }) + + it('leaves no timer of the old session running', async () => { + const harness = createPi() + await holdRunOpen(harness) + exitRunner(harness, 'run-a') + await vi.advanceTimersByTimeAsync(0) + expect(vi.getTimerCount()).toBe(1) + + await harness.replacePiSession('new') + await vi.advanceTimersByTimeAsync(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not bring back a child whose runner had already exited', async () => { + const harness = createPi() + await holdRunOpen(harness) + exitRunner(harness, 'run-a') + await harness.replacePiSession('new') + await harness.callHook('session_start', { reason: 'new' }, session('B')) + await harness.replacePiSession('resume', '/sessions/A.jsonl') + await harness.callHook('session_start', { reason: 'resume' }, session('A')) + await vi.advanceTimersByTimeAsync(0) + + expect(posts(harness).at(-1)).toMatchObject({ + hook_event_name: 'session_start', + session_id: 'A' + }) + }) + + it('brings a session’s children back when it is resumed, and ends the run when they finish', async () => { + const harness = createPi() + await holdRunOpen(harness) + await harness.replacePiSession('new') + await harness.callHook('session_start', { reason: 'new' }, session('B')) + await harness.replacePiSession('resume', '/sessions/A.jsonl') + await harness.callHook('session_start', { reason: 'resume' }, session('A')) + await vi.advanceTimersByTimeAsync(0) + expect(posts(harness).at(-1)).toMatchObject({ hook_event_name: 'agent_start', session_id: 'A' }) + expect(posts(harness).at(-1)?.subagents).toEqual([ + { id: 'run-a', state: 'working', startedAt: expect.any(Number), agentType: 'scout' } + ]) + + // pi-subagents reports the run's completion to the resumed session, finished or not. + complete(harness, 'run-a') + await vi.advanceTimersByTimeAsync(0) + expect(posts(harness).at(-1)).toEqual({ + hook_event_name: 'agent_end', + session_id: 'A', + session_file: '/sessions/A.jsonl' + }) + }) + + it('restores a session’s children once, not on every later resume', async () => { + const harness = createPi() + await holdRunOpen(harness) + await harness.replacePiSession('new') + await harness.callHook('session_start', { reason: 'new' }, session('B')) + await harness.replacePiSession('resume', '/sessions/A.jsonl') + await harness.callHook('session_start', { reason: 'resume' }, session('A')) + complete(harness, 'run-a') + await vi.advanceTimersByTimeAsync(0) + await harness.replacePiSession('new') + await harness.callHook('session_start', { reason: 'new' }, session('B')) + await harness.replacePiSession('resume', '/sessions/A.jsonl') + await harness.callHook('session_start', { reason: 'resume' }, session('A')) + await vi.advanceTimersByTimeAsync(0) + + expect(posts(harness).at(-1)).toMatchObject({ + hook_event_name: 'session_start', + session_id: 'A' + }) + expect(posts(harness).at(-1)?.subagents).toBeUndefined() + }) + + it('keeps the children across a resume into the same session file', async () => { + const harness = createPi() + await holdRunOpen(harness) + await harness.replacePiSession('resume', '/sessions/A.jsonl') + await harness.callHook('session_start', { reason: 'resume' }, session('A')) + await vi.advanceTimersByTimeAsync(0) + expect(agentEndCount(harness)).toBe(0) + + complete(harness, 'run-a') + await vi.advanceTimersByTimeAsync(0) + expect(agentEndCount(harness)).toBe(1) + }) +}) + +describe('Pi /reload', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('keeps the children and the hold across a reload', async () => { + const harness = createPi() + await holdRunOpen(harness) + await harness.reloadPi() + await harness.callHook('session_start', { reason: 'reload' }, session('A')) + expect(harness.piEventListenerCount('subagent:async-complete')).toBe(1) + expect(harness.piEventListenerCount('subagent:process-terminal')).toBe(1) + + // A turn that ends while the pre-reload child still runs must not report done. + await harness.callHook('agent_start', {}, session('A')) + await vi.advanceTimersByTimeAsync(0) + expect(childIds(posts(harness).at(-1))).toEqual(['run-a']) + await endTurn(harness) + expect(agentEndCount(harness)).toBe(0) + + complete(harness, 'run-a') + await vi.advanceTimersByTimeAsync(0) + expect(postedHookNames(harness).at(-1)).toBe('agent_end') + expect(agentEndCount(harness)).toBe(1) + }) + + it('keeps the turn counters across a reload, so a child starting afterwards is not read as late', async () => { + const harness = createPi() + await harness.callHook('agent_start', {}, session('A')) + await harness.reloadPi() + startAsync(harness, 'run-a', 'scout') + complete(harness, 'run-a') + await vi.advanceTimersByTimeAsync(0) + expect(agentEndCount(harness)).toBe(0) + + await endTurn(harness) + expect(agentEndCount(harness)).toBe(1) + }) + + it('releases a workflow’s pre-reload children when the workflow ends', async () => { + const harness = createPi() + await harness.callHook('agent_start', {}, session('A')) + startWorkflow(harness) + startChild(harness, 'child-a') + await endTurn(harness) + // Pi drops the old registration's runner-exit events, so child-a never reports its end. + await harness.reloadPi() + complete(harness, WORKFLOW) + await vi.advanceTimersByTimeAsync(0) + + expect(agentEndCount(harness)).toBe(1) + expect(posts(harness).at(-1)?.subagents).toBeUndefined() + }) + + it('drops the rows of pre-reload children released while the turn is still running', async () => { + const harness = createPi() + await harness.callHook('agent_start', {}, session('A')) + startWorkflow(harness) + startChild(harness, 'child-a') + await harness.reloadPi() + complete(harness, WORKFLOW) + await vi.advanceTimersByTimeAsync(0) + + expect(posts(harness).at(-1)).toEqual({ hook_event_name: 'subagents_update' }) + }) + + it.each(['reload', 'resume'] as const)( + 'settles an exited runner after same-session %s without another turn', + async (reason) => { + const harness = createPi() + await harness.callHook('session_start', {}, session('A')) + await harness.callHook('agent_start', {}, session('A')) + startChild(harness, 'child-a', 'tool-call-1') + await endTurn(harness) + exitRunner(harness, 'child-a') + await vi.advanceTimersByTimeAsync(1_000) + await (reason === 'reload' + ? harness.reloadPi() + : harness.replacePiSession('resume', '/sessions/A.jsonl')) + await harness.callHook('session_start', { reason }, session('A')) + await vi.advanceTimersByTimeAsync(999) + expect(agentEndCount(harness)).toBe(0) + await vi.advanceTimersByTimeAsync(1) + + expect(agentEndCount(harness)).toBe(1) + expect(posts(harness).at(-1)).toMatchObject({ hook_event_name: 'agent_end', session_id: 'A' }) + expect(posts(harness).at(-1)?.subagents).toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + } + ) + + it.each(['new', 'quit'] as const)( + 'clears runner grace when the session ends on %s', + async (reason) => { + const harness = createPi() + await holdRunOpen(harness) + exitRunner(harness, 'run-a') + await vi.advanceTimersByTimeAsync(0) + expect(vi.getTimerCount()).toBe(1) + await harness.callHook('session_shutdown', { reason }) + await vi.advanceTimersByTimeAsync(0) + const completionCount = agentEndCount(harness) + await vi.advanceTimersByTimeAsync(5_000) + + expect(agentEndCount(harness)).toBe(completionCount) + expect(vi.getTimerCount()).toBe(0) + } + ) +}) + +describe('children that start outside a turn', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('re-opens a finished OMP run for a late child and ends it when the child does', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + await harness.callHook('agent_start') + await harness.callHook('agent_end', {}) + await vi.advanceTimersByTimeAsync(0) + harness.emitPiEvent('task:subagent:lifecycle', { id: 'w1', agent: 'task', status: 'started' }) + await vi.advanceTimersByTimeAsync(0) + harness.emitPiEvent('task:subagent:lifecycle', { id: 'w1', status: 'completed' }) + await vi.advanceTimersByTimeAsync(0) + + expect(postedHookNames(harness)).toEqual([ + 'agent_start', + 'agent_end', + 'agent_start', + 'agent_end' + ]) + }) + + it('ends a run for a child that started before any turn in this session', async () => { + const harness = createPi() + startAsync(harness, 'run-a', 'scout') + await vi.advanceTimersByTimeAsync(0) + complete(harness, 'run-a') + await vi.advanceTimersByTimeAsync(0) + + expect(postedHookNames(harness)).toEqual(['agent_start', 'agent_end']) + }) + + it('keeps working when a dialog closes while a child runs, whether or not the turn has ended', async () => { + const harness = createPi() + await harness.callHook('agent_start') + startAsync(harness, 'run-a', 'scout') + await harness.callHook('ui_prompt_start', {}) + await harness.callHook('ui_prompt_end', {}, idle) + await vi.advanceTimersByTimeAsync(0) + expect(posts(harness).at(-1)).toMatchObject({ + hook_event_name: 'ui_prompt_end', + is_idle: false + }) + + await endTurn(harness) + await harness.callHook('ui_prompt_start', {}) + await harness.callHook('ui_prompt_end', {}, idle) + await vi.advanceTimersByTimeAsync(0) + expect(posts(harness).at(-1)).toMatchObject({ + hook_event_name: 'ui_prompt_end', + is_idle: false + }) + expect(childIds(posts(harness).at(-1))).toEqual(['run-a']) + + complete(harness, 'run-a') + await vi.advanceTimersByTimeAsync(0) + expect(posts(harness).at(-1)).toEqual({ hook_event_name: 'agent_end' }) + }) +}) + +describe('OMP session switches', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + // OMP keeps one registration and one SessionManager across a switch. + function ompSession() { + let id = 'A' + const context = { + sessionManager: { getSessionId: () => id, getSessionFile: () => `/sessions/${id}.jsonl` } + } + return { context, switchTo: (next: string) => (id = next) } + } + + async function holdOmpRunOpen(harness: AgentStatusExtensionHarness, context: HookContext) { + await harness.callHook('agent_start', {}, context) + harness.emitPiEvent('task:subagent:lifecycle', { id: 'c1', agent: 'task', status: 'started' }) + await harness.callHook('agent_end', {}, context) + await vi.advanceTimersByTimeAsync(0) + } + + it('ends the held run under the session that ran it and forgets its children', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + const { context, switchTo } = ompSession() + await holdOmpRunOpen(harness, context) + expect(agentEndCount(harness)).toBe(0) + + switchTo('B') + await harness.callHook( + 'session_switch', + { reason: 'new', previousSessionFile: '/sessions/A.jsonl' }, + context + ) + await vi.advanceTimersByTimeAsync(0) + expect(posts(harness).at(-1)).toMatchObject({ hook_event_name: 'agent_end', session_id: 'A' }) + expect(posts(harness).at(-1)?.subagents).toBeUndefined() + + await harness.callHook('agent_start', {}, context) + await vi.advanceTimersByTimeAsync(0) + expect(posts(harness).at(-1)).toMatchObject({ hook_event_name: 'agent_start', session_id: 'B' }) + expect(posts(harness).at(-1)?.subagents).toBeUndefined() + }) + + it('ends a turn the switch cut off, under the session that ran it', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + const { context, switchTo } = ompSession() + await harness.callHook('agent_start', {}, context) + switchTo('B') + await harness.callHook( + 'session_switch', + { reason: 'new', previousSessionFile: '/sessions/A.jsonl' }, + context + ) + await vi.advanceTimersByTimeAsync(0) + + expect(posts(harness).at(-1)).toMatchObject({ hook_event_name: 'agent_end', session_id: 'A' }) + }) + + it('keeps the children when OMP reloads the same session', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + const { context } = ompSession() + await holdOmpRunOpen(harness, context) + await harness.callHook( + 'session_switch', + { reason: 'resume', previousSessionFile: '/sessions/A.jsonl' }, + context + ) + await vi.advanceTimersByTimeAsync(0) + expect(agentEndCount(harness)).toBe(0) + + harness.emitPiEvent('task:subagent:lifecycle', { id: 'c1', status: 'completed' }) + await vi.advanceTimersByTimeAsync(0) + expect(posts(harness).at(-1)).toMatchObject({ hook_event_name: 'agent_end', session_id: 'A' }) + }) + + it.each(['fork', 'resume'] as const)( + 'keeps the children on a %s into another session, where OMP leaves them running', + async (reason) => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + const { context, switchTo } = ompSession() + await holdOmpRunOpen(harness, context) + switchTo('B') + await harness.callHook( + 'session_switch', + { reason, previousSessionFile: '/sessions/A.jsonl' }, + context + ) + await vi.advanceTimersByTimeAsync(0) + expect(agentEndCount(harness)).toBe(0) + + harness.emitPiEvent('task:subagent:lifecycle', { id: 'c1', status: 'completed' }) + await vi.advanceTimersByTimeAsync(0) + expect(posts(harness).at(-1)).toMatchObject({ hook_event_name: 'agent_end' }) + } + ) + + it('ends the held run when OMP branches the session', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + const { context, switchTo } = ompSession() + await holdOmpRunOpen(harness, context) + switchTo('B') + await harness.callHook('session_branch', { previousSessionFile: '/sessions/A.jsonl' }, context) + await vi.advanceTimersByTimeAsync(0) + + expect(posts(harness).at(-1)).toMatchObject({ hook_event_name: 'agent_end', session_id: 'A' }) + expect(posts(harness).at(-1)?.subagents).toBeUndefined() + }) + + it('keeps a completion that was still waiting to be sent when OMP starts a new session', async () => { + const releases: (() => void)[] = [] + const harness = createAgentStatusExtensionHarness({ + kind: 'omp', + fetchImpl: () => + new Promise((resolve) => { + releases.push(() => resolve({ ok: true })) + }) + }) + const { context, switchTo } = ompSession() + await harness.callHook('agent_start', {}, context) + await harness.callHook('agent_end', {}, context) + switchTo('B') + await harness.callHook( + 'session_switch', + { reason: 'new', previousSessionFile: '/sessions/A.jsonl' }, + context + ) + await harness.callHook('agent_start', {}, context) + + while (releases.length > 0) { + releases.shift()?.() + await vi.advanceTimersByTimeAsync(0) + } + expect(posts(harness).slice(-2)).toMatchObject([ + { hook_event_name: 'agent_end', session_id: 'A' }, + { hook_event_name: 'agent_start', session_id: 'B' } + ]) + }) + + it.each(['omp', 'pi'] as const)( + 'takes over a roster and its subscriptions that an older build left on the %s bus', + async (kind) => { + const harness = createAgentStatusExtensionHarness({ + kind, + sharedEventBus: true, + seedEventBus: (bus) => { + Object.assign(bus, { + __orcaPiSubagents: { + active: new Set(['old-child']), + waiting: false, + listener: () => {}, + runnerExitListener: () => {} + } + }) + } + }) + expect(harness.piEventListenerCount('task:subagent:lifecycle')).toBe(0) + expect(harness.piEventListenerCount('subagent:process-terminal')).toBe(0) + + await harness.callHook('agent_start') + await vi.advanceTimersByTimeAsync(0) + expect(childIds(posts(harness).at(-1))).toEqual(['old-child']) + } + ) + + it('posts nothing for a resume before any turn has run', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + const { context } = ompSession() + await harness.callHook( + 'session_switch', + { reason: 'resume', previousSessionFile: '/sessions/A.jsonl' }, + context + ) + await vi.advanceTimersByTimeAsync(0) + + expect(posts(harness)).toEqual([]) + }) + + it('keeps the next session’s children off the old session’s last post', async () => { + const releases: (() => void)[] = [] + const harness = createAgentStatusExtensionHarness({ + kind: 'omp', + fetchImpl: () => + new Promise((resolve) => { + releases.push(() => resolve({ ok: true })) + }) + }) + const { context, switchTo } = ompSession() + await holdOmpRunOpen(harness, context) + switchTo('B') + await harness.callHook( + 'session_switch', + { reason: 'new', previousSessionFile: '/sessions/A.jsonl' }, + context + ) + harness.emitPiEvent('task:subagent:lifecycle', { id: 'c2', agent: 'task', status: 'started' }) + + while (releases.length > 0) { + releases.shift()?.() + await vi.advanceTimersByTimeAsync(0) + } + const completion = posts(harness).find((post) => post.hook_event_name === 'agent_end') + expect(completion).toMatchObject({ session_id: 'A' }) + expect(completion?.subagents).toBeUndefined() + expect(childIds(posts(harness).at(-1))).toEqual(['c2']) + }) + + it('ends a turn that a reload of the same session cut off', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + const { context } = ompSession() + await harness.callHook('agent_start', {}, context) + await harness.callHook( + 'session_switch', + { reason: 'resume', previousSessionFile: '/sessions/A.jsonl' }, + context + ) + await vi.advanceTimersByTimeAsync(0) + + expect(posts(harness).at(-1)).toMatchObject({ hook_event_name: 'agent_end', session_id: 'A' }) + + // The turn is over, so a child that starts now re-opens the run. + harness.emitPiEvent('task:subagent:lifecycle', { id: 'w1', agent: 'task', status: 'started' }) + harness.emitPiEvent('task:subagent:lifecycle', { id: 'w1', status: 'completed' }) + await vi.advanceTimersByTimeAsync(0) + expect(postedHookNames(harness)).toEqual([ + 'agent_start', + 'agent_end', + 'agent_start', + 'agent_end' + ]) + }) + + it('keeps describing the lead’s children after a task child registers on its own bus', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + await harness.callHook('session_start') + harness.emitPiEvent('task:subagent:lifecycle', { id: 'c1', agent: 'task', status: 'started' }) + await vi.advanceTimersByTimeAsync(0) + harness.registerTaskChild() + harness.emitPiEvent('task:subagent:lifecycle', { id: 'c2', agent: 'task', status: 'started' }) + await vi.advanceTimersByTimeAsync(0) + + expect(childIds(posts(harness).at(-1))).toEqual(['c1', 'c2']) + }) + + it('ignores a task child’s own subagents, which run on that child’s bus', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + await harness.callHook('agent_start') + harness.emitPiEvent('task:subagent:lifecycle', { id: 'c1', agent: 'task', status: 'started' }) + await vi.advanceTimersByTimeAsync(0) + const emitOnChildBus = harness.registerTaskChild() + emitOnChildBus('task:subagent:lifecycle', { id: 'g1', agent: 'task', status: 'started' }) + emitOnChildBus('task:subagent:lifecycle', { id: 'g1', status: 'completed' }) + await vi.advanceTimersByTimeAsync(0) + + expect(postedHookNames(harness)).toEqual(['agent_start', 'agent_start']) + }) +}) diff --git a/src/main/pi/agent-status-extension-source.ts b/src/main/pi/agent-status-extension-source.ts index 58a18b78fa5..5a3d23f47a7 100644 --- a/src/main/pi/agent-status-extension-source.ts +++ b/src/main/pi/agent-status-extension-source.ts @@ -15,6 +15,7 @@ import type { PiAgentKind } from '../../shared/pi-agent-kind' import { getPiAgentStatusHandlerSourceLines } from './agent-status-handler-source' import { getPiAgentStatusRuntimeDetectionSourceLines } from './agent-status-runtime-detection-source' import { getPiAgentStatusWslCurlSourceLines } from './agent-status-wsl-curl-source' +import { getPiSubagentSnapshotSourceLines } from './agent-status-subagent-roster-source' export const ORCA_PI_AGENT_STATUS_EXTENSION_FILE = 'orca-agent-status.ts' @@ -53,14 +54,14 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin ' return ompRuntime ? { ...runtimeOmpSessionMetadata, ...modelMetadata } : sessionMetadata', '}', '', - 'function getPersistedSessionMetadata(): Record {', - ' const sessionFile = sessionMetadata.session_file', + 'function getPersistedSessionMetadata(metadata: Record): Record {', + ' const sessionFile = metadata.session_file', " if (typeof sessionFile !== 'string' || !sessionFile) return {}", ' try {', " const fs = require('fs')", ' // Why: Pi publishes its planned path before creating the transcript;', ' // recheck on every post so the first completed turn becomes resumable.', - ' return fs.existsSync(sessionFile) ? sessionMetadata : {}', + ' return fs.existsSync(sessionFile) ? metadata : {}', ' } catch {', ' return {}', ' }', @@ -123,8 +124,8 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin // Why: Pi resumes from an existing transcript; OMP resumes directly by session id (#8962). const payloadLine = kind !== 'omp' - ? ' payload: { hook_event_name: hookEventName, ...(ompRuntime ? metadata : getPersistedSessionMetadata()), ...extra },' - : ' payload: { hook_event_name: hookEventName, ...metadata, ...extra },' + ? ' payload: { hook_event_name: hookEventName, ...(ompRuntime ? metadata : getPersistedSessionMetadata(metadata)), ...(final ? {} : subagentPayload()), ...extra },' + : ' payload: { hook_event_name: hookEventName, ...metadata, ...(final ? {} : subagentPayload()), ...extra },' // Why: keep this string self-contained — it runs inside the pi process, // so it cannot import from Orca's main bundle. fs/http coords come from @@ -140,10 +141,11 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin '// critical path, and the latest-only pending slot prevents a stalled', '// Orca receiver from building an unbounded queue of obsolete snapshots.', 'const HOOK_POST_TIMEOUT_MS = 1000', - ...getPiAgentStatusPostQueueSourceLines(), + ...getPiAgentStatusPostQueueSourceLines(kind), ...(kind === 'pi' ? ['let piUiPromptDepth = 0', 'let piTurnInFlight = false'] : []), ...modelMetadataSourceLines, '', + ...getPiSubagentSnapshotSourceLines(), ...sessionMetadataSourceLines, '', '// Why: re-reading the endpoint file on every event is cheap (small file,', @@ -202,14 +204,23 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin '', ...getPiAgentStatusRuntimeDetectionSourceLines(kind), '', - 'function post(hookEventName: string, extra: Record = {}): void {', + // `final` marks the last post of a session that is being closed. + 'function post(hookEventName: string, extra: Record = {}, final = false): void {', ' const ompRuntime = isOmpRuntime()', - ' cancelPostRetry()', + // Why: the body is built at delivery, so the session is pinned here, where the event happened. ' const metadata = getPostSessionMetadata(ompRuntime)', + ' if (final) {', + ' postQueue.finalPosts.push({ revision: postQueue.postRevision, attempts: 0, delivered: false, hookEventName, extra, metadata, ompRuntime, final })', + ' drainPosts()', + ' return', + ' }', + ' cancelPostRetry()', + // Why: a new turn supersedes a same-session completion even when /reload preserved its delivery. + " if (hookEventName === 'before_agent_start' || hookEventName === 'agent_start') retireTurnCompletionPosts(metadata)", '// Model changes must not erase an unacknowledged completion in the latest-only slot.', - " const previousCompletion = latestPost?.hookEventName === 'agent_end' && !latestPost.delivered && latestPost.metadata.session_id === metadata.session_id", - ' pendingPost = {', - ' revision: ++postRevision,', + " const previousCompletion = postQueue.latestPost?.hookEventName === 'agent_end' && !postQueue.latestPost.delivered && postQueue.latestPost.metadata.session_id === metadata.session_id", + ' postQueue.pendingPost = {', + ' revision: ++postQueue.postRevision,', ' attempts: 0,', ' delivered: false,', " hookEventName: ompRuntime && hookEventName === 'model_select' && previousCompletion ? 'agent_end' : hookEventName,", @@ -220,7 +231,7 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin ' metadata,', ' ompRuntime,', ' }', - ' latestPost = pendingPost', + ' postQueue.latestPost = postQueue.pendingPost', ' drainPosts()', '}', '', @@ -228,7 +239,8 @@ export function getPiAgentStatusExtensionSource(kind: PiAgentKind = 'pi'): strin ' hookEventName: string,', ' extra: Record,', ' metadata: Record,', - ' ompRuntime: boolean', + ' ompRuntime: boolean,', + ' final: boolean', '): Promise {', ' const coords = resolveHookCoords()', ' const paneKey = process.env.ORCA_PANE_KEY', diff --git a/src/main/pi/agent-status-extension-test-harness.ts b/src/main/pi/agent-status-extension-test-harness.ts index 0c07caf8256..c9cfe952697 100644 --- a/src/main/pi/agent-status-extension-test-harness.ts +++ b/src/main/pi/agent-status-extension-test-harness.ts @@ -1,5 +1,5 @@ import { EventEmitter } from 'node:events' -import { runInNewContext } from 'node:vm' +import { createContext, runInContext } from 'node:vm' // TypeScript 7 is a native CLI; transpile tests still need the legacy JavaScript API. import ts from 'typescript-api' import { vi } from 'vitest' @@ -19,6 +19,10 @@ export type HookContext = { } } +type PiEventBus = { + on: (name: string, listener: (event: unknown) => void) => unknown +} + export type HookHandler = (event?: unknown, context?: HookContext) => Promise | void type FakeCurlChild = { @@ -48,9 +52,16 @@ export type AgentStatusExtensionHarness = { callHook: (name: string, event?: unknown, context?: HookContext) => Promise emitPiEvent: (name: string, event: unknown) => void piEventListenerCount: (name: string) => number - // Re-invoke the extension factory in the same process (as Pi does on an - // in-process extension reload), swapping in the freshly registered handlers. + // Re-run the factory on the same event bus and module state. reload: () => void + // What Pi does for /new, resume and fork: shut the old registration down, drop its bus + // subscriptions, then run the factory again on a fresh `pi.events` (module state kept). + replacePiSession: (reason: 'new' | 'resume' | 'fork', targetSessionFile?: string) => Promise + // What Pi does for /reload: as above, but the module is evaluated again and `globalThis` survives. + reloadPi: () => Promise + // An OMP in-process task child: the same module's factory, run again on the child's own bus. + // Returns an emitter for events on that child's bus. + registerTaskChild: () => (name: string, event: unknown) => void } const BASE_ENV = { @@ -80,6 +91,10 @@ export function createAgentStatusExtensionHarness(args: { statSync?: (path: string) => { mtimeMs: number; size: number; ino: number } curlExitCode?: number | null fetchImpl?: (...params: Parameters) => Promise + // Runs before the first registration, to leave state an older build would have put on the bus. + seedEventBus?: (bus: EventEmitter) => void + // Pi before 0.84 handed every registration the one shared bus. + sharedEventBus?: boolean }): AgentStatusExtensionHarness { const fetchMock = vi.fn( args.fetchImpl ?? @@ -132,7 +147,7 @@ export function createAgentStatusExtensionHarness(args: { command: { handler: (args: string, context: HookContext) => Promise } ) => void setModel: (model: unknown) => Promise - events?: EventEmitter + events?: PiEventBus }) => void } } = { exports: {} } @@ -186,30 +201,71 @@ export function createAgentStatusExtensionHarness(args: { target: ts.ScriptTarget.ES2020 } }).outputText - runInNewContext(output, context) - - const register = module.exports.default - if (!register) { - throw new Error('expected default export from generated source') + createContext(context) + // Why: a function scope per evaluation, so a Pi reload can evaluate the module again in one realm. + const evaluateModule = (): NonNullable => { + runInContext(`(function () {\n${output}\n})()`, context) + const factory = module.exports.default + if (!factory) { + throw new Error('expected default export from generated source') + } + return factory } + let register = evaluateModule() const handlers: Record = {} - const piEvents = new EventEmitter() + // Why: Pi calls every handler an extension registers for an event, in registration order. + let handlerLists: Record = {} + let piEvents = new EventEmitter() + let busSubscriptions: [string, (event: unknown) => void][] = [] const commands: AgentStatusExtensionHarness['commands'] = {} const setModelMock = vi.fn(async (_model: unknown) => true) - const registerInto = (target: Record): void => { + const registerInto = ( + target: Record, + events: PiEventBus = piEvents + ): void => { + handlerLists = {} register({ registerCommand: (name, command) => { commands[name] = command }, setModel: setModelMock, - events: piEvents, + events, on(name: string, handler: HookHandler) { target[name] = handler + ;(handlerLists[name] ??= []).push(handler) } }) } - registerInto(handlers) + const callHook: AgentStatusExtensionHarness['callHook'] = async (name, event, hookContext) => { + for (const handler of handlerLists[name] ?? []) { + await handler(event, hookContext) + } + } + // Why: each Pi registration gets its own `pi.events` object, and Pi removes its subscriptions when + // the registration is replaced; anything an extension stores on that object goes with it. + const registerLikePi = (): void => { + for (const [name, listener] of busSubscriptions) { + piEvents.off(name, listener) + } + busSubscriptions = [] + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + const bus = piEvents + registerInto(handlers, { + on(name: string, listener: (event: unknown) => void) { + bus.on(name, listener) + busSubscriptions.push([name, listener]) + } + }) + } + args.seedEventBus?.(piEvents) + if (args.kind === 'pi' && !args.sharedEventBus) { + registerLikePi() + } else { + registerInto(handlers) + } return { setModelMock, @@ -221,9 +277,7 @@ export function createAgentStatusExtensionHarness(args: { fsMock, handlers, processEnv: processMock.env, - callHook: async (name, event, hookContext) => { - await handlers[name]?.(event, hookContext) - }, + callHook, emitPiEvent: (name, event) => { piEvents.emit(name, event) }, @@ -233,6 +287,25 @@ export function createAgentStatusExtensionHarness(args: { delete handlers[key] } registerInto(handlers) + }, + replacePiSession: async (reason, targetSessionFile) => { + await callHook('session_shutdown', { reason, targetSessionFile }) + piEvents = new EventEmitter() + registerLikePi() + }, + registerTaskChild: () => { + const leadHandlerLists = handlerLists + const childBus = new EventEmitter() + registerInto({}, childBus) + handlerLists = leadHandlerLists + return (name, event) => { + childBus.emit(name, event) + } + }, + reloadPi: async () => { + await callHook('session_shutdown', { reason: 'reload' }) + register = evaluateModule() + registerLikePi() } } } diff --git a/src/main/pi/agent-status-handler-source.ts b/src/main/pi/agent-status-handler-source.ts index 76eff628731..af2735159fe 100644 --- a/src/main/pi/agent-status-handler-source.ts +++ b/src/main/pi/agent-status-handler-source.ts @@ -8,6 +8,10 @@ import { getPiSubagentRosterEventSourceLines, getPiSubagentRosterSetupSourceLines } from './agent-status-subagent-roster-source' +import { + getAgentStatusRunCloseOutSourceLines, + getAgentStatusSessionBoundaryHandlerSourceLines +} from './agent-status-session-boundary-source' // Why: keep the generated handler registrations separate from hook transport; // both are independently sizeable and the installed extension concatenates them. @@ -22,6 +26,13 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' // turn boundary and must not clear the visible status or unread state.', " if (event.reason === 'reload') return", " post('session_start')", + ...(kind === 'pi' + ? [ + ' restoreParkedSubagents()', + // Why: the host reads session_start as an idle session; children still hold this one. + " if (lifecycleState.waiting) post('agent_start')" + ] + : []), ' })', '' ] @@ -133,27 +144,9 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' if (ownerPid && ownerPid !== selfPid && isStatusOwnerAlive(ownerPid)) return', ` process.env.${ownerEnv} = selfPid`, ' resetPostQueue()', - ...getPiSubagentRosterSetupSourceLines(), - ...(kind !== 'pi' - ? [ - " pi.on('session_shutdown', () => { lifecycleState.active.clear(); lifecycleState.exited?.clear(); lifecycleState.waiting = false; lifecycleState.rootRunInFlight = false; resetPostQueue(); clearPendingAgentEndCheck() })" - ] - : []), - ...(kind !== 'prime-agent' - ? [ - " pi.on('session_switch', (_event, ctx) => {", - ' if (!isOmpRuntime()) return', - ' lifecycleState.active.clear()', - ' lifecycleState.exited?.clear()', - ' lifecycleState.waiting = false', - ' lifecycleState.rootRunInFlight = false', - ' resetPostQueue()', - ' clearPendingAgentEndCheck()', - ' updateRuntimeOmpSessionMetadata(ctx)', - ' })' - ] - : []), + ...getPiSubagentRosterSetupSourceLines(kind), ...getOmpSessionOwnerHandlerSourceLines(), + ...getAgentStatusSessionBoundaryHandlerSourceLines(kind), ...getOmpModelCommandSourceLines(), ...sessionStartHandler, ...(kind === 'omp' ? getPiPrefillHandlerSourceLines('omp', true) : []), @@ -166,8 +159,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ...captureSessionMetadata, ' clearPendingAgentEndCheck()', ' lifecycleState.waiting = false', - ' lifecycleState.rootRunInFlight = true', - ' runGeneration += 1', + ' lifecycleState.runGeneration += 1', // Why: a turn cannot begin under a dialog holding input focus, so this is the one // boundary that can recover a modal whose close never arrived. ...(kind === 'pi' ? [' piUiPromptDepth = 0', ' piTurnInFlight = true'] : []), @@ -219,15 +211,6 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' const AGENT_END_IDLE_RECHECK_MS = 25', ' const AGENT_END_IDLE_RECHECK_MAX_MS = 250', ' let agentSettledSupported = false', - // Why: completion is a per-RUN fact. A sibling extension (the memory reminder is one) - // can start the next run from inside its own agent_settled handler, and Pi dispatches - // handlers in registration order, so this extension sees that run's agent_start - // BEFORE its own agent_settled for the run that just ended. A boolean "already - // posted" latch reset on agent_start then eats the newer run's completion and leaves - // the host stuck on that run's last working event. - ' let runGeneration = 0', - ' let endedRunGeneration = 0', - ' let completionPostedGeneration = -1', ' let agentEndIdleRecheckMs = AGENT_END_IDLE_RECHECK_MS', ' let pendingAgentEndCheck: ReturnType | null = null', ' let pendingAgentEndContext: { isIdle: () => boolean } | null = null', @@ -238,26 +221,37 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' pendingAgentEndContext = null', ' }', ...getPiSubagentRosterEventSourceLines(), - ' function postAgentEndOnce(): void {', - ' for (const id of lifecycleState.exited ?? []) lifecycleState.active.delete(id)', - ' lifecycleState.exited?.clear()', - ' if (lifecycleState.active.size > 0) {', + // The one answer to "do children still hold this pane"; an exited runner holds through its grace. + ' function isHeldByChildren(): boolean {', + ' return lifecycleState.active.size > 0', + ' }', + '', + ' function isTurnInFlight(): boolean {', + ' return lifecycleState.runGeneration !== lifecycleState.endedRunGeneration', + ' }', + '', + ' function postAgentEndOnce(final = false): boolean {', + ' for (const id of lifecycleState.exited ?? []) forgetSubagent(id)', + ' if (isHeldByChildren()) {', ' lifecycleState.waiting = true', - ' return', + ' return false', ' }', ' lifecycleState.waiting = false', - ' if (completionPostedGeneration === endedRunGeneration) return', - ' completionPostedGeneration = endedRunGeneration', + ' if (lifecycleState.completionPostedGeneration === lifecycleState.endedRunGeneration) return false', + ' lifecycleState.completionPostedGeneration = lifecycleState.endedRunGeneration', // Why: distinct from the completion guard, which holds the generation of the posted run // and so starts clean on a pane that has not run a turn yet — that pane is idle, not busy. ...(kind === 'pi' ? [' piTurnInFlight = false'] : []), - " post('agent_end')", + // Why: a run that ends because its session did has not completed; the host records it quietly. + " post('agent_end', final ? { session_boundary: true } : {}, final)", + ' return true', ' }', '', + ...getAgentStatusRunCloseOutSourceLines(), ' function checkPendingAgentEnd(): void {', ' pendingAgentEndCheck = null', ' const ctx = pendingAgentEndContext', - ' if (!ctx || agentSettledSupported || completionPostedGeneration === endedRunGeneration) {', + ' if (!ctx || agentSettledSupported || lifecycleState.completionPostedGeneration === lifecycleState.endedRunGeneration) {', ' pendingAgentEndContext = null', ' return', ' }', @@ -289,8 +283,7 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' clearPendingAgentEndCheck()', ' return', ' }', - ' lifecycleState.rootRunInFlight = false', - ' endedRunGeneration = runGeneration', + ' lifecycleState.endedRunGeneration = lifecycleState.runGeneration', ' if (isOmpRuntime()) {', ' postAgentEndOnce()', ' return', diff --git a/src/main/pi/agent-status-post-queue-source.ts b/src/main/pi/agent-status-post-queue-source.ts index 512ed932339..2775cb75f14 100644 --- a/src/main/pi/agent-status-post-queue-source.ts +++ b/src/main/pi/agent-status-post-queue-source.ts @@ -1,49 +1,97 @@ -export function getPiAgentStatusPostQueueSourceLines(): string[] { +import type { PiAgentKind } from '../../shared/pi-agent-kind' + +export function getPiAgentStatusPostQueueSourceLines(kind: PiAgentKind): string[] { return [ - 'type HookPost = { hookEventName: string; extra: Record; metadata: Record; ompRuntime: boolean; revision: number; attempts: number; delivered: boolean }', - 'let activePost = false', - 'let pendingPost: HookPost | null = null', - 'let latestPost: HookPost | null = null', - '// A newer snapshot or session boundary retires every older retry.', - 'let postRevision = 0', - 'let retryTimer: ReturnType | null = null', + 'type HookPost = { hookEventName: string; extra: Record; metadata: Record; ompRuntime: boolean; revision: number; attempts: number; delivered: boolean; final?: boolean }', + 'type HookPostQueue = { activePost: HookPost | null; pendingPost: HookPost | null; latestPost: HookPost | null; finalPosts: HookPost[]; finalRetryTimer: ReturnType | null; postRevision: number; retryTimer: ReturnType | null }', + ...(kind === 'pi' + ? [ + 'declare global { var __orcaPiStatusPostQueue: HookPostQueue | undefined }', + // Why: old delivery continuations and a reloaded registration must serialize through one queue. + 'const postQueue: HookPostQueue = globalThis.__orcaPiStatusPostQueue ??= { activePost: null, pendingPost: null, latestPost: null, finalPosts: [], finalRetryTimer: null, postRevision: 0, retryTimer: null }' + ] + : [ + 'const postQueue: HookPostQueue = { activePost: null, pendingPost: null, latestPost: null, finalPosts: [], finalRetryTimer: null, postRevision: 0, retryTimer: null }' + ]), '', 'function cancelPostRetry(): void {', - ' if (retryTimer !== null) clearTimeout(retryTimer)', - ' retryTimer = null', + ' if (postQueue.retryTimer !== null) clearTimeout(postQueue.retryTimer)', + ' postQueue.retryTimer = null', + '}', + '', + '// Why: a queued or scheduled post builds its body later, so it already carries newer state.', + 'function hasQueuedPost(): boolean {', + ' return postQueue.pendingPost !== null || postQueue.retryTimer !== null', '}', '', 'function resetPostQueue(): void {', ' cancelPostRetry()', - ' postRevision++', - ' pendingPost = null', - ' latestPost = null', + ' postQueue.postRevision++', + // Why: preserve the original object so an in-flight acknowledgment also retires its final queue entry. + ' for (const post of [postQueue.activePost, postQueue.pendingPost, postQueue.latestPost]) {', + " if (!post || post.hookEventName !== 'agent_end' || post.delivered || post.final) continue", + ' post.final = true', + ' postQueue.finalPosts.push(post)', + ' }', + ' postQueue.pendingPost = null', + ' postQueue.latestPost = null', + ' drainPosts()', + '}', + '', + 'function retireTurnCompletionPosts(metadata: Record): void {', + ' const first = postQueue.finalPosts[0]', + ' for (const post of postQueue.finalPosts) {', + ' if (post.extra.session_boundary !== true && post.metadata.session_id === metadata.session_id) post.final = false', + ' }', + ' postQueue.finalPosts = postQueue.finalPosts.filter((post) => post.final)', + ' if (first && !first.final && postQueue.finalRetryTimer !== null) {', + ' clearTimeout(postQueue.finalRetryTimer)', + ' postQueue.finalRetryTimer = null', + ' }', '}', '', 'function drainPosts(): void {', - ' if (activePost || !pendingPost) return', - ' const next = pendingPost', - ' pendingPost = null', - ' activePost = true', - ' void postOnce(next.hookEventName, next.extra, next.metadata, next.ompRuntime)', - ' .then(() => { next.delivered = true })', + // Why: a final post waiting to retry holds newer posts back, so the old session's row is never written last. + ' if (postQueue.activePost || postQueue.finalRetryTimer !== null) return', + ' const next = postQueue.finalPosts[0] ?? postQueue.pendingPost', + ' if (!next) return', + ' if (!next.final) postQueue.pendingPost = null', + ' postQueue.activePost = next', + ' void postOnce(next.hookEventName, next.extra, next.metadata, next.ompRuntime, next.final === true)', + ' .then(() => {', + ' next.delivered = true', + ' if (next.final) postQueue.finalPosts.shift()', + ' })', ' .catch(() => {', - ' if (!next.ompRuntime || next.revision !== postRevision) return', + ' if (next.final) {', + ' if (next.attempts >= 3) {', + ' postQueue.finalPosts.shift()', + " console.warn('[orca-pi-status] hook delivery failed after retries:', next.hookEventName)", + ' return', + ' }', + ' postQueue.finalRetryTimer = setTimeout(() => {', + ' postQueue.finalRetryTimer = null', + ' drainPosts()', + ' }, 250 * 2 ** next.attempts++)', + " if (typeof postQueue.finalRetryTimer.unref === 'function') postQueue.finalRetryTimer.unref()", + ' return', + ' }', + ' if (!next.ompRuntime || next.revision !== postQueue.postRevision) return', ' if (next.attempts >= 3) {', " console.warn('[orca-pi-status] hook delivery failed after retries:', next.hookEventName)", ' return', ' }', ' const delay = 250 * 2 ** next.attempts++', - ' retryTimer = setTimeout(() => {', - ' retryTimer = null', - ' if (next.revision !== postRevision) return', - ' pendingPost = next', + ' postQueue.retryTimer = setTimeout(() => {', + ' postQueue.retryTimer = null', + ' if (next.revision !== postQueue.postRevision) return', + ' postQueue.pendingPost = next', ' drainPosts()', ' }, delay)', - " if (typeof retryTimer.unref === 'function') retryTimer.unref()", + " if (typeof postQueue.retryTimer.unref === 'function') postQueue.retryTimer.unref()", ' })', ' .finally(() => {', - ' activePost = false', + ' postQueue.activePost = null', ' drainPosts()', ' })', '}', diff --git a/src/main/pi/agent-status-reload-delivery.test.ts b/src/main/pi/agent-status-reload-delivery.test.ts new file mode 100644 index 00000000000..d8b4b9e4f7a --- /dev/null +++ b/src/main/pi/agent-status-reload-delivery.test.ts @@ -0,0 +1,87 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { createAgentStatusExtensionHarness } from './agent-status-extension-test-harness' +import { agentEndCount, endTurn, posts } from './agent-status-subagent-event-fixtures' + +beforeEach(() => vi.useFakeTimers()) +afterEach(() => vi.useRealTimers()) + +it('does not overwrite the next turn with an old completion retried across reload', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'pi', existsSync: () => true }) + const ctx = { + isIdle: () => true, + sessionManager: { getSessionId: () => 'A', getSessionFile: () => '/sessions/A.jsonl' } + } + await harness.callHook('session_start', { reason: 'startup' }, ctx) + await harness.callHook('agent_start', {}, ctx) + await vi.advanceTimersByTimeAsync(0) + harness.fetchMock.mockRejectedValueOnce(new Error('offline')) + await endTurn(harness) + harness.fetchMock.mockRejectedValueOnce(new Error('still offline')) + await harness.reloadPi() + await vi.advanceTimersByTimeAsync(0) + await harness.callHook('session_start', { reason: 'reload' }, ctx) + const completionCount = agentEndCount(harness) + await harness.callHook('agent_start', {}, ctx) + await vi.advanceTimersByTimeAsync(1_000) + expect(agentEndCount(harness)).toBe(completionCount) + + expect(posts(harness).at(-1)).toMatchObject({ hook_event_name: 'agent_start', session_id: 'A' }) +}) + +it.each(['failure', 'success'] as const)( + 'serializes the next turn behind an in-flight completion on reload (%s)', + async (delivery) => { + const harness = createAgentStatusExtensionHarness({ kind: 'pi', existsSync: () => true }) + const ctx = { + isIdle: () => true, + sessionManager: { getSessionId: () => 'A', getSessionFile: () => '/sessions/A.jsonl' } + } + await harness.callHook('session_start', { reason: 'startup' }, ctx) + await harness.callHook('agent_start', {}, ctx) + await vi.advanceTimersByTimeAsync(0) + let acknowledge: ((value: { ok: boolean }) => void) | undefined + let fail: ((error: Error) => void) | undefined + harness.fetchMock.mockImplementationOnce( + () => + new Promise((resolve, reject) => { + acknowledge = resolve + fail = reject + }) + ) + await endTurn(harness) + await harness.reloadPi() + await harness.callHook('session_start', { reason: 'reload' }, ctx) + await harness.callHook('agent_start', {}, ctx) + await vi.advanceTimersByTimeAsync(0) + expect(posts(harness).at(-1)).toMatchObject({ hook_event_name: 'agent_end', session_id: 'A' }) + const completionCount = agentEndCount(harness) + if (delivery === 'failure') { + fail?.(new Error('late failure')) + } else { + acknowledge?.({ ok: true }) + } + await vi.advanceTimersByTimeAsync(5_000) + expect(agentEndCount(harness)).toBe(completionCount) + expect(posts(harness).at(-1)).toMatchObject({ hook_event_name: 'agent_start', session_id: 'A' }) + expect(vi.getTimerCount()).toBe(0) + } +) + +it('recovers a completion on reload without requiring a new turn', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'pi', existsSync: () => true }) + const ctx = { + isIdle: () => true, + sessionManager: { getSessionId: () => 'A', getSessionFile: () => '/sessions/A.jsonl' } + } + await harness.callHook('session_start', { reason: 'startup' }, ctx) + await harness.callHook('agent_start', {}, ctx) + await vi.advanceTimersByTimeAsync(0) + harness.fetchMock.mockRejectedValueOnce(new Error('offline')) + await endTurn(harness) + await harness.reloadPi() + await harness.callHook('session_start', { reason: 'reload' }, ctx) + await vi.advanceTimersByTimeAsync(5_000) + expect(agentEndCount(harness)).toBe(2) + expect(posts(harness).at(-1)).toMatchObject({ hook_event_name: 'agent_end', session_id: 'A' }) + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/src/main/pi/agent-status-session-boundary-source.ts b/src/main/pi/agent-status-session-boundary-source.ts new file mode 100644 index 00000000000..b958ba5b346 --- /dev/null +++ b/src/main/pi/agent-status-session-boundary-source.ts @@ -0,0 +1,110 @@ +import type { PiAgentKind } from '../../shared/pi-agent-kind' + +// What the generated extension does when a session ends or is replaced. A run belongs to one +// session: once that session is gone its children never report here again, so the run ends with it. + +// Pi replaces the registration for /new, resume and fork, and again for /reload, which keeps the session. +function getPiSessionShutdownHandlerSourceLines(): string[] { + return [ + " onStatus('session_shutdown', (event) => {", + ' clearPendingAgentEndCheck()', + ' if (isOmpRuntime()) {', + ' clearRunnerExitCheck()', + ' resetPostQueue()', + ' return', + ' }', + // Why: pi tears an open dialog down without resolving its promise, so no ui_prompt_end follows. + ' piUiPromptDepth = 0', + ' const reason = (event as { reason?: unknown } | null)?.reason', + ' const target = (event as { targetSessionFile?: unknown } | null)?.targetSessionFile', + // Why: /reload and a resume of the file already open keep the session, and its children still report to it. + " const keepsSession = reason === 'reload' || (reason === 'resume' && typeof target === 'string' && target === sessionMetadata.session_file)", + ' if (!keepsSession) clearRunnerExitCheck()', + // Why: on quit the PTY's exit clears the pane, and a done here would notify on every quit. + " if (keepsSession || reason === 'quit') {", + // Why: this registration's queue outlives it and would deliver stale posts after the next one's. + ' resetPostQueue()', + ' return', + ' }', + // Also a Pi too old to give a reason: its children would otherwise hold the pane for good. + ' closeOutRun(sessionMetadata.session_file)', + // Why: pi-subagents can still emit here until Pi invalidates this registration. + ' lifecycleState.onEvent = undefined', + ' })', + '' + ] +} + +// Registered after onStatus exists; expects the roster and closeOutRun() from the handler scope. +export function getAgentStatusSessionBoundaryHandlerSourceLines(kind: PiAgentKind): string[] { + return [ + ...(kind !== 'pi' + ? [ + " pi.on('session_shutdown', () => { resetSubagentRoster(); resetPostQueue(); clearPendingAgentEndCheck() })" + ] + : []), + ...(kind !== 'prime-agent' + ? [ + // Why: OMP keeps this registration and bus across a switch. /new and a branch cancel the + // session's own jobs; fork and resume (which is also how it reloads) leave them running + // and reporting here. + ' const onOmpSessionChange = (event, ctx) => {', + ' if (!isOmpRuntime()) return', + " if (event?.reason === 'fork' || event?.reason === 'resume') {", + // Why: a resume cuts a running turn off without an agent_end. + ' if (isTurnInFlight()) {', + ' lifecycleState.endedRunGeneration = lifecycleState.runGeneration', + ' postAgentEndOnce()', + ' }', + ' } else {', + ' closeOutRun()', + ' }', + ' updateRuntimeOmpSessionMetadata(ctx)', + ' }', + " pi.on('session_switch', onOmpSessionChange)", + " pi.on('session_branch', onOmpSessionChange)" + ] + : []), + ...(kind === 'pi' ? getPiSessionShutdownHandlerSourceLines() : []) + ] +} + +export function getAgentStatusRunCloseOutSourceLines(): string[] { + return [ + // Why: pi-subagents re-attaches a resumed session's runs and reports their completion to it. + ' function restoreParkedSubagents(): void {', + ' const file = sessionMetadata.session_file', + " if (typeof file !== 'string') return", + ' const parked = lifecycleState.parked?.get(file)', + ' if (!parked) return', + ' lifecycleState.parked?.delete(file)', + ' for (const [id, detail] of parked) {', + ' lifecycleState.active.add(id)', + ' subagentDetails.set(id, detail)', + ' }', + ' if (!isHeldByChildren()) return', + ' lifecycleState.waiting = true', + ' lifecycleState.completionPostedGeneration = -1', + ' }', + '', + // `parkUnder` keeps the children for a resume into that session file, where pi-subagents + // announces each one's completion again. + ' function closeOutRun(parkUnder?: unknown): void {', + ' const unsettled = lifecycleState.waiting || isTurnInFlight()', + " if (typeof parkUnder === 'string' && isHeldByChildren()) {", + ' const parked = new Map()', + ' for (const id of lifecycleState.active) {', + ' const detail = subagentDetails.get(id)', + ' if (detail && !lifecycleState.exited?.has(id)) parked.set(id, detail)', + ' }', + ' ;(lifecycleState.parked ??= new Map()).set(parkUnder, parked)', + ' }', + ' resetSubagentRoster()', + ' resetPostQueue()', + ' if (!unsettled) return', + ' lifecycleState.endedRunGeneration = lifecycleState.runGeneration', + ' postAgentEndOnce(true)', + ' }', + '' + ] +} diff --git a/src/main/pi/agent-status-subagent-event-fixtures.ts b/src/main/pi/agent-status-subagent-event-fixtures.ts new file mode 100644 index 00000000000..92e92fd7bf8 --- /dev/null +++ b/src/main/pi/agent-status-subagent-event-fixtures.ts @@ -0,0 +1,84 @@ +import { vi } from 'vitest' + +import { + AGENT_STATUS_EXTENSION_SELF_PID, + type AgentStatusExtensionHarness +} from './agent-status-extension-test-harness' + +// Event shapes and orderings mirror traces recorded from pi-subagents 0.71.0. +export const WORKFLOW = 'workflow-1' +export const idle = { isIdle: () => true } + +type PostedChild = { id: string; state: string; startedAt: number; agentType?: string } +type PostedPayload = { + hook_event_name: string + session_id?: string + session_file?: string + subagents?: PostedChild[] +} + +export function posts(harness: AgentStatusExtensionHarness): PostedPayload[] { + return harness.fetchMock.mock.calls.map((call) => { + const body: { payload: PostedPayload } = JSON.parse(String(call[1]?.body)) + return body.payload + }) +} + +export function postedHookNames(harness: AgentStatusExtensionHarness): string[] { + return posts(harness).map((post) => post.hook_event_name) +} + +export function agentEndCount(harness: AgentStatusExtensionHarness): number { + return postedHookNames(harness).filter((name) => name === 'agent_end').length +} + +export function startWorkflow(harness: AgentStatusExtensionHarness): void { + harness.emitPiEvent('subagent:async-started', { + id: WORKFLOW, + mode: 'workflow', + agent: 'workflow', + pid: AGENT_STATUS_EXTENSION_SELF_PID + }) +} + +export function startChild( + harness: AgentStatusExtensionHarness, + id: string, + parent = WORKFLOW +): void { + harness.emitPiEvent('subagent:async-started', { + id, + mode: 'single', + pid: 4000, + parentWorkflowRunId: parent + }) +} + +export function exitRunner(harness: AgentStatusExtensionHarness, runId: string): void { + harness.emitPiEvent('subagent:process-terminal', { runId, state: 'observed' }) +} + +export function complete(harness: AgentStatusExtensionHarness, id: string): void { + harness.emitPiEvent('subagent:async-complete', { id, runId: id, state: 'complete' }) +} + +export function childIds(payload: PostedPayload | undefined): string[] | undefined { + return payload?.subagents?.map((child) => child.id) +} + +export function startAsync(harness: AgentStatusExtensionHarness, id: string, agent: string): void { + harness.emitPiEvent('subagent:async-started', { + id, + mode: 'single', + agent, + task: '[REDACTED]', + goal: '[REDACTED]', + pid: 4000 + }) +} + +export async function endTurn(harness: AgentStatusExtensionHarness): Promise { + await harness.callHook('agent_end', {}, idle) + await harness.callHook('agent_settled', undefined, idle) + await vi.advanceTimersByTimeAsync(0) +} diff --git a/src/main/pi/agent-status-subagent-roster-source.ts b/src/main/pi/agent-status-subagent-roster-source.ts index 106d49a7533..ac3d294542b 100644 --- a/src/main/pi/agent-status-subagent-roster-source.ts +++ b/src/main/pi/agent-status-subagent-roster-source.ts @@ -1,75 +1,161 @@ // Why: Pi settles its own turn while pi-subagents children keep running, so the // generated extension holds the pane's completion until every child it saw start is gone. -// The roster lives on pi.events so an in-process /reload keeps children and listeners. -export function getPiSubagentRosterSetupSourceLines(): string[] { +import type { PiAgentKind } from '../../shared/pi-agent-kind' +import { AGENT_STATUS_MAX_SUBAGENTS } from '../../shared/agent-status-types' + +// Module scope: post() reads the roster when a body is built, so a coalesced or +// retried post always carries the children live at delivery. +export function getPiSubagentSnapshotSourceLines(): string[] { return [ - ' const piEventBus = (pi as { events?: { on?: (name: string, handler: (event: unknown) => void) => void } }).events', - ' const lifecycleState = (piEventBus as { __orcaPiSubagents?: { active: Set; exited?: Set; waiting: boolean; ownsPane?: boolean; rootRunInFlight?: boolean; onEvent?: (event: unknown, forcedStatus?: string) => void; listener?: (event: unknown) => void; onRunnerExit?: (event: unknown) => void; runnerExitListener?: (event: unknown) => void } } | undefined)?.__orcaPiSubagents ?? { active: new Set(), waiting: false }', - ' if (piEventBus) (piEventBus as { __orcaPiSubagents?: unknown }).__orcaPiSubagents = lifecycleState', - ' if (piEventBus?.on && !(lifecycleState as { listener?: unknown }).listener) {', - ' const listener = (event: unknown) => lifecycleState.onEvent?.(event)', - ' lifecycleState.listener = listener', - " piEventBus.on('task:subagent:lifecycle', listener)", + 'type SubagentDetail = { agentType?: string; description?: string; startedAt: number; workflow?: boolean; parent?: string; registration?: object }', + 'type SubagentRoster = { active: Set; exited?: Set; details?: Map; waiting: boolean; ownsPane?: boolean; runGeneration?: number; endedRunGeneration?: number; completionPostedGeneration?: number; parked?: Map>; onEvent?: (event: unknown, forcedStatus?: string) => void; listener?: (event: unknown) => void; onRunnerExit?: (event: unknown) => void; runnerExitListener?: (event: unknown) => void; runnerExitCheck?: ReturnType | null; onRunnerExitSettled?: () => void }', + // Why: interpolated, not re-typed, so the extension cap cannot drift from the host's. + `const MAX_SUBAGENT_SNAPSHOT = ${AGENT_STATUS_MAX_SUBAGENTS}`, + 'let subagentRoster: SubagentRoster | null = null', + '', + // Why: an exited runner is already gone, and a pi-subagents workflow run is the lead + // coordinating children that post their own rows; both still hold the pane. + 'function isVisibleSubagent(roster: SubagentRoster, id: string): boolean {', + ' return roster.active.has(id) && !roster.exited?.has(id) && roster.details?.get(id)?.workflow !== true', + '}', + '', + 'function subagentPayload(): Record {', + ' if (!subagentRoster) return {}', + ' const subagents: Record[] = []', + ' for (const id of subagentRoster.active) {', + ' if (!isVisibleSubagent(subagentRoster, id)) continue', + ' const detail = subagentRoster.details?.get(id)', + " subagents.push({ id, state: 'working', startedAt: detail?.startedAt ?? 0, ...(detail?.agentType ? { agentType: detail.agentType } : {}), ...(detail?.description ? { description: detail.description } : {}) })", + ' if (subagents.length >= MAX_SUBAGENT_SNAPSHOT) break', + ' }', + ' return subagents.length > 0 ? { subagents } : {}', + '}', + '' + ] +} + +// The run state (children, the hold, the turn counters) has to outlive a registration: Pi hands +// each one a fresh `pi.events` and evaluates this module again on /reload, so only globalThis +// survives there. OMP and Prime keep one bus for the session. +export function getPiSubagentRosterSetupSourceLines(kind: PiAgentKind): string[] { + return [ + ' const piEventBus = (pi as { events?: { __orcaPiSubagents?: SubagentRoster; __orcaPiSubagentsHeard?: boolean; __orcaPiRunnerExitsHeard?: boolean; on?: (name: string, handler: (event: unknown) => void) => void } }).events', + kind === 'pi' + ? ' const runStateHome: { __orcaPiSubagents?: SubagentRoster } | undefined = isOmpRuntime() ? piEventBus : (globalThis as { __orcaPiSubagents?: SubagentRoster })' + : ' const runStateHome: { __orcaPiSubagents?: SubagentRoster } | undefined = piEventBus', + // Why: a roster an older in-process build left on this bus is adopted, with the subscriptions it made. + ' const busRoster = piEventBus?.__orcaPiSubagents', + ' const lifecycleState: SubagentRoster = busRoster ?? runStateHome?.__orcaPiSubagents ?? { active: new Set(), waiting: false }', + ' if (runStateHome) runStateHome.__orcaPiSubagents = lifecycleState', + // Why: optional on the shared roster so one created by an older in-process build gains them on /reload. + ' const subagentDetails = (lifecycleState.details ??= new Map())', + // Why: completion is a per-RUN fact. A sibling extension (the memory reminder is one) can + // start the next run from inside its own agent_settled handler, so this extension sees that + // run's agent_start BEFORE its own agent_settled for the run that just ended; a boolean + // "already posted" latch would eat the newer run's completion. + ' lifecycleState.runGeneration ??= 0', + ' lifecycleState.endedRunGeneration ??= 0', + ' lifecycleState.completionPostedGeneration ??= -1', + // Why: an OMP task child runs this factory again on its own bus. Posts keep describing the + // lead's children, and the child's own subagents must not settle the lead's pane. + ' subagentRoster ??= lifecycleState', + ' const ownsPaneRoster = subagentRoster === lifecycleState', + // Tells the children this registration saw start from the ones a /reload handed it. + ' const registration = {}', + ' function resetSubagentRoster(): void {', + ' clearRunnerExitCheck()', + ' lifecycleState.active.clear()', + ' lifecycleState.exited?.clear()', + ' subagentDetails.clear()', + ' lifecycleState.waiting = false', + ' }', + // Why: one subscription per bus object; Pi drops a replaced registration's own. + ' if (ownsPaneRoster && piEventBus?.on && !piEventBus.__orcaPiSubagentsHeard && !busRoster?.listener) {', + ' piEventBus.__orcaPiSubagentsHeard = true', + " piEventBus.on('task:subagent:lifecycle', (event: unknown) => lifecycleState.onEvent?.(event))", " piEventBus.on('subagent:async-started', (event: unknown) => lifecycleState.onEvent?.(event, 'started'))", " piEventBus.on('subagent:async-complete', (event: unknown) => lifecycleState.onEvent?.(event, 'completed'))", ' }', - // Why: separate guard so a roster created by an older in-process build still subscribes. - ' if (piEventBus?.on && !lifecycleState.runnerExitListener) {', - ' const runnerExitListener = (event: unknown) => lifecycleState.onRunnerExit?.(event)', - ' lifecycleState.runnerExitListener = runnerExitListener', - " piEventBus.on('subagent:process-terminal', runnerExitListener)", + ' if (ownsPaneRoster && piEventBus?.on && !piEventBus.__orcaPiRunnerExitsHeard && !busRoster?.runnerExitListener) {', + ' piEventBus.__orcaPiRunnerExitsHeard = true', + " piEventBus.on('subagent:process-terminal', (event: unknown) => lifecycleState.onRunnerExit?.(event))", ' }' ] } -// Expects post(), the run generations and postAgentEndOnce() from the handler scope; -// the latter prunes exited runners before deciding whether children still hold the pane. +// Expects post() and postAgentEndOnce() from the handler scope; the latter prunes +// exited runners, then returns whether it settled the pane. export function getPiSubagentRosterEventSourceLines(): string[] { return [ // Why: a run that reports its own completion does so ~150ms after its runner exits; // the grace lets that path (and the wake turn it triggers) settle the pane first. ' const RUNNER_EXIT_GRACE_MS = 2000', - ' let runnerExitCheck: ReturnType | null = null', + ' function clearRunnerExitCheck(): void {', + ' if (lifecycleState.runnerExitCheck != null) clearTimeout(lifecycleState.runnerExitCheck)', + ' lifecycleState.runnerExitCheck = null', + ' }', + ' function forgetSubagent(id: string): void {', + ' lifecycleState.active.delete(id)', + ' lifecycleState.exited?.delete(id)', + ' subagentDetails.delete(id)', + ' }', + // Why: a child can end with no lead event to carry it; a queued post already reads the new roster. + ' function postSubagentsUpdate(): void {', + " if (!hasQueuedPost()) post('subagents_update')", + ' }', + " const readLabel = (value: unknown): string | undefined => typeof value === 'string' && value.trim() ? value : undefined", ' lifecycleState.onEvent = (event: unknown, forcedStatus?: string): void => {', " if (!event || typeof event !== 'object') return", - ' const record = event as { id?: unknown; runId?: unknown }', + ' const record = event as { id?: unknown; runId?: unknown; agent?: unknown; description?: unknown; mode?: unknown; parentWorkflowRunId?: unknown }', " const id = typeof record.id === 'string' && record.id ? record.id : typeof record.runId === 'string' ? record.runId : ''", ' const status = forcedStatus ?? (event as { status?: unknown }).status', ' if (!id) return', - // Why: each OMP task session runs its own copy on its own bus; only the pane's bus tracks children. ' if (isOmpRuntime() && !lifecycleState.ownsPane) return', " if (status === 'started') {", ' lifecycleState.active.add(id)', - // Why: a child starting after the run's done owes a fresh done. Under OMP the same holds - // before the first turn of this factory run (a resumed root, or a reload), but only when no - // root run is in flight -- a reload mid-turn resets these counters while the root still works. - ' if (completionPostedGeneration === runGeneration || (isOmpRuntime() && runGeneration === 0 && !lifecycleState.rootRunInFlight)) {', + // Why: pi-subagents redacts task prompts, so only the agent name and OMP's short label are shown. + " if (!subagentDetails.has(id)) subagentDetails.set(id, { agentType: readLabel(record.agent), description: readLabel(record.description), startedAt: Date.now(), workflow: record.mode === 'workflow', parent: readLabel(record.parentWorkflowRunId), registration })", + // Why: Pi re-opens only a posted completion; an earlier child must leave the idle check intact. + ' if (!isTurnInFlight() && (isOmpRuntime() || lifecycleState.runGeneration === 0 || lifecycleState.completionPostedGeneration === lifecycleState.runGeneration)) {', ' lifecycleState.waiting = true', - ' completionPostedGeneration = -1', + ' lifecycleState.completionPostedGeneration = -1', ' }', " post('agent_start')", ' return', ' }', " if (status !== 'completed' && status !== 'failed' && status !== 'aborted') return", - ' lifecycleState.active.delete(id)', - ' lifecycleState.exited?.delete(id)', - ' if (lifecycleState.waiting) postAgentEndOnce()', + ' let wasVisible = isVisibleSubagent(lifecycleState, id)', + ' forgetSubagent(id)', + // Why: Pi drops the runner-exit events of runs started before a /reload, so a finished run + // takes the children it launched back then with it. + ' for (const [childId, detail] of subagentDetails) {', + ' if (detail.parent !== id || detail.registration === registration) continue', + ' wasVisible ||= isVisibleSubagent(lifecycleState, childId)', + ' forgetSubagent(childId)', + ' }', + ' if (lifecycleState.waiting && postAgentEndOnce()) return', + ' if (wasVisible) postSubagentsUpdate()', ' }', // Why: awaited workflow children never get subagent:async-complete; their runner // exiting is the only end signal pi-subagents publishes for them. + ' lifecycleState.onRunnerExitSettled = (): void => {', + ' if (lifecycleState.waiting) postAgentEndOnce()', + ' }', ' lifecycleState.onRunnerExit = (event: unknown): void => {', " const runId = event && typeof event === 'object' ? (event as { runId?: unknown }).runId : undefined", " if (typeof runId !== 'string' || !lifecycleState.active.has(runId)) return", ' if (!lifecycleState.exited) lifecycleState.exited = new Set()', + ' const wasVisible = isVisibleSubagent(lifecycleState, runId)', ' lifecycleState.exited.add(runId)', + ' if (wasVisible) postSubagentsUpdate()', ' if (!lifecycleState.waiting) return', - ' if (runnerExitCheck !== null) clearTimeout(runnerExitCheck)', - ' runnerExitCheck = setTimeout(() => {', - ' runnerExitCheck = null', - ' if (lifecycleState.waiting) postAgentEndOnce()', + ' clearRunnerExitCheck()', + ' lifecycleState.runnerExitCheck = setTimeout(() => {', + ' lifecycleState.runnerExitCheck = null', + ' lifecycleState.onRunnerExitSettled?.()', ' }, RUNNER_EXIT_GRACE_MS)', - " if (typeof runnerExitCheck.unref === 'function') runnerExitCheck.unref()", + " if (typeof lifecycleState.runnerExitCheck.unref === 'function') lifecycleState.runnerExitCheck.unref()", ' }' ] } diff --git a/src/main/pi/agent-status-ui-prompt-source.ts b/src/main/pi/agent-status-ui-prompt-source.ts index d961864cd41..9ff30bd888d 100644 --- a/src/main/pi/agent-status-ui-prompt-source.ts +++ b/src/main/pi/agent-status-ui-prompt-source.ts @@ -29,19 +29,10 @@ export function getPiAgentStatusUiPromptHandlerSourceLines(kind: PiAgentKind): s ' } catch {', ' // Why: a runner this very modal invalidated cannot answer; keep the local verdict.', ' }', + ' // Why: Pi reports idle once its own turn settles, but children still hold the run open.', + ' isIdle &&= !isHeldByChildren()', " post('ui_prompt_end', { is_idle: isIdle })", ' })', - '', - " onStatus('session_shutdown', () => {", - ' resetPostQueue()', - ' clearPendingAgentEndCheck()', - ' if (isOmpRuntime()) return', - ' // Why: pi tears an open dialog down through resetExtensionUI without resolving its', - ' // promise, so a replaced session never emits the matching ui_prompt_end and the wait', - ' // would stick forever. Reset without posting: shutdown is not a turn boundary, and', - ' // the session_start that follows republishes the corrected state.', - ' piUiPromptDepth = 0', - ' })', '' ] } diff --git a/src/shared/agent-hook-listener/providers/pi-family-events.ts b/src/shared/agent-hook-listener/providers/pi-family-events.ts index 9786f09fb56..bf2acdc89ef 100644 --- a/src/shared/agent-hook-listener/providers/pi-family-events.ts +++ b/src/shared/agent-hook-listener/providers/pi-family-events.ts @@ -10,7 +10,8 @@ import { readString } from '../tool-input-preview' /** Maps a Pi-family hook event (Pi, OMP, Prime) onto a pane status: lifecycle * events become `working` / `done`, an ask tool becomes `blocked`, and OMP's - * `model` stamp rides along. Returns null for events that carry no status. */ + * `model` stamp and the extension's live `subagents` ride along. Returns null + * for events that carry no status. */ export function normalizePiCompatibleEvent( state: HookListenerState, agentType: 'pi' | 'omp' | 'prime-agent', @@ -32,15 +33,26 @@ export function normalizePiCompatibleEvent( const model = readString(hookPayload, 'model') const modelSwitchCommand = hookPayload.model_switch_command === 'orca-model' ? 'orca-model' : undefined - if (eventName === 'model_select') { - // Why: a model switch happens between turns, so it must ride on the pane's last - // known status instead of inventing a state — and before any status exists there - // is nothing for a model to describe. - const previous = state.lastStatusByPaneKey.get(paneKey)?.payload - if (!model || !previous || previous.agentType !== agentType) { + // Why: every post restates the extension's whole live roster, so an absent list means none. + const subagents = hookPayload.subagents + if (eventName === 'model_select' || eventName === 'subagents_update') { + // Why: a model switch or a child ending happens between lead events, so it rides on the + // pane's last visible row instead of inventing a state. Before any row exists there is + // nothing to describe, and a providerSessionOnly placeholder is a hidden resume record. + const previous = state.lastStatusByPaneKey.get(paneKey) + if ( + !previous || + previous.providerSessionOnly === true || + previous.payload.agentType !== agentType || + (eventName === 'model_select' && !model) + ) { return null } - return normalizeAgentStatusPayload({ ...previous, model, modelSwitchCommand }) + return normalizeAgentStatusPayload({ + ...previous.payload, + ...(eventName === 'model_select' ? { model, modelSwitchCommand } : {}), + subagents + }) } // Why: gate on the event's own tool_name so a stale cached question can't re-enter blocked. @@ -107,7 +119,12 @@ export function normalizePiCompatibleEvent( toolInput: snapshot.toolInput, interactivePrompt: snapshot.interactivePrompt, lastAssistantMessage: snapshot.lastAssistantMessage, - lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, + subagents, + // Why: the extension ends a run whose session was replaced; that is not a completed turn. + ...(eventName === 'agent_end' && hookPayload.session_boundary === true + ? { sessionBoundary: true } + : {}) }) } diff --git a/tests/tools/omp-completion-runtime-smoke.mjs b/tests/tools/omp-completion-runtime-smoke.mjs index 6c32c18db32..6e9a2da65ad 100644 --- a/tests/tools/omp-completion-runtime-smoke.mjs +++ b/tests/tools/omp-completion-runtime-smoke.mjs @@ -16,8 +16,9 @@ const server = createServer(async (request, response) => { for await (const chunk of request) { body += chunk } - const event = JSON.parse(body).payload.hook_event_name - received.push(event) + const payload = JSON.parse(body).payload + const event = payload.hook_event_name + received.push(payload) const reject = event === 'agent_end' && rejectCompletion if (reject) { rejectCompletion = false @@ -66,38 +67,68 @@ try { }) const handlers = new Map() require(extension).default({ on: (event, handler) => handlers.set(event, handler) }) - const emit = async (event) => { + let sessionId = 'A' + const context = { + isIdle: () => false, + sessionManager: { + getSessionId: () => sessionId, + getSessionFile: () => join(scratch, `${sessionId}.jsonl`) + } + } + const emit = async (event, payload = {}) => { assert.ok(handlers.has(event), `Missing lifecycle handler: ${event}`) - await handlers.get(event)({}, { isIdle: () => false }) + await handlers.get(event)(payload, context) } await emit('agent_start') await waitForRequests(1) await emit('agent_end') await waitForRequests(3) - assert.deepEqual(received, ['agent_start', 'agent_end', 'agent_end']) + assert.deepEqual( + received.map((event) => event.hook_event_name), + ['agent_start', 'agent_end', 'agent_end'] + ) const recovered = [...received] - for (const boundary of ['agent_start', 'session_switch', 'session_shutdown']) { + received.length = 0 + rejectCompletion = true + await emit('agent_start') + await waitForRequests(1) + await emit('agent_end') + await waitForRequests(2) + await emit('agent_start') + await delay(600) + assert.equal( + received.filter((event) => event.hook_event_name === 'agent_end').length, + 1, + 'An obsolete completion retried after the next turn started' + ) + + for (const boundary of ['session_switch', 'session_shutdown']) { received.length = 0 rejectCompletion = true + sessionId = 'A' await emit('agent_start') await waitForRequests(1) await emit('agent_end') await waitForRequests(2) - await emit(boundary) + sessionId = 'B' + await emit(boundary, { reason: 'new' }) + await emit('agent_start') + await waitForRequests(4) await delay(600) - assert.equal( - received.filter((event) => event === 'agent_end').length, - 1, - `An obsolete completion retried after ${boundary}` - ) + const completions = received.filter((event) => event.hook_event_name === 'agent_end') + assert.equal(completions.length, 2, `Unsent completion was lost at ${boundary}`) + assert.ok(completions.every((event) => event.session_id === 'A')) + assert.equal(received.at(-1).hook_event_name, 'agent_start') + assert.equal(received.at(-1).session_id, 'B', 'Old completion overwrote the new session') } console.log( JSON.stringify({ platform: process.platform, node: process.version, recovered, - cancelledAt: ['agent_start', 'session_switch', 'session_shutdown'], + cancelledAt: ['agent_start'], + preservedAt: ['session_switch', 'session_shutdown'], scope: 'Generated OMP extension, real native HTTP 503, synthetic lifecycle callbacks' }) ) From 60125709964ccbe933a65c43260523cc48019821 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:10:31 +0000 Subject: [PATCH 03/48] feat(files): reveal Source Control files and terminal file links in the file manager (#24502) * feat(reveal): reveal changed files and terminal file links Reveal only existed on file explorer rows. Every reveal menu now shares one action, so a missing file or a remote workspace behaves the same in all of them. Fixes #24003 * fix(reveal): address review findings on missing links and SSH files A hovered hyperlink to a missing file saved "missing" in the cache plain path links trust, hiding the path once the file existed. A tab holding an SSH file from outside its workspace still offered reveal. * fix(reveal): drop reveal from check-details tabs Their path is a synthetic id, so reveal could only fail. * fix(reveal): use scoped file access for directory checks * fix(reveal): retire superseded Spanish menu labels * test: preserve explicit file access and untranslated-locale contracts * refactor(terminal): reveal a file link from its click popover, not the right-click menu The terminal "Reveal in Finder" action now lives in the file-link popover (the one with "Open file" and "Open with default app"), as a row with no click shortcut. It goes through the shared reveal, so a folder link (including a macOS .app or .xcodeproj bundle) is selected in its parent and never opened. The row is omitted, not disabled, when the file is not on this machine: an SSH or runtime-owned file, a pane whose shell runs on another host, or while a remote runtime is focused. Workspace-root links keep their own "Open in Finder"/"Open folder" row and get no reveal row. Removes the right-click path: the context-menu item, the hovered-link tracking and its OSC 8 / path-existence probe refactors, the directory stat (and its user-named file-access grant, which only served the open-a-folder behaviour), and the right-click e2e case. Terminal files the PR touched are back to main apart from the popover row. Co-authored-by: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> * test(terminal): type the reveal settings fixture without assertion * test(startup): account for encoded Windows prompt lines * test(compatibility): retain the released journal parser dependency * test(journal): retain one canonical provider-handle import * fix(terminal): give the popover's Reveal in Finder row the reveal icon Every other Reveal in Finder item shows the external-link icon; the link popover draws it for rows that open outside Orca, so mark the row that way. --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- .../src/components/editor/EditorPanel.tsx | 17 -- .../editor/EditorPanelHeader.test.tsx | 1 - .../components/editor/EditorPanelHeader.tsx | 3 - .../editor/EditorPanelHeaderPath.test.tsx | 40 ++++- .../editor/EditorPanelHeaderPath.tsx | 46 +++--- .../editor/EditorPanelShell.header.test.tsx | 1 - .../components/editor/EditorPanelShell.tsx | 3 - .../link-actions/LinkActionPopover.test.tsx | 33 ++++ .../link-actions/LinkActionPopover.tsx | 29 +++- .../link-actions/link-action-request.ts | 2 + .../src/components/local-only-menu-hint.tsx | 11 ++ .../file-explorer-row-context-menu.test.tsx | 150 ++++++++++++++++++ .../file-explorer-row-context-menu.tsx | 53 ++----- ...source-control-entry-context-menu.test.tsx | 125 ++++++++++++--- .../listing/branch-entry-row.tsx | 1 + .../listing/entry-context-menu.tsx | 66 +++++--- .../listing/uncommitted-entry-row.tsx | 1 + .../NativeChatSupportedAgents.test.tsx | 11 ++ .../components/tab-bar/EditorFileTab.test.tsx | 5 - .../tab-bar/EditorFileTabContextMenu.test.tsx | 82 ++++++++-- .../tab-bar/EditorFileTabContextMenu.tsx | 68 +++----- .../editor-tab-local-open-guard.test.ts | 22 --- .../tab-bar/editor-tab-local-open-guard.ts | 13 -- .../tab-bar/tab-title-tooltip.test.tsx | 8 - .../TerminalContextMenu.test.tsx | 12 ++ .../terminal-file-link-actions.test.ts | 149 ++++++++++++++++- .../terminal-file-link-actions.ts | 26 ++- .../terminal-link-action-request.ts | 5 +- .../terminal-pane-mount-preparation.ts | 3 +- src/renderer/src/i18n/locales/en.json | 20 ++- src/renderer/src/i18n/locales/es.json | 22 +-- src/renderer/src/i18n/locales/fr.json | 20 ++- src/renderer/src/i18n/locales/ja.json | 20 ++- src/renderer/src/i18n/locales/ko.json | 20 ++- src/renderer/src/i18n/locales/zh.json | 20 ++- .../src/lib/local-file-manager-label.ts | 22 ++- .../src/lib/reveal-in-file-manager.test.ts | 101 ++++++++++++ .../src/lib/reveal-in-file-manager.ts | 60 +++++++ .../golden-source-control-open-diff.spec.ts | 25 +++ 39 files changed, 1020 insertions(+), 296 deletions(-) create mode 100644 src/renderer/src/components/local-only-menu-hint.tsx create mode 100644 src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.test.tsx delete mode 100644 src/renderer/src/components/tab-bar/editor-tab-local-open-guard.test.ts delete mode 100644 src/renderer/src/components/tab-bar/editor-tab-local-open-guard.ts create mode 100644 src/renderer/src/lib/reveal-in-file-manager.test.ts create mode 100644 src/renderer/src/lib/reveal-in-file-manager.ts diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index 04d18529b4e..de466cd7074 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -4,7 +4,6 @@ import { getConnectionId } from '@/lib/connection-context' import { detectLanguage } from '@/lib/language-detect' import { canShowWorkspaceFileBrowserAction, openFilePreviewToSide } from '@/lib/file-preview' import { getEditorHeaderCopyState } from './editor-header' -import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { exportActiveMarkdownToPdf } from './export-active-markdown' import type { EditorToggleValue } from './EditorViewToggle' @@ -278,21 +277,6 @@ function EditorPanelInner({ { sourceFileId: activeFile.id } ) } - const handleOpenContainingFolder = (): void => { - // Why: virtual editor tabs use synthetic ids instead of on-disk paths. - if (activeFile.mode === 'check-details') { - return - } - if ( - isLocalPathOpenBlocked(settingsForRuntimeOwner(settings, activeFile.runtimeEnvironmentId), { - connectionId: getConnectionId(activeFile.worktreeId) - }) - ) { - showLocalPathOpenBlockedToast() - return - } - window.api.shell.openPath(activeFile.filePath) - } const disableRenameBrowse = Boolean( settingsForRuntimeOwner( settings, @@ -357,7 +341,6 @@ function EditorPanelInner({ onOpenDiffTargetFile={handleOpenDiffTargetFile} onOpenPreviewToSide={handleOpenPreviewToSide} onOpenMarkdownPreview={handleOpenMarkdownPreview} - onOpenContainingFolder={handleOpenContainingFolder} onToggleSideBySide={() => setSideBySide((prev) => !prev)} onEditorToggleChange={handleEditorToggleChange} onToggleMarkdownTableOfContents={() => diff --git a/src/renderer/src/components/editor/EditorPanelHeader.test.tsx b/src/renderer/src/components/editor/EditorPanelHeader.test.tsx index 49f68207348..dd96aa04cbe 100644 --- a/src/renderer/src/components/editor/EditorPanelHeader.test.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeader.test.tsx @@ -90,7 +90,6 @@ const baseProps = { onOpenDiffTargetFile: vi.fn(), onOpenPreviewToSide: vi.fn(), onOpenMarkdownPreview: vi.fn(), - onOpenContainingFolder: vi.fn(), onToggleSideBySide: vi.fn(), onEditorToggleChange: vi.fn(), onToggleMarkdownTableOfContents: vi.fn(), diff --git a/src/renderer/src/components/editor/EditorPanelHeader.tsx b/src/renderer/src/components/editor/EditorPanelHeader.tsx index b16ea92587b..f8283f0d6d8 100644 --- a/src/renderer/src/components/editor/EditorPanelHeader.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeader.tsx @@ -47,7 +47,6 @@ type EditorPanelHeaderProps = { onOpenDiffTargetFile: (preferredMarkdownViewMode?: 'rich') => void onOpenPreviewToSide: () => void onOpenMarkdownPreview: () => void - onOpenContainingFolder: () => void onToggleSideBySide: () => void onEditorToggleChange: (next: EditorToggleValue) => void onToggleMarkdownTableOfContents: () => void @@ -82,7 +81,6 @@ export function EditorPanelHeader({ onOpenDiffTargetFile, onOpenPreviewToSide, onOpenMarkdownPreview, - onOpenContainingFolder, onToggleSideBySide, onEditorToggleChange, onToggleMarkdownTableOfContents, @@ -113,7 +111,6 @@ export function EditorPanelHeader({ canShowMarkdownPreview={canShowMarkdownPreview} onCopyPath={onCopyPath} onOpenMarkdownPreview={onOpenMarkdownPreview} - onOpenContainingFolder={onOpenContainingFolder} /> {canOpenPreviewToSide && ( diff --git a/src/renderer/src/components/editor/EditorPanelHeaderPath.test.tsx b/src/renderer/src/components/editor/EditorPanelHeaderPath.test.tsx index 0ffd1ac3cda..d65140902fc 100644 --- a/src/renderer/src/components/editor/EditorPanelHeaderPath.test.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeaderPath.test.tsx @@ -51,7 +51,6 @@ function renderPath(file: OpenFile): (next: OpenFile) => void { canShowMarkdownPreview={false} onCopyPath={vi.fn()} onOpenMarkdownPreview={vi.fn()} - onOpenContainingFolder={vi.fn()} /> ) return (next) => @@ -62,7 +61,6 @@ function renderPath(file: OpenFile): (next: OpenFile) => void { canShowMarkdownPreview={false} onCopyPath={vi.fn()} onOpenMarkdownPreview={vi.fn()} - onOpenContainingFolder={vi.fn()} /> ) } @@ -247,3 +245,41 @@ describe('EditorPanelHeaderPath inline rename', () => { expect(input.selectionEnd).toBe('Makefile'.length) }) }) + +describe('EditorPanelHeaderPath reveal in file manager', () => { + const openInFileManager = vi.fn() + + function openPathMenu(): HTMLElement { + const pathRow = document.querySelector('.editor-header-path-row') + if (!pathRow) { + throw new Error('Missing editor header path row') + } + fireEvent.contextMenu(pathRow) + return screen.getByRole('menuitem', { name: /Open Containing Folder/ }) + } + + beforeEach(() => { + openInFileManager.mockReset().mockResolvedValue({ ok: true }) + Object.assign(window, { api: { shell: { openInFileManager } } }) + }) + + it('reveals the open file through the shared reveal action', () => { + renderPath(baseFile()) + + fireEvent.click(openPathMenu()) + + expect(openInFileManager).toHaveBeenCalledWith('/repo/notes.md') + }) + + it.each([ + ['a remote runtime owns', { runtimeEnvironmentId: 'env-1' }], + ['opened from an SSH host outside the workspace', { externalSshTargetId: 'ssh-1' }] + ])('disables reveal as local-only for a file %s', (_owner, overrides) => { + renderPath(baseFile(overrides)) + + const reveal = openPathMenu() + + expect(reveal.getAttribute('aria-disabled')).toBe('true') + expect(reveal.textContent).toContain('Local only') + }) +}) diff --git a/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx b/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx index a7acb8ee7a2..a5b820d7c51 100644 --- a/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx +++ b/src/renderer/src/components/editor/EditorPanelHeaderPath.tsx @@ -12,37 +12,26 @@ import { import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { isImeCompositionKeyDown } from '@/lib/ime-composition-keyboard-event' import { translate } from '@/i18n/i18n' +import { LocalOnlyMenuHint } from '@/components/local-only-menu-hint' +import { getConnectionIdFromState } from '@/lib/connection-context' +import { + getRevealInFileManagerLabel, + isRevealInFileManagerBlocked, + revealInFileManager +} from '@/lib/reveal-in-file-manager' +import { useAppStore } from '@/store' import type { OpenFile } from '@/store/slices/editor' import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus' import { useEditorHeaderFileRename } from './editor-header-file-rename' import { getEditorHeaderCopyState } from './editor-header' import { splitPathForDisplay } from './editor-path-display' -const isMac = navigator.userAgent.includes('Mac') -const isLinux = navigator.userAgent.includes('Linux') - -/** Platform-appropriate label: macOS -> Finder, Windows -> File Explorer, Linux -> Files */ -function getRevealLabel(): string { - return isMac - ? translate('auto.components.editor.EditorPanelHeader.revealInFinder', 'Reveal in Finder') - : isLinux - ? translate( - 'auto.components.editor.EditorPanelHeader.openContainingFolder', - 'Open Containing Folder' - ) - : translate( - 'auto.components.editor.EditorPanelHeader.revealInFileExplorer', - 'Reveal in File Explorer' - ) -} - type EditorPanelHeaderPathProps = { activeFile: OpenFile copiedPathVisible: boolean canShowMarkdownPreview: boolean onCopyPath: () => void onOpenMarkdownPreview: () => void - onOpenContainingFolder: () => void } export function EditorPanelHeaderPath({ @@ -50,8 +39,7 @@ export function EditorPanelHeaderPath({ copiedPathVisible, canShowMarkdownPreview, onCopyPath, - onOpenMarkdownPreview, - onOpenContainingFolder + onOpenMarkdownPreview }: EditorPanelHeaderPathProps): React.JSX.Element { const [pathMenuOpen, setPathMenuOpen] = useState(false) const [pathMenuPoint, setPathMenuPoint] = useState({ x: 0, y: 0 }) @@ -59,7 +47,15 @@ export function EditorPanelHeaderPath({ const headerCopyState = getEditorHeaderCopyState(activeFile) const displayPath = splitPathForDisplay(headerCopyState.pathLabel) const canCopyHeaderPath = headerCopyState.copyText !== null + // Why: virtual editor tabs use synthetic ids instead of on-disk paths. const isVirtualEditorTab = activeFile.mode === 'check-details' + const revealBlocked = useAppStore((s) => + isRevealInFileManagerBlocked(s.settings, { + connectionId: + activeFile.externalSshTargetId ?? getConnectionIdFromState(s, activeFile.worktreeId), + runtimeEnvironmentId: activeFile.runtimeEnvironmentId + }) + ) const markdownPreviewShortcutLabel = useShortcutLabel('editor.markdownPreview') const { canRename, @@ -216,9 +212,13 @@ export function EditorPanelHeaderPath({ )} {canShowMarkdownPreview && } {!isVirtualEditorTab && ( - + void revealInFileManager(activeFile.filePath)} + > - {getRevealLabel()} + {getRevealInFileManagerLabel()} + {revealBlocked ? : null} )} diff --git a/src/renderer/src/components/editor/EditorPanelShell.header.test.tsx b/src/renderer/src/components/editor/EditorPanelShell.header.test.tsx index 2ea5003dbd4..8250a8d83f7 100644 --- a/src/renderer/src/components/editor/EditorPanelShell.header.test.tsx +++ b/src/renderer/src/components/editor/EditorPanelShell.header.test.tsx @@ -82,7 +82,6 @@ function renderShell(file: OpenFile, isCombinedDiff = false): string { onOpenDiffTargetFile={noop} onOpenPreviewToSide={noop} onOpenMarkdownPreview={noop} - onOpenContainingFolder={noop} onToggleSideBySide={noop} onEditorToggleChange={noop} onToggleMarkdownTableOfContents={noop} diff --git a/src/renderer/src/components/editor/EditorPanelShell.tsx b/src/renderer/src/components/editor/EditorPanelShell.tsx index dd15231cefe..d2ac2fef5ba 100644 --- a/src/renderer/src/components/editor/EditorPanelShell.tsx +++ b/src/renderer/src/components/editor/EditorPanelShell.tsx @@ -37,7 +37,6 @@ type EditorPanelShellProps = { onOpenDiffTargetFile: (preferredMarkdownViewMode?: 'rich') => void onOpenPreviewToSide: () => void onOpenMarkdownPreview: () => void - onOpenContainingFolder: () => void onToggleSideBySide: () => void onEditorToggleChange: (next: EditorToggleValue) => void onToggleMarkdownTableOfContents: () => void @@ -78,7 +77,6 @@ export function EditorPanelShell({ onOpenDiffTargetFile, onOpenPreviewToSide, onOpenMarkdownPreview, - onOpenContainingFolder, onToggleSideBySide, onEditorToggleChange, onToggleMarkdownTableOfContents, @@ -125,7 +123,6 @@ export function EditorPanelShell({ onOpenDiffTargetFile={onOpenDiffTargetFile} onOpenPreviewToSide={onOpenPreviewToSide} onOpenMarkdownPreview={onOpenMarkdownPreview} - onOpenContainingFolder={onOpenContainingFolder} onToggleSideBySide={onToggleSideBySide} onEditorToggleChange={onEditorToggleChange} onToggleMarkdownTableOfContents={onToggleMarkdownTableOfContents} diff --git a/src/renderer/src/components/link-actions/LinkActionPopover.test.tsx b/src/renderer/src/components/link-actions/LinkActionPopover.test.tsx index af92f5acab4..39b1f283a6a 100644 --- a/src/renderer/src/components/link-actions/LinkActionPopover.test.tsx +++ b/src/renderer/src/components/link-actions/LinkActionPopover.test.tsx @@ -105,6 +105,39 @@ describe('LinkActionPopover', () => { expect(run).toHaveBeenCalledOnce() }) + it('lists secondary rows after the shortcut rows, without a shortcut, and runs them', () => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + const onClose = vi.fn() + const restoreFocus = vi.fn() + const reveal = vi.fn() + const request: LinkActionRequest = { + anchorX: 100, + anchorY: 200, + destination: '/repo/src/main.ts', + kind: 'file', + primary: { label: 'Open file', run: vi.fn() }, + alternate: { label: 'Open with default app', run: vi.fn() }, + secondaryActions: [{ label: 'Reveal in Finder', run: reveal }], + restoreFocus + } + + render() + + const labels = screen.getAllByRole('button').map((button) => button.textContent ?? '') + expect(labels.findIndex((label) => label.startsWith('Reveal in Finder'))).toBeGreaterThan( + labels.findIndex((label) => label.startsWith('Open with default app')) + ) + expect(screen.getAllByText('Click')).toHaveLength(2) + expect(screen.getByText('Reveal in Finder').closest('button')?.textContent).toBe( + 'Reveal in Finder' + ) + + fireEvent.click(screen.getByText('Reveal in Finder')) + expect(onClose).toHaveBeenCalledOnce() + expect(restoreFocus).toHaveBeenCalledOnce() + expect(reveal).toHaveBeenCalledOnce() + }) + it('identifies the dismissed request so a newer request can survive', () => { vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) const onClose = vi.fn() diff --git a/src/renderer/src/components/link-actions/LinkActionPopover.tsx b/src/renderer/src/components/link-actions/LinkActionPopover.tsx index 4bde5fdbae1..62c8f305c0e 100644 --- a/src/renderer/src/components/link-actions/LinkActionPopover.tsx +++ b/src/renderer/src/components/link-actions/LinkActionPopover.tsx @@ -18,17 +18,20 @@ type LinkActionPopoverProps = { function ActionRow({ action, - alternate, + shortcut, onRun }: { action: LinkAction - alternate: boolean + shortcut: 'primary' | 'alternate' | null onRun: () => void }): React.JSX.Element { const isMac = navigator.userAgent.includes('Mac') - const keys = alternate - ? [isMac ? '⇧' : 'Shift', isMac ? '⌘' : 'Ctrl', 'Click'] - : [isMac ? '⌘' : 'Ctrl', 'Click'] + const keys = + shortcut === 'alternate' + ? [isMac ? '⇧' : 'Shift', isMac ? '⌘' : 'Ctrl', 'Click'] + : shortcut === 'primary' + ? [isMac ? '⌘' : 'Ctrl', 'Click'] + : null return ( ) } @@ -180,16 +185,24 @@ export function LinkActionPopover({ runAction(request.primary)} /> {request.alternate ? ( runAction(request.alternate!)} /> ) : null} + {request.secondaryActions?.map((action) => ( + runAction(action)} + /> + ))} ) : null} diff --git a/src/renderer/src/components/link-actions/link-action-request.ts b/src/renderer/src/components/link-actions/link-action-request.ts index f79f22aa7d0..fbdb66929dc 100644 --- a/src/renderer/src/components/link-actions/link-action-request.ts +++ b/src/renderer/src/components/link-actions/link-action-request.ts @@ -12,6 +12,8 @@ export type LinkActionRequest = { kind: LinkActionKind primary: LinkAction alternate?: LinkAction + /** Rows after primary/alternate that have no click shortcut. */ + secondaryActions?: readonly LinkAction[] /** Hands focus back to the surface that owned the click (terminal, chat transcript). */ restoreFocus: () => void } diff --git a/src/renderer/src/components/local-only-menu-hint.tsx b/src/renderer/src/components/local-only-menu-hint.tsx new file mode 100644 index 00000000000..1143277cf0c --- /dev/null +++ b/src/renderer/src/components/local-only-menu-hint.tsx @@ -0,0 +1,11 @@ +import React from 'react' +import { translate } from '@/i18n/i18n' + +/** Trailing reason on a menu item disabled because its target lives on another host. */ +export function LocalOnlyMenuHint(): React.JSX.Element { + return ( + + {translate('auto.components.sidebar.WorktreeOpenInMenu.localOnly', 'Local only')} + + ) +} diff --git a/src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.test.tsx b/src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.test.tsx new file mode 100644 index 00000000000..0fcd1ebac16 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.test.tsx @@ -0,0 +1,150 @@ +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FileExplorerRowContextMenu } from './file-explorer-row-context-menu' +import type { TreeNode } from './file-explorer-types' + +type ItemProps = { onSelect?: () => void; disabled?: boolean; children?: React.ReactNode } + +const items = vi.hoisted(() => ({ list: [] as ItemProps[] })) +const storeState = vi.hoisted( + (): { + activeWorktreeId: string + activeWorkspaceExecutionHostId: 'local' | `runtime:${string}` | null + openMarkdownPreview: () => void + settings: { activeRuntimeEnvironmentId: string | null } + } => ({ + activeWorktreeId: 'wt-1', + activeWorkspaceExecutionHostId: null, + openMarkdownPreview: () => {}, + settings: { activeRuntimeEnvironmentId: null } + }) +) +const revealInFileManager = vi.hoisted(() => vi.fn()) + +vi.mock('@/components/ui/context-menu', async () => { + const React_ = await import('react') + const passthrough = ({ children }: { children?: React.ReactNode }) => + React_.createElement(React_.Fragment, null, children) + return { + ContextMenuContent: passthrough, + ContextMenuItem: (props: ItemProps) => { + items.list.push(props) + return React_.createElement(React_.Fragment, null, props.children) + }, + ContextMenuSeparator: () => null, + ContextMenuShortcut: () => null + } +}) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: typeof storeState) => unknown) => selector(storeState) +})) + +vi.mock('@/hooks/useShortcutLabel', () => ({ useShortcutLabel: () => 'Unassigned' })) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/lib/file-preview', () => ({ openFileInBrowserTab: vi.fn() })) + +vi.mock(import('@/lib/reveal-in-file-manager'), async (importOriginal) => ({ + ...(await importOriginal()), + getRevealInFileManagerLabel: () => 'Reveal in Finder', + revealInFileManager +})) + +vi.mock('./file-explorer-row-file-transfer', () => ({ + copyFileToOsClipboard: vi.fn(), + downloadRemoteFile: vi.fn() +})) + +const fileNode: TreeNode = { + name: 'index.ts', + path: '/repo/src/index.ts', + relativePath: 'src/index.ts', + isDirectory: false, + depth: 1 +} + +function renderRevealItem( + owner: Pick, 'connectionId'> = {} +): ItemProps | undefined { + renderToStaticMarkup( + + ) + return items.list.find((item) => + React.Children.toArray(item.children).includes('Reveal in Finder') + ) +} + +function showsLocalOnlyHint(item: ItemProps | undefined): boolean { + return renderToStaticMarkup(<>{item?.children}).includes('Local only') +} + +describe('FileExplorerRowContextMenu reveal in file manager', () => { + beforeEach(() => { + items.list = [] + storeState.activeWorktreeId = 'wt-1' + storeState.activeWorkspaceExecutionHostId = null + storeState.settings.activeRuntimeEnvironmentId = null + revealInFileManager.mockReset() + }) + + it('reveals a local row through the shared reveal action', () => { + const reveal = renderRevealItem() + + expect(reveal?.disabled).toBe(false) + expect(showsLocalOnlyHint(reveal)).toBe(false) + reveal?.onSelect?.() + expect(revealInFileManager).toHaveBeenCalledWith('/repo/src/index.ts') + }) + + it('disables reveal as local-only for a row on an SSH host', () => { + const reveal = renderRevealItem({ connectionId: 'ssh-1' }) + + expect(reveal?.disabled).toBe(true) + expect(showsLocalOnlyHint(reveal)).toBe(true) + }) + + it.each([ + ['worktree', 'wt-1'], + ['folder workspace', 'folder:fw-1'] + ])('disables reveal as local-only for a row in a %s a remote runtime owns', (_kind, id) => { + storeState.activeWorktreeId = id + storeState.activeWorkspaceExecutionHostId = 'runtime:env-1' + + const reveal = renderRevealItem() + + expect(reveal?.disabled).toBe(true) + expect(showsLocalOnlyHint(reveal)).toBe(true) + }) + + it('reveals a row in a local folder workspace', () => { + storeState.activeWorktreeId = 'folder:fw-1' + + expect(renderRevealItem()?.disabled).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.tsx b/src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.tsx index a9be313a436..39f3f5d37c8 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.tsx +++ b/src/renderer/src/components/right-sidebar/file-explorer-row-context-menu.tsx @@ -26,8 +26,14 @@ import { useAppStore } from '@/store' import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { detectLanguage } from '@/lib/language-detect' import { openFileInBrowserTab } from '@/lib/file-preview' -import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' import { translate } from '@/i18n/i18n' +import { LocalOnlyMenuHint } from '@/components/local-only-menu-hint' +import { + getRevealInFileManagerLabel, + isRevealInFileManagerBlocked, + revealInFileManager +} from '@/lib/reveal-in-file-manager' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import type { FileExplorerRowProps } from './FileExplorerRow' import { shouldShowCollapseFolderAction, @@ -40,22 +46,6 @@ import { import { copyFileToOsClipboard, downloadRemoteFile } from './file-explorer-row-file-transfer' const isMac = navigator.userAgent.includes('Mac') -const isLinux = navigator.userAgent.includes('Linux') - -/** Platform-appropriate label: macOS → Finder, Windows → File Explorer, Linux → Files */ -function getRevealLabel(): string { - return isMac - ? translate('auto.components.right.sidebar.FileExplorerRow.revealInFinder', 'Reveal in Finder') - : isLinux - ? translate( - 'auto.components.right.sidebar.FileExplorerRow.openContainingFolder', - 'Open Containing Folder' - ) - : translate( - 'auto.components.right.sidebar.FileExplorerRow.revealInFileExplorer', - 'Reveal in File Explorer' - ) -} function stopRightButtonMenuSelection(event: React.PointerEvent): void { if (event.button !== 2) { @@ -129,6 +119,12 @@ export function FileExplorerRowContextMenu({ supportsFolderDownload ) const showCopyFileAction = shouldShowCopyFileAction(node, connectionId, selectionSize) + const revealBlocked = useAppStore((s) => + isRevealInFileManagerBlocked(s.settings, { + connectionId, + runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(s, s.activeWorktreeId) + }) + ) const handleOpenInOrcaBrowser = useCallback(() => { if (!activeWorktreeId) { return @@ -278,27 +274,12 @@ export function FileExplorerRowContextMenu({ )} { - const state = useAppStore.getState() - const activeWorktree = Object.values(state.worktreesByRepo) - .flat() - .find((worktree) => worktree.id === activeWorktreeId) - const activeRepo = activeWorktree - ? state.repos.find((repo) => repo.id === activeWorktree.repoId) - : null - if ( - isLocalPathOpenBlocked(state.settings, { - connectionId: activeRepo?.connectionId ?? null - }) - ) { - showLocalPathOpenBlockedToast() - return - } - window.api.shell.openPath(node.path) - }} + disabled={revealBlocked} + onSelect={() => void revealInFileManager(node.path)} > - {getRevealLabel()} + {getRevealInFileManagerLabel()} + {revealBlocked ? : null} onStartRename(node)}> diff --git a/src/renderer/src/components/right-sidebar/source-control-entry-context-menu.test.tsx b/src/renderer/src/components/right-sidebar/source-control-entry-context-menu.test.tsx index fd466696920..c29da151c1d 100644 --- a/src/renderer/src/components/right-sidebar/source-control-entry-context-menu.test.tsx +++ b/src/renderer/src/components/right-sidebar/source-control-entry-context-menu.test.tsx @@ -1,11 +1,24 @@ import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { OpenInApplication } from '../../../../shared/ui-chrome-types' import { SourceControlEntryContextMenu } from './source-control/listing/entry-context-menu' -type ItemProps = { onSelect?: () => void; children?: React.ReactNode } +type ItemProps = { onSelect?: () => void; disabled?: boolean; children?: React.ReactNode } const items = vi.hoisted(() => ({ list: [] as ItemProps[] })) +const storeState = vi.hoisted( + (): { + settings: { + openInApplications: OpenInApplication[] + activeRuntimeEnvironmentId: string | null + } + } => ({ + settings: { openInApplications: [], activeRuntimeEnvironmentId: null } + }) +) +const ownerRuntime = vi.hoisted((): { environmentId: string | null } => ({ environmentId: null })) +const revealInFileManager = vi.hoisted(() => vi.fn()) vi.mock('@/components/ui/context-menu', async () => { const React_ = await import('react') @@ -28,39 +41,79 @@ vi.mock('@/components/ui/context-menu', async () => { }) vi.mock('@/store', () => ({ - useAppStore: (selector: (state: { settings: { openInApplications: never[] } }) => unknown) => - selector({ settings: { openInApplications: [] } }) + useAppStore: (selector: (state: typeof storeState) => unknown) => selector(storeState) +})) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getRuntimeEnvironmentIdForWorktree: () => ownerRuntime.environmentId +})) + +vi.mock(import('@/lib/reveal-in-file-manager'), async (importOriginal) => ({ + ...(await importOriginal()), + getRevealInFileManagerLabel: () => 'Reveal in Finder', + revealInFileManager })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -vi.mock('@/lib/local-file-manager-label', () => ({ - getLocalFileManagerLabel: () => 'Finder' -})) - vi.mock('@/lib/open-in-app-catalog', () => ({ OpenInApplicationIcon: () => null })) vi.mock('@/components/sidebar/WorktreeOpenInMenu', () => ({ - getWorktreeOpenInEntries: () => [], + getOpenInEntryAvailability: () => ({ disabled: false }), openOpenInAppsSettings: vi.fn(), openWorktreePath: vi.fn() })) function childrenText(children: React.ReactNode): string { return React.Children.toArray(children) - .filter((child): child is string => typeof child === 'string') + .map((child) => { + if (typeof child === 'string') { + return child + } + return React.isValidElement<{ children?: React.ReactNode }>(child) + ? childrenText(child.props.children) + : '' + }) .join('') } +function showsLocalOnlyHint(item: ItemProps | undefined): boolean { + return renderToStaticMarkup(<>{item?.children}).includes('Local only') +} + +function renderMenu(props: { connectionId?: string; hasWorkingTreeFile?: boolean } = {}): void { + renderToStaticMarkup( + +
+ + ) +} + +function renderRevealItem(props?: Parameters[0]): ItemProps | undefined { + renderMenu(props) + return items.list.find((item) => childrenText(item.children) === 'Reveal in Finder') +} + describe('SourceControlEntryContextMenu', () => { const writeClipboardText = vi.fn() beforeEach(() => { items.list = [] + storeState.settings.activeRuntimeEnvironmentId = null + storeState.settings.openInApplications = [] + ownerRuntime.environmentId = null + revealInFileManager.mockReset() writeClipboardText.mockReset() vi.stubGlobal('window', { api: { ui: { writeClipboardText } } @@ -72,16 +125,7 @@ describe('SourceControlEntryContextMenu', () => { }) it('copies the supplied relative path', () => { - renderToStaticMarkup( - -
- - ) + renderMenu() const copyRelativePathItem = items.list.find( (item) => childrenText(item.children) === 'Copy Relative Path' @@ -91,4 +135,47 @@ describe('SourceControlEntryContextMenu', () => { copyRelativePathItem?.onSelect?.() expect(writeClipboardText).toHaveBeenCalledWith('src/example.ts') }) + + it('reveals the changed file in the OS file manager (issue #24003)', () => { + const revealItem = renderRevealItem() + + expect(revealItem?.disabled).toBe(false) + expect(showsLocalOnlyHint(revealItem)).toBe(false) + revealItem?.onSelect?.() + expect(revealInFileManager).toHaveBeenCalledWith('/repo/src/example.ts') + }) + + it('offers the file manager once, outside the "Open in" apps', () => { + storeState.settings.openInApplications = [{ id: 'zed', label: 'Zed', command: 'zed' }] + + renderMenu() + + const labels = items.list.map((item) => childrenText(item.children)) + expect(labels).toContain('Zed') + expect(labels).not.toContain('Finder') + expect(labels.filter((label) => label === 'Reveal in Finder')).toHaveLength(1) + }) + + it('disables reveal, with no reason, for a deleted file', () => { + const revealItem = renderRevealItem({ hasWorkingTreeFile: false }) + + expect(revealItem?.disabled).toBe(true) + expect(showsLocalOnlyHint(revealItem)).toBe(false) + }) + + it('disables reveal as local-only for a repo on an SSH host', () => { + const revealItem = renderRevealItem({ connectionId: 'ssh-1' }) + + expect(revealItem?.disabled).toBe(true) + expect(showsLocalOnlyHint(revealItem)).toBe(true) + }) + + it('disables reveal as local-only for a repo owned by a runtime that is not the focused one', () => { + ownerRuntime.environmentId = 'env-2' + + const revealItem = renderRevealItem() + + expect(revealItem?.disabled).toBe(true) + expect(showsLocalOnlyHint(revealItem)).toBe(true) + }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/branch-entry-row.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/branch-entry-row.tsx index 90d0755d57a..68fd0a91223 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/branch-entry-row.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/branch-entry-row.tsx @@ -43,6 +43,7 @@ export function BranchEntryRow({ currentWorktreeId={currentWorktreeId} absolutePath={joinPath(worktreePath, entry.path)} relativePath={entry.path} + hasWorkingTreeFile={entry.status !== 'deleted'} connectionId={connectionId} onView={() => onOpen()} onRevealInExplorer={onRevealInExplorer} diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/entry-context-menu.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/entry-context-menu.tsx index 0c0417a79ce..6ad4d25b0f6 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/entry-context-menu.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/entry-context-menu.tsx @@ -13,11 +13,16 @@ import { import { useAppStore } from '@/store' import { OpenInApplicationIcon } from '@/lib/open-in-app-catalog' import { translate } from '@/i18n/i18n' -import { getLocalFileManagerLabel } from '@/lib/local-file-manager-label' +import { LocalOnlyMenuHint } from '@/components/local-only-menu-hint' +import { + getRevealInFileManagerLabel, + isRevealInFileManagerBlocked, + revealInFileManager +} from '@/lib/reveal-in-file-manager' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { NO_OPEN_IN_APPLICATIONS } from '@/lib/open-in-application-selection' import { getOpenInEntryAvailability, - getWorktreeOpenInEntries, openOpenInAppsSettings, openWorktreePath } from '@/components/sidebar/WorktreeOpenInMenu' @@ -26,6 +31,8 @@ type SourceControlEntryContextMenuProps = { currentWorktreeId: string absolutePath?: string relativePath?: string + /** False for a deleted entry: there is no file for the OS file manager to show. */ + hasWorkingTreeFile: boolean connectionId?: string | null onView?: () => void onRevealInExplorer: (worktreeId: string, absolutePath: string) => void @@ -37,6 +44,7 @@ export function SourceControlEntryContextMenu({ currentWorktreeId, absolutePath, relativePath, + hasWorkingTreeFile, connectionId, onView, onRevealInExplorer, @@ -47,11 +55,15 @@ export function SourceControlEntryContextMenu({ (s) => s.settings?.openInApplications ?? NO_OPEN_IN_APPLICATIONS ) const settings = useAppStore((s) => s.settings) - const fileManagerLabel = getLocalFileManagerLabel() - const openInEntries = React.useMemo( - () => getWorktreeOpenInEntries(openInApplications, fileManagerLabel), - [fileManagerLabel, openInApplications] + // Why: a repo can belong to a runtime other than the focused one, and the OS reveal + // cannot tell that host's path from a local one of the same name. + const runtimeEnvironmentId = useAppStore((s) => + getRuntimeEnvironmentIdForWorktree(s, currentWorktreeId) ) + const revealBlocked = isRevealInFileManagerBlocked(settings, { + connectionId, + runtimeEnvironmentId + }) const handleCopyPath = useCallback(() => { if (!absolutePath) { @@ -74,13 +86,19 @@ export function SourceControlEntryContextMenu({ onRevealInExplorer(currentWorktreeId, absolutePath) }, [absolutePath, currentWorktreeId, onRevealInExplorer]) - const handleOpenInExternal = useCallback( - (target: 'file-manager' | 'external-editor', command?: string) => { + const handleRevealInFileManager = useCallback(() => { + if (absolutePath) { + void revealInFileManager(absolutePath) + } + }, [absolutePath]) + + const handleOpenInApplication = useCallback( + (command: string) => { if (!absolutePath) { return } void openWorktreePath({ - target, + target: 'external-editor', worktreePath: absolutePath, connectionId, command @@ -119,22 +137,24 @@ export function SourceControlEntryContextMenu({ {translate('auto.components.sidebar.WorktreeOpenInMenu.8009ab69a6', 'Open in')} - {openInEntries.map((entry) => { - const availability = getOpenInEntryAvailability(entry, settings, connectionId) + {openInApplications.map((application) => { + const availability = getOpenInEntryAvailability( + { ...application, target: 'external-editor' }, + settings, + connectionId + ) return ( handleOpenInExternal(entry.target, entry.command)} + key={application.id} + onSelect={() => handleOpenInApplication(application.command)} disabled={!absolutePath || availability.disabled} > - {entry.target === 'file-manager' ? ( - - ) : entry.command ? ( - + {application.command ? ( + ) : ( )} - {entry.label} + {application.label} {availability.metadata ? ( {availability.metadata} @@ -143,7 +163,7 @@ export function SourceControlEntryContextMenu({ ) })} - + {openInApplications.length > 0 ? : null} {translate( 'auto.components.sidebar.WorktreeOpenInMenu.1417fd8380', @@ -152,6 +172,14 @@ export function SourceControlEntryContextMenu({ + + + {getRevealInFileManagerLabel()} + {revealBlocked ? : null} + diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-entry-row.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-entry-row.tsx index 73b9d1abd6b..d733b37e7cd 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-entry-row.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/listing/uncommitted-entry-row.tsx @@ -92,6 +92,7 @@ export const UncommittedEntryRow = React.memo(function UncommittedEntryRow({ currentWorktreeId={currentWorktreeId} absolutePath={joinPath(worktreePath, entry.path)} relativePath={entry.path} + hasWorkingTreeFile={entry.status !== 'deleted'} connectionId={connectionId} onView={() => onOpen(entry)} onRevealInExplorer={onRevealInExplorer} diff --git a/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx b/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx index 1a6ae82bece..a1c1c247ea8 100644 --- a/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx +++ b/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx @@ -38,6 +38,7 @@ describe('NativeChatSupportedAgents', () => { afterEach(async () => { await i18n.changeLanguage('en') i18n.removeResourceBundle('test', 'translation') + await i18n.loadNamespaces('translation') }) it('keeps the advertised list and support predicate on the independent contract', () => { @@ -94,4 +95,14 @@ describe('NativeChatSupportedAgents', () => { expect(markup).toContain('Supported agents:') }) + + it('renders the translated label in Spanish', async () => { + await i18n.changeLanguage('es') + expect(i18n.language).toBe('es') + expect(i18n.getResource('es', 'translation', SUPPORTED_AGENTS_LABEL_KEY)).toBe('Agentes apoyados:') + + const markup = renderToStaticMarkup() + + expect(markup).toContain('Agentes apoyados:') + }) }) diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx index 68da6cc1613..061a8319be4 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.test.tsx @@ -215,11 +215,6 @@ vi.mock('@/components/editor/markdown-preview-controls', () => ({ canOpenMarkdownPreview: () => false })) -vi.mock('@/lib/local-path-open-guard', () => ({ - shouldBlockEditorTabLocalOpen: () => false, - showLocalPathOpenBlockedToast: vi.fn() -})) - type ReactElementLike = { type: unknown props: Record diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx index 812079563af..ba9a79b2221 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.test.tsx @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const shortcutLabelMock = vi.hoisted(() => vi.fn()) +const revealInFileManager = vi.hoisted(() => vi.fn()) +const storeSettings = vi.hoisted((): { activeRuntimeEnvironmentId?: string } => ({})) vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenu: function DropdownMenu(props: { children?: unknown }) { @@ -108,7 +110,7 @@ const useAppStoreMock = Object.assign( }) => unknown ) => selector({ - settings: {}, + settings: storeSettings, unifiedTabsByWorktree: { 'wt-1': [{ id: 'tab-1', groupId: 'group-1' }] }, @@ -118,7 +120,7 @@ const useAppStoreMock = Object.assign( }), { getState: () => ({ - settings: {}, + settings: storeSettings, unifiedTabsByWorktree: { 'wt-1': [{ id: 'tab-1', groupId: 'group-1' }] }, @@ -133,12 +135,9 @@ vi.mock('@/store', () => ({ useAppStore: useAppStoreMock })) -vi.mock('@/lib/local-path-open-guard', () => ({ - showLocalPathOpenBlockedToast: vi.fn() -})) - -vi.mock('./editor-tab-local-open-guard', () => ({ - shouldBlockEditorTabLocalOpen: () => false +vi.mock(import('@/lib/reveal-in-file-manager'), async (importOriginal) => ({ + ...(await importOriginal()), + revealInFileManager })) type ReactElementLike = { @@ -203,8 +202,16 @@ function extractText(node: unknown): string { } async function renderMenu( - overrides: { onActivate?: () => void; onOpenRenameInput?: () => void } = {} + overrides: { + onActivate?: () => void + onOpenRenameInput?: () => void + repoConnectionId?: string | null + runtimeEnvironmentId?: string | null + externalSshTargetId?: string + mode?: 'edit' | 'check-details' + } = {} ): Promise { + const { runtimeEnvironmentId, externalSshTargetId, mode = 'edit', ...props } = overrides const module = await import('./EditorFileTabContextMenu') return module.EditorFileTabContextMenu({ open: true, @@ -217,7 +224,9 @@ async function renderMenu( worktreeId: 'wt-1', language: 'typescript', isDirty: false, - mode: 'edit' + mode, + runtimeEnvironmentId, + externalSshTargetId }, unifiedTabId: 'tab-1', groupId: 'group-1', @@ -241,10 +250,19 @@ async function renderMenu( onCloseToRight: vi.fn(), onCloseToLeft: vi.fn(), onOpenMarkdownPreview: vi.fn(), - ...overrides + ...props }) } +async function renderRevealItem( + overrides?: Parameters[0] +): Promise { + const tree = expandNode(await renderMenu(overrides)) + return findElementsByType(tree, 'DropdownMenuItem').find((item) => + extractText(item.props.children).includes('Reveal in Finder') + )! +} + function assignedShortcutLabel(actionId: string): string | null { switch (actionId) { case 'tab.rename': @@ -344,3 +362,45 @@ describe('EditorFileTabContextMenu close-all shortcut', () => { expect(findElementsByType(tree, 'DropdownMenuShortcut')).toHaveLength(0) }) }) + +describe('EditorFileTabContextMenu reveal in file manager', () => { + beforeEach(() => { + vi.resetModules() + shortcutLabelMock.mockReturnValue(null) + revealInFileManager.mockReset() + delete storeSettings.activeRuntimeEnvironmentId + vi.stubGlobal('navigator', { userAgent: 'Mac' }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('reveals a local file through the shared reveal action', async () => { + const reveal = await renderRevealItem() + + expect(reveal.props.disabled).toBe(false) + expect(extractText(reveal.props.children)).not.toContain('Local only') + const onSelect = reveal.props.onSelect + if (typeof onSelect !== 'function') { + throw new Error('Reveal item has no select handler') + } + onSelect() + expect(revealInFileManager).toHaveBeenCalledWith('/repo/foo.ts') + }) + + it.each([ + ['on an SSH host', { repoConnectionId: 'ssh-1' }], + ['owned by a remote runtime', { runtimeEnvironmentId: 'env-1' }], + ['opened from an SSH host outside the workspace', { externalSshTargetId: 'ssh-1' }] + ])('disables reveal as local-only for a file %s', async (_owner, overrides) => { + const reveal = await renderRevealItem(overrides) + + expect(reveal.props.disabled).toBe(true) + expect(extractText(reveal.props.children)).toContain('Local only') + }) + + it('offers no reveal for a check-details tab, which has no file on disk', async () => { + expect(await renderRevealItem({ mode: 'check-details' })).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx index a6df437feac..db89a9fa6d9 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTabContextMenu.tsx @@ -20,35 +20,18 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { useAppStore } from '@/store' -import { showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import type { OpenFile } from '../../store/slices/editor' -import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard' import { translate } from '@/i18n/i18n' +import { LocalOnlyMenuHint } from '@/components/local-only-menu-hint' +import { + getRevealInFileManagerLabel, + isRevealInFileManagerBlocked, + revealInFileManager +} from '@/lib/reveal-in-file-manager' import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection' import { TAB_CONTEXT_MENU_CONTENT_CLASS } from './tab-context-menu-sizing' -const isMac = navigator.userAgent.includes('Mac') -const isLinux = navigator.userAgent.includes('Linux') - -/** Platform-appropriate label: macOS → Finder, Windows → File Explorer, Linux → Files */ -function getRevealLabel(): string { - return isMac - ? translate( - 'auto.components.tab.bar.EditorFileTabContextMenu.revealInFinder', - 'Reveal in Finder' - ) - : isLinux - ? translate( - 'auto.components.tab.bar.EditorFileTabContextMenu.openContainingFolder', - 'Open Containing Folder' - ) - : translate( - 'auto.components.tab.bar.EditorFileTabContextMenu.revealInFileExplorer', - 'Reveal in File Explorer' - ) -} - type EditorFileTabContextMenuProps = { open: boolean menuPoint: { x: number; y: number } @@ -116,6 +99,12 @@ export function EditorFileTabContextMenu({ const renameShortcut = useOptionalShortcutLabel('tab.rename') const closeShortcut = useOptionalShortcutLabel('tab.close') const closeAllShortcut = useOptionalShortcutLabel('tab.closeAll') + const revealBlocked = useAppStore((s) => + isRevealInFileManagerBlocked(s.settings, { + connectionId: file.externalSshTargetId ?? repoConnectionId, + runtimeEnvironmentId: file.runtimeEnvironmentId + }) + ) return ( @@ -245,25 +234,20 @@ export function EditorFileTabContextMenu({ 'Copy Relative Path' )} - - { - if ( - shouldBlockEditorTabLocalOpen( - useAppStore.getState().settings, - file.runtimeEnvironmentId, - repoConnectionId - ) - ) { - showLocalPathOpenBlockedToast() - return - } - window.api.shell.openPath(file.filePath) - }} - > - - {getRevealLabel()} - + {/* Why: virtual editor tabs use synthetic ids instead of on-disk paths. */} + {file.mode !== 'check-details' && ( + <> + + void revealInFileManager(file.filePath)} + > + + {getRevealInFileManagerLabel()} + {revealBlocked ? : null} + + + )} ) diff --git a/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.test.ts b/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.test.ts deleted file mode 100644 index 21b850b21fa..00000000000 --- a/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { shouldBlockEditorTabLocalOpen } from './editor-tab-local-open-guard' - -describe('shouldBlockEditorTabLocalOpen', () => { - it('blocks remote-owned editor tabs even after the active runtime switches local', () => { - expect(shouldBlockEditorTabLocalOpen({ activeRuntimeEnvironmentId: null }, 'env-1', null)).toBe( - true - ) - }) - - it('blocks SSH editor tabs without a runtime owner', () => { - expect( - shouldBlockEditorTabLocalOpen({ activeRuntimeEnvironmentId: null }, undefined, 'ssh-1') - ).toBe(true) - }) - - it('allows local editor tabs without runtime or SSH ownership', () => { - expect( - shouldBlockEditorTabLocalOpen({ activeRuntimeEnvironmentId: null }, undefined, null) - ).toBe(false) - }) -}) diff --git a/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.ts b/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.ts deleted file mode 100644 index 574d6507232..00000000000 --- a/src/renderer/src/components/tab-bar/editor-tab-local-open-guard.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { isLocalPathOpenBlocked } from '@/lib/local-path-open-guard' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' - -export function shouldBlockEditorTabLocalOpen( - settings: Pick | null | undefined, - fileRuntimeEnvironmentId: string | null | undefined, - connectionId: string | null | undefined -): boolean { - return isLocalPathOpenBlocked(settingsForRuntimeOwner(settings, fileRuntimeEnvironmentId), { - connectionId - }) -} diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx index 445076b590b..d2f7cc4bdcb 100644 --- a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx @@ -134,14 +134,6 @@ vi.mock('@/components/editor/markdown-preview-controls', () => ({ canOpenMarkdownPreview: () => false })) -vi.mock('@/lib/local-path-open-guard', () => ({ - showLocalPathOpenBlockedToast: vi.fn() -})) - -vi.mock('./editor-tab-local-open-guard', () => ({ - shouldBlockEditorTabLocalOpen: () => false -})) - function makeDragData(tabType: TabDragItemData['tabType'], visibleTabId: string): TabDragItemData { return { kind: 'tab', diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx index 16f87ae24a9..54e33409862 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx @@ -129,6 +129,18 @@ describe('TerminalContextMenu', () => { expect(items.list.length).toBeGreaterThan(0) }) + // Why: revealing a terminal file link lives in its click popover, not the right-click menu. + it.each(['Macintosh', 'Windows NT 10.0', 'Linux'])( + 'offers no file-manager reveal item on %s', + (userAgent) => { + vi.stubGlobal('navigator', { userAgent }) + renderMenu() + const labels = items.list.map((item) => childrenText(item.children)) + expect(labels.length).toBeGreaterThan(0) + expect(labels.some((label) => /Reveal in|Open Containing Folder/.test(label))).toBe(false) + } + ) + it('renders a "Copy Context" item that triggers onCopyAgentSessionContext (issue #5020)', () => { const onCopyAgentSessionContext = vi.fn() const onForkAgentSession = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts index b9354a73267..c135f330a1f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.test.ts @@ -1,11 +1,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TerminalLinkActionContext } from './terminal-link-action-request' -const mocks = vi.hoisted(() => ({ - canOpenWithSystemDefault: true, - downloadAndOpen: vi.fn(), - openDetectedFilePath: vi.fn(), - worktreeRoot: false +const mocks = vi.hoisted(() => { + const settings: { activeRuntimeEnvironmentId: string | null } = { + activeRuntimeEnvironmentId: null + } + return { + canOpenWithSystemDefault: true, + downloadAndOpen: vi.fn(), + openDetectedFilePath: vi.fn(), + settings, + worktreeRoot: false + } +}) + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => ({ settings: mocks.settings }) } })) vi.mock('./terminal-file-open-routing', () => ({ @@ -41,19 +51,31 @@ function plainEvent(): MouseEvent { } as unknown as MouseEvent } -function context(request: ReturnType): TerminalLinkActionContext { +function context( + request: ReturnType, + sourceOwner: TerminalLinkActionContext['sourceOwner'] = { kind: 'local' } +): TerminalLinkActionContext { return { paneId: 3, pointerGesture: { canRequestAction: () => true, dispose: vi.fn() }, claimPtyMouse: vi.fn(() => true), request: request as TerminalLinkActionContext['request'], - focusTerminal: vi.fn() + focusTerminal: vi.fn(), + sourceOwner } } +const shellApi = { + openInFileManager: vi.fn(async () => ({ ok: true })), + openFilePath: vi.fn(async () => true) +} +const fsApi = { stat: vi.fn() } + beforeEach(() => { vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + vi.stubGlobal('window', { api: { shell: shellApi, fs: fsApi } }) mocks.canOpenWithSystemDefault = true + mocks.settings = { activeRuntimeEnvironmentId: null } mocks.worktreeRoot = false vi.clearAllMocks() }) @@ -157,4 +179,117 @@ describe('terminal file link actions', () => { expect(request.mock.calls[0][0]).not.toHaveProperty('alternate') }) + + describe('reveal row', () => { + const revealRow = (request: ReturnType) => + request.mock.calls[0][0].secondaryActions?.find( + (action: { label: string }) => action.label === 'Reveal in Finder' + ) + + it('offers Reveal in Finder for a local file link and reveals it through the shared path', async () => { + const request = vi.fn() + handleTerminalFileLink('/repo/src/main.ts', 12, 4, plainEvent(), deps, context(request)) + + const row = revealRow(request) + // Marked external so the popover draws the same icon as every other Reveal item. + expect(row).toMatchObject({ external: true }) + await row.run() + expect(shellApi.openInFileManager).toHaveBeenCalledWith('/repo/src/main.ts') + expect(mocks.openDetectedFilePath).not.toHaveBeenCalled() + }) + + // Why: a macOS .app or .xcodeproj is a folder; "Reveal" must select it, never launch it. + it('selects a folder link in its parent instead of opening it', async () => { + const request = vi.fn() + handleTerminalFileLink( + '/repo/build/Orca.app', + null, + null, + plainEvent(), + deps, + context(request) + ) + + await revealRow(request).run() + expect(shellApi.openInFileManager).toHaveBeenCalledWith('/repo/build/Orca.app') + expect(shellApi.openFilePath).not.toHaveBeenCalled() + expect(fsApi.stat).not.toHaveBeenCalled() + expect(mocks.openDetectedFilePath).not.toHaveBeenCalled() + }) + + it('uses the platform file manager name', () => { + vi.stubGlobal('navigator', { userAgent: 'Windows NT 10.0' }) + const request = vi.fn() + handleTerminalFileLink('C:\\repo\\a.ts', null, null, plainEvent(), deps, context(request)) + + expect(request.mock.calls[0][0].secondaryActions).toEqual([ + expect.objectContaining({ label: 'Reveal in File Explorer' }) + ]) + }) + + it('omits the row for a workspace-root link, which has its own Open in Finder row', () => { + mocks.worktreeRoot = true + const request = vi.fn() + handleTerminalFileLink('/repo', null, null, plainEvent(), deps, context(request)) + + const actionRequest = request.mock.calls[0][0] + expect(actionRequest.alternate.label).toBe('Open in Finder') + expect(actionRequest).not.toHaveProperty('secondaryActions') + }) + + it('omits the row for a file owned by an SSH or runtime host', () => { + mocks.canOpenWithSystemDefault = false + const request = vi.fn() + handleTerminalFileLink('/repo/src/main.ts', null, null, plainEvent(), deps, context(request)) + + expect(request.mock.calls[0][0]).not.toHaveProperty('secondaryActions') + }) + + it.each([ + ['an SSH host', { kind: 'ssh', connectionId: 'conn-1' }], + ['a paired runtime', { kind: 'runtime', runtimeEnvironmentId: 'env-1' }], + ['an unknown host', { kind: 'unknown' }], + ['an unreported host', undefined] + ] as const)('omits the row when the pane shell runs on %s', (_label, sourceOwner) => { + const request = vi.fn() + handleTerminalFileLink( + '/repo/src/main.ts', + null, + null, + plainEvent(), + deps, + // Why spread: a default parameter would turn an explicit undefined back into local. + { ...context(request), sourceOwner } + ) + + expect(request.mock.calls[0][0]).not.toHaveProperty('secondaryActions') + }) + + it('omits the row while a remote runtime is focused, or for a runtime-owned link', () => { + mocks.settings = { activeRuntimeEnvironmentId: 'env-1' } + const focused = vi.fn() + handleTerminalFileLink('/repo/src/main.ts', null, null, plainEvent(), deps, context(focused)) + expect(focused.mock.calls[0][0]).not.toHaveProperty('secondaryActions') + + mocks.settings = { activeRuntimeEnvironmentId: null } + const owned = vi.fn() + handleTerminalFileLink( + '/repo/src/main.ts', + null, + null, + plainEvent(), + { ...deps, runtimeEnvironmentId: 'env-1' }, + context(owned) + ) + expect(owned.mock.calls[0][0]).not.toHaveProperty('secondaryActions') + }) + + it('reveals nothing until the row is clicked', () => { + const request = vi.fn() + handleTerminalFileLink('/repo/src/main.ts', null, null, plainEvent(), deps, context(request)) + + expect(shellApi.openInFileManager).not.toHaveBeenCalled() + expect(fsApi.stat).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts index 6f55be94103..0ef281514a0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts +++ b/src/renderer/src/components/terminal-pane/terminal-file-link-actions.ts @@ -13,6 +13,12 @@ import { import { resolveKnownWorktreeRootPathLink } from './terminal-worktree-path-link' import { downloadAndOpenRemoteTerminalFile } from './terminal-remote-file-download-open' import { translate } from '@/i18n/i18n' +import { + getRevealInFileManagerLabel, + isRevealInFileManagerBlocked, + revealInFileManager +} from '@/lib/reveal-in-file-manager' +import { useAppStore } from '@/store' export type TerminalFileLinkActionDeps = { worktreeId: string @@ -92,6 +98,23 @@ export function handleTerminalFileLink( ), run: () => downloadAndOpenRemoteTerminalFile(fileContext, mappedPath) } + // Why omit, not disable: the popover has no disabled rows. The OS file manager can only show a + // file on this machine, and the main process refuses every reveal while a remote runtime is focused. + const canReveal = + !worktreeRoot && + canOpenWithSystemDefault && + actionContext?.sourceOwner?.kind === 'local' && + !isRevealInFileManagerBlocked(useAppStore.getState().settings, { + runtimeEnvironmentId: deps.runtimeEnvironmentId + }) + // Why the shared reveal: it selects a folder (or a macOS .app bundle) in its parent, never opens it. + const revealRow = canReveal + ? { + external: true, + label: getRevealInFileManagerLabel(), + run: () => revealInFileManager(mappedPath) + } + : null return requestTerminalLinkAction(event, actionContext, { destination: actionDestination ?? mappedPath, kind: worktreeRoot ? 'workspace' : 'file', @@ -107,6 +130,7 @@ export function handleTerminalFileLink( ), run: () => openDetectedFilePath(filePath, line, column, deps) }, - ...(systemDefaultRow ? { alternate: systemDefaultRow } : {}) + ...(systemDefaultRow ? { alternate: systemDefaultRow } : {}), + ...(revealRow ? { secondaryActions: [revealRow] } : {}) }) } diff --git a/src/renderer/src/components/terminal-pane/terminal-link-action-request.ts b/src/renderer/src/components/terminal-pane/terminal-link-action-request.ts index 21f917302ca..441fecaee0b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-action-request.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-action-request.ts @@ -4,6 +4,7 @@ import { isTerminalMiddleClickActivation } from './terminal-link-activation' import type { TerminalLinkClickBehavior } from './terminal-link-click-behavior' +import type { HttpLinkSourceOwner } from '@/lib/http-link-routing' import { closeLinkActionRequest, type LinkAction, @@ -27,6 +28,8 @@ export type TerminalLinkActionContext = { focusTerminal: () => void plainClickBehavior?: TerminalLinkClickBehavior middleClickBehavior?: TerminalLinkClickBehavior + /** The host running the pane's shell; absent reads as not this machine. */ + sourceOwner?: HttpLinkSourceOwner } export function closeTerminalLinkActionRequest( @@ -38,7 +41,7 @@ export function closeTerminalLinkActionRequest( type LinkActionDetails = Pick< TerminalLinkActionRequest, - 'destination' | 'kind' | 'primary' | 'alternate' + 'destination' | 'kind' | 'primary' | 'alternate' | 'secondaryActions' > export function requestTerminalLinkAction( diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts index c6fe626871b..22b654073fd 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-preparation.ts @@ -150,7 +150,8 @@ export function prepareTerminalPaneMount( request: deps.requestTerminalLinkAction, focusTerminal: () => pane.terminal.focus(), plainClickBehavior, - middleClickBehavior + middleClickBehavior, + sourceOwner: getHttpLinkSourceOwnerForPane(paneId) } } const pathExistsCache = new Map() diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7a63120928d..6fc6a042603 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1097,6 +1097,16 @@ "tooLarge": "Too large to copy, so Orca couldn't hand it to the agent.", "storageFull": "Orca's storage for dropped files is full, so Orca couldn't hand it to the agent.", "generic": "Try the drop again." + }, + "reveal": { + "in": { + "file": { + "manager": { + "notFound": "File not found. It may have been moved or deleted.", + "launchFailed": "Could not reveal the file." + } + } + } } }, "hooks": { @@ -3546,10 +3556,7 @@ "1d04b1630b": "Split Down", "6b3efb106e": "Split Up", "fdd29eb669": "Pin Tab", - "8e9d603a09": "Unpin Tab", - "revealInFinder": "Reveal in Finder", - "openContainingFolder": "Open Containing Folder", - "revealInFileExplorer": "Reveal in File Explorer" + "8e9d603a09": "Unpin Tab" }, "QuickLaunchButton": { "348a04c1ad": "Agent settings…", @@ -15288,10 +15295,7 @@ "f0fd4174b5": "Open file tab to use rich markdown editing", "a10d9b8337": "Open file", "2076ecfc9c": "Previous change", - "631dab0df3": "Next change", - "revealInFinder": "Reveal in Finder", - "openContainingFolder": "Open Containing Folder", - "revealInFileExplorer": "Reveal in File Explorer" + "631dab0df3": "Next change" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "Export as PDF", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 79490c3da4e..404db9b7487 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -934,6 +934,16 @@ }, "clipboardWriteFailure": { "tooLarge": "El texto es demasiado grande para copiarlo." + }, + "reveal": { + "in": { + "file": { + "manager": { + "notFound": "No se encontró el archivo. Puede que se haya movido o eliminado.", + "launchFailed": "No se pudo mostrar el archivo." + } + } + } } }, "hooks": { @@ -3343,10 +3353,7 @@ "1d04b1630b": "Dividir hacia abajo", "6b3efb106e": "Dividir hacia arriba", "fdd29eb669": "Fijar pestaña", - "8e9d603a09": "Desanclar pestaña", - "revealInFinder": "Revelar en Finder", - "openContainingFolder": "Abrir carpeta contenedora", - "revealInFileExplorer": "Revelar en el Explorador de archivos" + "8e9d603a09": "Desanclar pestaña" }, "QuickLaunchButton": { "348a04c1ad": "Ajustes de Agents…", @@ -5712,7 +5719,7 @@ "3921d3d9a5": "No se encontró la carpeta del espacio de trabajo.", "f387af445b": "La ruta del espacio de trabajo no es una ruta local válida.", "3ec372b664": "gestor de archivos", - "localOnly": "Local only", + "localOnly": "Solo local", "remoteSsh": "Remote SSH", "remoteRuntimeUnsupported": "Opening this path in a local app is not available.", "remoteRuntimeUnsupportedDetail": "Switch to a local or SSH workspace, then try again.", @@ -15074,10 +15081,7 @@ "f0fd4174b5": "Abre la pestaña de archivo para usar la edición enriquecida de Markdown", "a10d9b8337": "Abrir archivo", "2076ecfc9c": "Cambio anterior", - "631dab0df3": "Cambio siguiente", - "revealInFinder": "Revelar en Finder", - "openContainingFolder": "Abrir carpeta contenedora", - "revealInFileExplorer": "Revelar en el Explorador de archivos" + "631dab0df3": "Cambio siguiente" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "Exportar como PDF", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 5ee417ffa54..b99a2c6b1bb 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -1033,6 +1033,16 @@ "tooLarge": "Trop volumineux pour être copié : Orca n'a pas pu le transmettre à l'agent.", "storageFull": "L'espace de stockage d'Orca pour les fichiers déposés est plein : Orca n'a pas pu le transmettre à l'agent.", "generic": "Réessayez de déposer les fichiers." + }, + "reveal": { + "in": { + "file": { + "manager": { + "notFound": "Fichier introuvable. Il a peut-être été déplacé ou supprimé.", + "launchFailed": "Impossible d’afficher le fichier." + } + } + } } }, "hooks": { @@ -3446,10 +3456,7 @@ "1d04b1630b": "Scinder vers le bas", "6b3efb106e": "Scinder vers le haut", "fdd29eb669": "Épingler l'onglet", - "8e9d603a09": "Détacher l'onglet", - "revealInFinder": "Révéler dans le Finder", - "openContainingFolder": "Ouvrir le dossier contenant", - "revealInFileExplorer": "Révéler dans l'explorateur de fichiers" + "8e9d603a09": "Détacher l'onglet" }, "QuickLaunchButton": { "348a04c1ad": "Paramètres de l'agent…", @@ -15177,10 +15184,7 @@ "f0fd4174b5": "Ouvrez un onglet de fichier pour utiliser l'édition Markdown enrichie", "a10d9b8337": "Ouvrir le fichier", "2076ecfc9c": "Modification précédente", - "631dab0df3": "Modification suivante", - "revealInFinder": "Révéler dans le Finder", - "openContainingFolder": "Ouvrir le dossier contenant", - "revealInFileExplorer": "Révéler dans l'explorateur de fichiers" + "631dab0df3": "Modification suivante" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "Exporter en PDF", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 8439f48cca6..817c968e22c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -934,6 +934,16 @@ "tooLarge": "サイズが大きすぎてコピーできないため、Orca は Agent に渡せませんでした。", "storageFull": "ドロップされたファイル用の Orca の保存先がいっぱいのため、Orca は Agent に渡せませんでした。", "generic": "もう一度ドロップしてください。" + }, + "reveal": { + "in": { + "file": { + "manager": { + "notFound": "ファイルが見つかりません。移動または削除された可能性があります。", + "launchFailed": "ファイルを表示できませんでした。" + } + } + } } }, "hooks": { @@ -3343,10 +3353,7 @@ "1d04b1630b": "下に分割", "6b3efb106e": "上に分割", "fdd29eb669": "ピンタブ", - "8e9d603a09": "タブの固定を解除する", - "revealInFinder": "ファインダーで表示", - "openContainingFolder": "含まれているフォルダーを開く", - "revealInFileExplorer": "ファイルエクスプローラーで表示" + "8e9d603a09": "タブの固定を解除する" }, "QuickLaunchButton": { "348a04c1ad": "Agent 設定…", @@ -15074,10 +15081,7 @@ "f0fd4174b5": "ファイルタブを開いてリッチ markdown 編集を使用する", "a10d9b8337": "ファイルを開く", "2076ecfc9c": "前の変更", - "631dab0df3": "次の変更", - "revealInFinder": "ファインダーで表示", - "openContainingFolder": "含まれているフォルダーを開く", - "revealInFileExplorer": "ファイルエクスプローラーで表示" + "631dab0df3": "次の変更" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "PDFとしてエクスポート", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 81d0be4d3da..3597fb22408 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -934,6 +934,16 @@ "tooLarge": "너무 커서 복사할 수 없어 Orca가 에이전트에 전달하지 못했습니다.", "storageFull": "드롭한 파일을 위한 Orca 저장 공간이 가득 차서 Orca가 에이전트에 전달하지 못했습니다.", "generic": "다시 드롭해 보세요." + }, + "reveal": { + "in": { + "file": { + "manager": { + "notFound": "파일을 찾을 수 없습니다. 이동되었거나 삭제되었을 수 있습니다.", + "launchFailed": "파일을 표시할 수 없습니다." + } + } + } } }, "hooks": { @@ -3343,10 +3353,7 @@ "1d04b1630b": "아래로 분할", "6b3efb106e": "위로 분할", "fdd29eb669": "탭 고정", - "8e9d603a09": "탭 고정 해제", - "revealInFinder": "Finder에 공개", - "openContainingFolder": "포함된 폴더 열기", - "revealInFileExplorer": "파일 탐색기에 표시" + "8e9d603a09": "탭 고정 해제" }, "QuickLaunchButton": { "348a04c1ad": "Agent 설정…", @@ -15074,10 +15081,7 @@ "f0fd4174b5": "풍부한 markdown 편집을 사용하려면 파일 탭을 엽니다.", "a10d9b8337": "파일 열기", "2076ecfc9c": "이전 변경", - "631dab0df3": "다음 변경", - "revealInFinder": "Finder에 공개", - "openContainingFolder": "포함된 폴더 열기", - "revealInFileExplorer": "파일 탐색기에 표시" + "631dab0df3": "다음 변경" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "PDF로 내보내기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e438542a3d8..fbcaa33d409 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -934,6 +934,16 @@ "tooLarge": "文件太大,无法复制,因此 Orca 无法将其交给代理。", "storageFull": "Orca 用于拖放文件的存储空间已满,因此 Orca 无法将其交给代理。", "generic": "请重新拖放。" + }, + "reveal": { + "in": { + "file": { + "manager": { + "notFound": "未找到文件。它可能已被移动或删除。", + "launchFailed": "无法显示该文件。" + } + } + } } }, "hooks": { @@ -3343,10 +3353,7 @@ "1d04b1630b": "向下拆分", "6b3efb106e": "向上拆分", "fdd29eb669": "固定标签", - "8e9d603a09": "取消固定标签", - "revealInFinder": "在 Finder 中显示", - "openContainingFolder": "打开包含的文件夹", - "revealInFileExplorer": "在文件资源管理器中显示" + "8e9d603a09": "取消固定标签" }, "QuickLaunchButton": { "348a04c1ad": "智能体设置...", @@ -15074,10 +15081,7 @@ "f0fd4174b5": "打开文件选项卡以使用富文本 Markdown 编辑", "a10d9b8337": "打开文件", "2076ecfc9c": "上一个更改", - "631dab0df3": "下一个更改", - "revealInFinder": "在 Finder 中显示", - "openContainingFolder": "打开包含的文件夹", - "revealInFileExplorer": "在文件资源管理器中显示" + "631dab0df3": "下一个更改" }, "EditorPanelMarkdownActionsMenu": { "3e0ce48c24": "导出为 PDF", diff --git a/src/renderer/src/lib/local-file-manager-label.ts b/src/renderer/src/lib/local-file-manager-label.ts index 58e4f1b6810..913bc07e063 100644 --- a/src/renderer/src/lib/local-file-manager-label.ts +++ b/src/renderer/src/lib/local-file-manager-label.ts @@ -1,11 +1,25 @@ -export function getLocalFileManagerLabel(userAgent?: string): string { +type LocalFileManager = 'finder' | 'file-explorer' | 'file-manager' + +export function getLocalFileManager(userAgent?: string): LocalFileManager { const resolvedUserAgent = userAgent ?? (typeof navigator === 'undefined' ? '' : navigator.userAgent) if (resolvedUserAgent.includes('Mac')) { - return 'Finder' + return 'finder' } if (resolvedUserAgent.includes('Windows')) { - return 'File Explorer' + return 'file-explorer' + } + return 'file-manager' +} + +/** The file manager's name, for lists of apps a path can be opened in. */ +export function getLocalFileManagerLabel(userAgent?: string): string { + switch (getLocalFileManager(userAgent)) { + case 'finder': + return 'Finder' + case 'file-explorer': + return 'File Explorer' + case 'file-manager': + return 'File Manager' } - return 'File Manager' } diff --git a/src/renderer/src/lib/reveal-in-file-manager.test.ts b/src/renderer/src/lib/reveal-in-file-manager.test.ts new file mode 100644 index 00000000000..c7a6ec7a78b --- /dev/null +++ b/src/renderer/src/lib/reveal-in-file-manager.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + getRevealInFileManagerLabel, + isRevealInFileManagerBlocked, + revealInFileManager +} from './reveal-in-file-manager' + +const toastError = vi.hoisted(() => vi.fn()) + +vi.mock('sonner', () => ({ toast: { error: toastError } })) +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +describe('getRevealInFileManagerLabel', () => { + afterEach(() => vi.unstubAllGlobals()) + + it.each([ + ['Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', 'Reveal in Finder'], + ['Mozilla/5.0 (Windows NT 10.0; Win64; x64)', 'Reveal in File Explorer'], + ['Mozilla/5.0 (X11; Linux x86_64)', 'Open Containing Folder'] + ])('names the file manager for %s', (userAgent, label) => { + vi.stubGlobal('navigator', { userAgent }) + + expect(getRevealInFileManagerLabel()).toBe(label) + }) +}) + +describe('isRevealInFileManagerBlocked', () => { + const local = { activeRuntimeEnvironmentId: null } + + it('allows a file this client owns', () => { + expect(isRevealInFileManagerBlocked(local, {})).toBe(false) + expect(isRevealInFileManagerBlocked(null, { connectionId: null })).toBe(false) + expect(isRevealInFileManagerBlocked(local, { runtimeEnvironmentId: null })).toBe(false) + }) + + it('blocks a file on an SSH host', () => { + expect(isRevealInFileManagerBlocked(local, { connectionId: 'ssh-1' })).toBe(true) + }) + + it('blocks a runtime-owned file even after the focused runtime switches to local', () => { + expect(isRevealInFileManagerBlocked(local, { runtimeEnvironmentId: 'env-1' })).toBe(true) + }) + + it('blocks every file while a remote runtime is focused, as the main process does', () => { + expect( + isRevealInFileManagerBlocked( + { activeRuntimeEnvironmentId: 'env-1' }, + { runtimeEnvironmentId: null } + ) + ).toBe(true) + }) +}) + +describe('revealInFileManager', () => { + const openInFileManager = vi.fn() + + beforeEach(() => { + toastError.mockReset() + openInFileManager.mockReset() + vi.stubGlobal('window', { api: { shell: { openInFileManager } } }) + }) + + afterEach(() => vi.unstubAllGlobals()) + + it('reveals the path without a toast when the OS accepts it', async () => { + openInFileManager.mockResolvedValue({ ok: true }) + + await revealInFileManager('/repo/src/foo.ts') + + expect(openInFileManager).toHaveBeenCalledWith('/repo/src/foo.ts') + expect(toastError).not.toHaveBeenCalled() + }) + + it('says the file is gone when it no longer exists', async () => { + openInFileManager.mockResolvedValue({ ok: false, reason: 'not-found' }) + + await revealInFileManager('/repo/src/deleted.ts') + + expect(toastError).toHaveBeenCalledWith('File not found. It may have been moved or deleted.') + }) + + it('says remote paths cannot be revealed when a remote runtime is focused', async () => { + openInFileManager.mockResolvedValue({ ok: false, reason: 'remote-runtime-unsupported' }) + + await revealInFileManager('/repo/src/foo.ts') + + expect(toastError).toHaveBeenCalledWith( + 'Opening remote paths in the local OS is not available.' + ) + }) + + it('reports a file manager that failed to launch', async () => { + openInFileManager.mockResolvedValue({ ok: false, reason: 'launch-failed' }) + + await revealInFileManager('/repo/src/foo.ts') + + expect(toastError).toHaveBeenCalledWith('Could not reveal the file.') + }) +}) diff --git a/src/renderer/src/lib/reveal-in-file-manager.ts b/src/renderer/src/lib/reveal-in-file-manager.ts new file mode 100644 index 00000000000..ae21bb07401 --- /dev/null +++ b/src/renderer/src/lib/reveal-in-file-manager.ts @@ -0,0 +1,60 @@ +import { toast } from 'sonner' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import { translate } from '@/i18n/i18n' +import { getLocalFileManager } from './local-file-manager-label' +import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from './local-path-open-guard' + +/** Menu label for showing a path in the OS file manager, as each platform names it. */ +export function getRevealInFileManagerLabel(): string { + switch (getLocalFileManager()) { + case 'finder': + return translate( + 'auto.components.right.sidebar.FileExplorerRow.revealInFinder', + 'Reveal in Finder' + ) + case 'file-explorer': + return translate( + 'auto.components.right.sidebar.FileExplorerRow.revealInFileExplorer', + 'Reveal in File Explorer' + ) + case 'file-manager': + return translate( + 'auto.components.right.sidebar.FileExplorerRow.openContainingFolder', + 'Open Containing Folder' + ) + } +} + +/** + * Whether the OS file manager cannot show a file: another host owns it, or a remote runtime is + * focused, which makes the main process refuse every reveal. + */ +export function isRevealInFileManagerBlocked( + settings: Pick | null | undefined, + owner: { connectionId?: string | null; runtimeEnvironmentId?: string | null } +): boolean { + return ( + isLocalPathOpenBlocked(settings, { connectionId: owner.connectionId }) || + Boolean(owner.runtimeEnvironmentId?.trim()) + ) +} + +/** Shows a client-local path selected in the OS file manager, and says why when it cannot. */ +export async function revealInFileManager(path: string): Promise { + const result = await window.api.shell.openInFileManager(path) + if (result.ok) { + return + } + if (result.reason === 'remote-runtime-unsupported') { + showLocalPathOpenBlockedToast() + return + } + toast.error( + result.reason === 'launch-failed' + ? translate('auto.lib.reveal.in.file.manager.launchFailed', 'Could not reveal the file.') + : translate( + 'auto.lib.reveal.in.file.manager.notFound', + 'File not found. It may have been moved or deleted.' + ) + ) +} diff --git a/tests/e2e/golden-source-control-open-diff.spec.ts b/tests/e2e/golden-source-control-open-diff.spec.ts index ed14e8207b6..e8c52c6e3d4 100644 --- a/tests/e2e/golden-source-control-open-diff.spec.ts +++ b/tests/e2e/golden-source-control-open-diff.spec.ts @@ -50,3 +50,28 @@ test('@golden opens an unstaged file diff from Source Control', async ({ await probe.focus() await expect(probe).toBeFocused() }) + +test('@golden offers to reveal a changed file in the OS file manager from its Source Control row', async ({ + orcaPage, + testRepoPath, + registerPostElectronShutdownCleanup +}) => { + const fixture = createGoldenWorktree(testRepoPath, 'reveal-row') + registerPostElectronShutdownCleanup(async () => cleanupGoldenWorktree(testRepoPath, fixture)) + seedGoldenSourceEdit(fixture.worktreePath) + + await waitForSessionReady(orcaPage) + await openGoldenSourceControl(orcaPage, testRepoPath, fixture) + + const changedFile = orcaPage + .locator('[data-testid="source-control-entry"]') + .filter({ hasText: path.basename(GOLDEN_CHANGED_PATH) }) + await expect(changedFile).toBeVisible({ timeout: 15_000 }) + await changedFile.click({ button: 'right' }) + + await expect( + orcaPage.getByRole('menuitem', { + name: /Reveal in Finder|Reveal in File Explorer|Open Containing Folder/ + }) + ).toBeEnabled() +}) From 420447e0636db705190d9ecd24ee895946bb4b7c Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:12:38 +0000 Subject: [PATCH 04/48] fix(agent-status): retire a removed worktree's hook-status rows (#23881) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agent-status): retire a removed worktree's hook-status rows Rows whose terminal was never reattached had no teardown path, so they outlived the worktree in last-status.json for up to 7 days. Fixes #23068 * fix(agent-status): skip panes another owner has since reclaimed * fix(agent-status): clear only the removed owner's claims on a shared pane * fix(agent-status): retire hook-status rows a host scan proved removed `worktrees:forgetRemovedForExecutionHost` is the only path that ever retires an off-host WorktreeMeta row: gcStaleWorktreeMeta skips any row whose repo or hostId is not local. It already prunes the cleanup and space-analysis snapshots for a worktree a remote scan proved gone, but left that worktree's hook-status rows behind in `last-status.json` — the same stranding this branch fixes for the in-Orca delete, reached through the other trigger. A scan the host answered is positive evidence of removal rather than loss of contact, so it is the host evidence `ssh-execution-boundary.md` requires, and it publishes no verdict. The drop is scoped to the scanned host's id, so a same-id worktree on another connection keeps its rows, and a runtime host falls through the method's own early return because a paired server owns its own store. Also names the shared-pane condition in `dropStatusEntriesForRemovedWorktree`, which was the one place the two-owner logic was hard to read. * fix(agent-status): stop a removed worktree's row returning on a shared pane The mixed-owner branch deleted the removed worktree's row but left the pane unfenced, so the stale row came straight back. `getAgentStatusDisposition` returns `accept` for an unfenced pane, and the removed worktree's agent can still post a late turn on a pane it shares with another owner — I reproduced the `working` row being rewritten to memory and to `last-status.json`, which is the symptom #23068 is about. A launch-token fence cannot close this: `ingestTerminalStatus` calls the disposition gate with no event, so the token check never runs and an OSC report carries no token to check. The pane fence the sole-owner path already uses does suppress it, and it lifts on PTY reattach or a new agent's turn. Fencing the pane would otherwise discard the surviving owner's claims, which is what the branch existed to protect, so both of its records are captured first and restored after: its persisted authority commitment (its resume identity) and its current authority observation. The observation also fixes a second defect on this path — `deleteStatusEntry` drops it whatever `preserveAuthority` says, so the surviving owner silently lost `current_runtime` attestation until its next hook event. Both are covered by tests that fail against the previous commit. * fix(agent-status): decide a reused pane by its occupant, and retire rows for local scan-proved removals A pane has one terminal, so its newest row names who occupies it. A saved commitment naming a different owner is what an earlier occupant left behind, and serialization already drops it while the row disagrees. Removal now retires a pane the removed worktree occupies, and on a pane another owner occupies it clears only the removed worktree's outlived commitment. This replaces the capture-and-restore handling of two-owner panes. The local authoritative-scan prune is the local twin of the SSH scan forget: it drops a worktree's metadata once the scan proves it gone, and now retires its status rows too. * fix(agent-status): preserve foreign authority during worktree removal * fix(agent-status): preserve terminal connection validation * fix(agent-status): keep ordinary OSC admission unchanged * refactor(agent-status): colocate the remote envelope type * fix(agent-status): revoke removed startup authority evidence * fix(agent-status): retain foreign startup claims during removal --------- Co-authored-by: Neil Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- docs/reference/agent-status-store.md | 10 +- ...removed-worktree-foreign-authority.test.ts | 283 ++++++++++++++++++ .../server-removed-worktree-status.test.ts | 175 +++++++++++ src/main/agent-hooks/server/server-cleanup.ts | 81 +++++ .../server/server-ingest-remote.ts | 48 +-- .../server/server-ingest-terminal.ts | 21 +- .../server-remote-envelope-normalization.ts | 32 ++ .../server/server-row-ownership.ts | 16 +- src/main/agent-hooks/server/server-state.ts | 6 +- .../server/server-status-disposition.ts | 21 +- ...thoritative-local-metadata-pruning.test.ts | 25 ++ .../ipc/worktrees-removal-recovery.test.ts | 81 ++++- ...itative-local-worktree-metadata-pruning.ts | 4 + .../listing/register-host-catalog-handlers.ts | 5 + .../removal/worktree-removal-ownership.ts | 3 + src/main/orcad/orcad-entry.ts | 2 + .../agent-status-store-wiring.test-fixture.ts | 5 +- .../orca-runtime-preserved-branch-cleanup.ts | 7 + ...runtime-resolve-worktree-removal-target.ts | 2 + src/main/runtime/orca-runtime-state-fields.ts | 3 + ...rca-runtime-test-scenario-builders.spec.ts | 6 +- ...orktree-removal-and-reconciliation.spec.ts | 43 +++ .../startup/main-process-runtime-service.ts | 2 + 23 files changed, 810 insertions(+), 71 deletions(-) create mode 100644 src/main/agent-hooks/server-removed-worktree-foreign-authority.test.ts create mode 100644 src/main/agent-hooks/server-removed-worktree-status.test.ts diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index dcd9c308f2a..912b14d51fe 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -26,11 +26,11 @@ the structured-session mapping and nothing else. An audit on 2026-09-09 found six producers and three consumers, and three separate copies of the same row inside the main process alone: -| Main-process copy | Keyed by | Owned by | Persisted | Evicted | -| --------------------------------- | --------- | --------------------------------------------------------------------------------- | ------------------ | ---------------------------- | -| hook server `lastStatusByPaneKey` | paneKey | `src/main/agent-hooks/server.ts` | `last-status.json` | tab close, pty exit, hydrate | -| runtime `RuntimeAgentRowStore` | paneKey | `runtime-agent-row-store.ts` (deleted in PR 1b) | no | pty exit only | -| structured feed `published` | sessionId | `src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts` | no | never (a broadcast cache) | +| Main-process copy | Keyed by | Owned by | Persisted | Evicted | +| --------------------------------- | --------- | --------------------------------------------------------------------------------- | ------------------ | ---------------------------------------------- | +| hook server `lastStatusByPaneKey` | paneKey | `src/main/agent-hooks/server.ts` | `last-status.json` | tab close, pty exit, hydrate, worktree removal | +| runtime `RuntimeAgentRowStore` | paneKey | `runtime-agent-row-store.ts` (deleted in PR 1b) | no | pty exit only | +| structured feed `published` | sessionId | `src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts` | no | never (a broadcast cache) | The second copy is a duplicate write: the OSC status parsed in main is forwarded to the hook server _and_ retained in the runtime store from the same diff --git a/src/main/agent-hooks/server-removed-worktree-foreign-authority.test.ts b/src/main/agent-hooks/server-removed-worktree-foreign-authority.test.ts new file mode 100644 index 00000000000..daa99b603c1 --- /dev/null +++ b/src/main/agent-hooks/server-removed-worktree-foreign-authority.test.ts @@ -0,0 +1,283 @@ +import { createHash } from 'node:crypto' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AgentHookServer, _internals } from './server' +import { makePaneKey } from '../../shared/stable-pane-id' +import { LEAF_1 } from './server.test-fixtures' + +const REMOVED = 'repo::/removed' +const KEPT = 'repo::/kept' +const PANE = makePaneKey('tab-foreign', LEAF_1) +const TOKEN = 'foreign-launch' +const HASH = createHash('sha256').update(TOKEN).digest('hex') +const working = { state: 'working', prompt: 'live', agentType: 'codex' } as const + +describe('removed-worktree foreign authority', () => { + let userDataPath: string + beforeEach(() => { + _internals.resetCachesForTests() + userDataPath = mkdtempSync(join(tmpdir(), 'orca-foreign-authority-')) + }) + afterEach(() => rmSync(userDataPath, { recursive: true, force: true })) + + it('preserves ordinary tokenless OSC after a tokened new turn revives a pane', () => { + const server = new AgentHookServer() + server.retirePaneAuthority(PANE) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-foreign', + worktreeId: KEPT, + launchToken: TOKEN, + source: 'codex', + hookEventName: 'SessionStart', + payload: working + }, + null + ) + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-foreign', + worktreeId: KEPT, + connectionId: null, + payload: { ...working, state: 'done' } + }) + expect(server.getStatusSnapshot()).toMatchObject([{ worktreeId: KEPT, state: 'done' }]) + server.stop() + }) + + it('keeps foreign hydrated evidence when a removed commitment outlives its row', async () => { + const seed = new AgentHookServer() + await seed.start({ env: 'production', userDataPath }) + seed.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-foreign', + worktreeId: KEPT, + launchToken: TOKEN, + payload: working + }, + 'user@box' + ) + seed.flushStatusPersistSync() + seed.stop() + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-foreign', + worktreeId: REMOVED, + launchToken: 'removed-launch', + payload: working + }, + null + ) + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-foreign', + worktreeId: KEPT, + connectionId: 'user@box', + payload: working + }) + server.dropStatusEntriesForRemovedWorktree(REMOVED, 'local') + expect(server.getStatusSnapshot()).toMatchObject([ + { worktreeId: KEPT, connectionId: 'user@box' } + ]) + expect( + server.attestCompatibilityAuthority({ + paneKey: PANE, + launchTokenHash: HASH, + connectionId: 'user@box', + terminalProvenance: 'restored' + }) + ).toEqual({ paneKey: PANE, source: 'hydrated_commitment' }) + expect(server.getCurrentAuthorityObservations()).toEqual([]) + server.flushStatusPersistSync() + const file = JSON.parse( + readFileSync(join(userDataPath, 'agent-hooks', 'last-status.json'), 'utf8') + ) + expect(file.authorityCommitments[PANE]).toBeUndefined() + } finally { + server.stop() + } + }) + + it.each([ + { owner: REMOVED, connectionId: null }, + { owner: KEPT, connectionId: 'user@box' }, + { owner: REMOVED, connectionId: 'user@box' } + ])( + 'revokes hydrated evidence only for removed $owner on $connectionId', + async ({ owner, connectionId }) => { + const token = connectionId === null ? 'removed-launch' : TOKEN + const hash = createHash('sha256').update(token).digest('hex') + const seed = new AgentHookServer() + await seed.start({ env: 'production', userDataPath }) + seed.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-foreign', + worktreeId: owner, + launchToken: token, + payload: working + }, + connectionId + ) + seed.flushStatusPersistSync() + seed.stop() + + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + const attest = () => + server.attestCompatibilityAuthority({ + paneKey: PANE, + launchTokenHash: hash, + connectionId, + terminalProvenance: 'restored' + }) + try { + expect(attest()).toEqual({ paneKey: PANE, source: 'hydrated_commitment' }) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-foreign', + worktreeId: KEPT, + launchToken: TOKEN, + payload: working + }, + 'user@box' + ) + server.clearStatusEntriesForConnection('user@box') + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-foreign', + worktreeId: REMOVED, + connectionId: null, + payload: working + }) + server.dropStatusEntriesForRemovedWorktree(REMOVED, 'local') + expect(attest()).toEqual( + owner === REMOVED && connectionId === null + ? null + : { paneKey: PANE, source: 'hydrated_commitment' } + ) + server.flushStatusPersistSync() + const file = JSON.parse( + readFileSync(join(userDataPath, 'agent-hooks', 'last-status.json'), 'utf8') + ) + expect(file.authorityCommitments[PANE]).toMatchObject({ + worktreeId: KEPT, + connectionId: 'user@box', + launchTokenHash: HASH + }) + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-foreign', + worktreeId: KEPT, + launchToken: TOKEN, + payload: working + }, + 'user@box' + ) + expect( + server.attestCompatibilityAuthority({ + paneKey: PANE, + launchTokenHash: HASH, + connectionId: 'user@box', + terminalProvenance: 'current_runtime' + }) + ).toEqual({ paneKey: PANE, source: 'current_hook' }) + } finally { + server.stop() + } + } + ) + + it.each([false, true])( + 'keeps a foreign claim after removed OSC with disconnect=%s', + async (disconnect) => { + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + const remote = (state: 'working' | 'done') => + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-foreign', + worktreeId: KEPT, + launchToken: TOKEN, + payload: { ...working, state } + }, + 'user@box' + ) + const terminal = (worktreeId: string, connectionId: string | null) => + server.ingestTerminalStatus({ + paneKey: PANE, + tabId: 'tab-foreign', + worktreeId, + connectionId, + payload: working + }) + const attest = () => + server.attestCompatibilityAuthority({ + paneKey: PANE, + launchTokenHash: HASH, + connectionId: 'user@box', + terminalProvenance: 'current_runtime' + }) + try { + remote('working') + if (disconnect) { + server.clearStatusEntriesForConnection('user@box') + } + terminal(REMOVED, null) + if (!disconnect) { + expect(attest()).not.toBeNull() + } + + server.dropStatusEntriesForRemovedWorktree(REMOVED, 'local') + if (!disconnect) { + expect(attest()).not.toBeNull() + } + server.flushStatusPersistSync() + const file = JSON.parse( + readFileSync(join(userDataPath, 'agent-hooks', 'last-status.json'), 'utf8') + ) + expect(file.authorityCommitments[PANE]).toMatchObject({ + worktreeId: KEPT, + connectionId: 'user@box', + launchTokenHash: HASH + }) + expect(file.entries[PANE]).toBeUndefined() + + terminal(REMOVED, null) + expect(server.getStatusSnapshot()).toHaveLength(0) + server.ingestRemote( + { paneKey: PANE, tabId: 'tab-foreign', worktreeId: REMOVED, payload: working }, + 'user@box' + ) + expect(server.getStatusSnapshot()).toHaveLength(0) + server.ingestRemote( + { paneKey: PANE, tabId: 'tab-foreign', worktreeId: KEPT, payload: working }, + 'user@box' + ) + expect(server.getStatusSnapshot()).toMatchObject([ + { worktreeId: KEPT, connectionId: 'user@box' } + ]) + terminal(KEPT, 'user@box') + expect(server.getStatusSnapshot()).toMatchObject([ + { worktreeId: KEPT, connectionId: 'user@box' } + ]) + remote('done') + expect(server.getStatusSnapshot()).toMatchObject([{ worktreeId: KEPT, state: 'done' }]) + expect(attest()).not.toBeNull() + } finally { + server.stop() + } + } + ) +}) diff --git a/src/main/agent-hooks/server-removed-worktree-status.test.ts b/src/main/agent-hooks/server-removed-worktree-status.test.ts new file mode 100644 index 00000000000..d40540326c0 --- /dev/null +++ b/src/main/agent-hooks/server-removed-worktree-status.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { AgentHookServer, _internals } from './server' +import { makePaneKey } from '../../shared/stable-pane-id' +import { LEAF_1, LEAF_2, LEAF_3, LEAF_4, LEAF_5, recentTs } from './server.test-fixtures' + +const REMOVED = 'repo-1::/workspace/removed' +const KEPT = 'repo-1::/workspace/kept' +const LOCAL_PANE = makePaneKey('tab-local', LEAF_1) +const WSL_PANE = makePaneKey('tab-wsl', LEAF_2) +const SSH_PANE = makePaneKey('tab-ssh', LEAF_3) +const SSH_COMMITMENT_PANE = makePaneKey('tab-ssh-idle', LEAF_4) +const OTHER_PANE = makePaneKey('tab-other', LEAF_5) + +function row(paneKey: string, worktreeId: string, connectionId: string | null) { + const receivedAt = recentTs() + return { + paneKey, + tabId: paneKey.split(':')[0], + worktreeId, + connectionId, + receivedAt, + stateStartedAt: receivedAt, + payload: { state: 'working', prompt: 'stranded', agentType: 'codex' } + } +} + +describe('AgentHookServer removed-worktree retirement', () => { + let userDataPath: string + const lastStatusPath = () => join(userDataPath, 'agent-hooks', 'last-status.json') + + beforeEach(() => { + _internals.resetCachesForTests() + userDataPath = mkdtempSync(join(tmpdir(), 'orca-removed-worktree-')) + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + lastStatusPath(), + JSON.stringify({ + version: 2, + entries: { + [LOCAL_PANE]: row(LOCAL_PANE, REMOVED, null), + [WSL_PANE]: row(WSL_PANE, REMOVED, 'wsl:Ubuntu'), + [SSH_PANE]: row(SSH_PANE, REMOVED, 'user@box'), + [OTHER_PANE]: row(OTHER_PANE, KEPT, null) + } + }), + 'utf8' + ) + }) + + afterEach(() => { + rmSync(userDataPath, { recursive: true, force: true }) + }) + + it('retires only the removing host rows and commitments, then persists the pruned map', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + // An SSH commitment recorded this session outlives its row across a disconnect clear. + server.ingestRemote( + { + paneKey: SSH_COMMITMENT_PANE, + tabId: 'tab-ssh-idle', + worktreeId: REMOVED, + launchToken: 'idle-launch', + payload: { state: 'working', prompt: 'idle', agentType: 'codex' } + }, + 'idle@box' + ) + server.clearStatusEntriesForConnection('idle@box') + const persisted = () => { + server.flushStatusPersistSync() + const file = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + return { + entries: Object.keys(file.entries).sort(), + commitments: Object.keys(file.authorityCommitments ?? {}) + } + } + + server.dropStatusEntriesForRemovedWorktree(REMOVED, 'runtime:env-1') + expect(persisted().entries).toHaveLength(4) + + server.dropStatusEntriesForRemovedWorktree(REMOVED, 'local') + expect(persisted()).toEqual({ + entries: [OTHER_PANE, SSH_PANE].sort(), + commitments: [SSH_COMMITMENT_PANE] + }) + + server.dropStatusEntriesForRemovedWorktree(REMOVED, 'ssh:user%40box') + expect(persisted()).toEqual({ entries: [OTHER_PANE], commitments: [SSH_COMMITMENT_PANE] }) + + server.dropStatusEntriesForRemovedWorktree(REMOVED, 'ssh:idle%40box') + expect(persisted().commitments).toEqual([]) + } finally { + server.stop() + } + }) + + it('fences only the retired pane, so its kept tab still reports a new agent', async () => { + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + server.dropStatusEntriesForRemovedWorktree(REMOVED, 'local') + const newPane = makePaneKey('tab-local', '66666666-6666-4666-8666-666666666666') + const done = { state: 'done', prompt: 'late', agentType: 'codex' } as const + server.ingestTerminalStatus({ paneKey: LOCAL_PANE, connectionId: null, payload: done }) + server.ingestTerminalStatus({ paneKey: newPane, connectionId: null, payload: done }) + + const panes = server.getStatusSnapshot().map((entry) => entry.paneKey) + expect(panes).toContain(newPane) + expect(panes).not.toContain(LOCAL_PANE) + } finally { + server.stop() + } + }) + + it.each([ + { occupant: 'the removed worktree', sshWorktree: KEPT, localWorktree: REMOVED, host: 'local' }, + { + occupant: 'another owner', + sshWorktree: REMOVED, + localWorktree: KEPT, + host: 'ssh:user%40box' + } + ] as const)( + 'decides a reused pane by its occupant: $occupant', + async ({ sshWorktree, localWorktree, host }) => { + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + try { + const pane = makePaneKey('tab-reused', '77777777-7777-4777-8777-777777777777') + const working = { state: 'working', prompt: 'live', agentType: 'codex' } as const + const reportLocally = (state: 'working' | 'done') => + server.ingestTerminalStatus({ + paneKey: pane, + worktreeId: localWorktree, + connectionId: null, + payload: { ...working, state } + }) + server.ingestRemote( + { + paneKey: pane, + tabId: 'tab-reused', + worktreeId: sshWorktree, + launchToken: 'ssh', + payload: working + }, + 'user@box' + ) + server.clearStatusEntriesForConnection('user@box') + reportLocally('working') + + server.dropStatusEntriesForRemovedWorktree(REMOVED, host) + reportLocally('done') + + server.flushStatusPersistSync() + const file = JSON.parse(readFileSync(lastStatusPath(), 'utf8')) + if (localWorktree === REMOVED) { + expect(file.authorityCommitments?.[pane]).toMatchObject({ worktreeId: KEPT }) + // The retained foreign launch fence rejects the removed workspace's late repaint. + expect(file.entries[pane]).toBeUndefined() + } else { + expect(file.authorityCommitments?.[pane]).toBeUndefined() + // The occupant keeps reporting, without the removed owner's token hash stamped on its row. + expect(file.entries[pane]).toMatchObject({ worktreeId: KEPT, payload: { state: 'done' } }) + expect(file.entries[pane].launchTokenHash).toBeUndefined() + } + } finally { + server.stop() + } + } + ) +}) diff --git a/src/main/agent-hooks/server/server-cleanup.ts b/src/main/agent-hooks/server/server-cleanup.ts index 15a773a9b22..252c995a95c 100644 --- a/src/main/agent-hooks/server/server-cleanup.ts +++ b/src/main/agent-hooks/server/server-cleanup.ts @@ -1,11 +1,19 @@ import type { AgentProcessPresence } from '../../../shared/agent-process-presence' import { admitLegacyAgentStatus, + clearPaneCacheState, deleteLegacyAgentStatus, paneHasStateClaims } from '../../../shared/agent-hook-listener/listener-state' import { AGENT_STATUS_2A_CURRENT_PRODUCER_MODE } from '../../../shared/agent-status-legacy-adapter' import type { AgentStatusCacheIdentity } from '../../../shared/agent-status-types' +import { + ALL_EXECUTION_HOSTS_SCOPE, + parseExecutionHostId, + type ExecutionHostScope +} from '../../../shared/execution-host' +import { worktreeIdsEqual } from '../../../shared/worktree/id' +import { isWslHookRelayConnectionId } from '../../../shared/wsl-hook-relay-contract' import type { EnrichedAgentHookEventPayload } from './server-types' import { AgentHookServerAuthorityFences } from './server-authority-fences' @@ -171,6 +179,79 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen return Boolean(this.getTmuxSelectedStatus(paneKey)) || paneHasStateClaims(this.state, paneKey) } + /** Retire the panes a removed worktree occupied on `host`, and its leftover claim on any other pane. */ + dropStatusEntriesForRemovedWorktree(worktreeId: string, host?: ExecutionHostScope): void { + const parsed = host === ALL_EXECUTION_HOSTS_SCOPE ? null : parseExecutionHostId(host ?? 'local') + // Why: a runtime host keeps its own store, so no row here is its to retire. + if (host !== ALL_EXECUTION_HOSTS_SCOPE && (!parsed || parsed.kind === 'runtime')) { + return + } + const ownedByRemoved = (claim: { connectionId: string | null; worktreeId?: string }): boolean => + Boolean(claim.worktreeId && worktreeIdsEqual(claim.worktreeId, worktreeId)) && + (!parsed || + (parsed.kind === 'ssh' + ? claim.connectionId === parsed.targetId + : // Why: WSL panes are local; their relay only stamps transport provenance. + claim.connectionId === null || isWslHookRelayConnectionId(claim.connectionId))) + // The startup snapshot may outlive its replaced map entry; revoke only the removed owner. + for (const commitment of this.hydratedAuthorityCommitments) { + if (ownedByRemoved(commitment)) { + this.revokedHydratedAuthorityCommitments.add(commitment) + } + } + const paneKeys = new Set() + for (const claim of [ + ...this.state.lastStatusByPaneKey.values(), + ...this.persistedAuthorityCommitmentsByPaneKey.values() + ]) { + if (ownedByRemoved(claim)) { + paneKeys.add(claim.paneKey) + } + } + for (const paneKey of paneKeys) { + const row = this.state.lastStatusByPaneKey.get(paneKey) + const commitment = this.persistedAuthorityCommitmentsByPaneKey.get(paneKey) + if (row && ownedByRemoved(row) && commitment && !ownedByRemoved(commitment)) { + // A tokenless repaint cannot prove a foreign launch exited; retain it behind its token fence. + const observation = this.currentAuthorityObservations.get(paneKey) + const deleted = this.deleteStatusEntry(paneKey, { preserveAuthority: true }) + clearPaneCacheState(this.state, paneKey) + if (observation && !ownedByRemoved(observation)) { + this.currentAuthorityObservations.set(paneKey, observation) + } + this.restartedStatusLaunchTokenHashByPaneKey.set(paneKey, { + hash: commitment.launchTokenHash, + allowRetainedOwner: true + }) + this.observations.forget(paneKey) + this.commitStatusRowMutation(deleted, undefined) + this.scheduleStatusPersist() + this.notifyStatusChangeListeners() + this.emitPaneStatusCleared({ paneKey }) + continue + } + const occupant = row ?? commitment + if (occupant && !ownedByRemoved(occupant)) { + // Another owner has the pane now; only our outlived commitment is left to clear. + if (commitment && ownedByRemoved(commitment)) { + this.persistedAuthorityCommitmentsByPaneKey.delete(paneKey) + this.hydratedLaunchTokenHashByPaneKey.delete(paneKey) + this.scheduleStatusPersist() + } + const observation = this.currentAuthorityObservations.get(paneKey) + if (observation && ownedByRemoved(observation)) { + this.currentAuthorityObservations.delete(paneKey) + } + continue + } + // Why a pane fence, not a tab one: a surviving same-id host keeps the shared tab. + this.retirePaneAuthority(paneKey) + if (row) { + this.emitPaneStatusCleared({ paneKey }) + } + } + } + /** Clear statuses proven to belong to one lost SSH transport. */ clearStatusEntriesForConnection(connectionId: string): void { const normalizedConnectionId = connectionId.trim() diff --git a/src/main/agent-hooks/server/server-ingest-remote.ts b/src/main/agent-hooks/server/server-ingest-remote.ts index 5fc4456f30f..787f3ed0cef 100644 --- a/src/main/agent-hooks/server/server-ingest-remote.ts +++ b/src/main/agent-hooks/server/server-ingest-remote.ts @@ -21,45 +21,15 @@ import { olderPeerAgentStatusLegacyMode } from '../../../shared/agent-status-legacy-adapter' import { isValidPiProviderSessionOnly } from './server-status-identity' -import { normalizeRemoteEnvelopeFields } from './server-remote-envelope-normalization' +import { + normalizeRemoteEnvelopeFields, + type RemoteAgentStatusEnvelope +} from './server-remote-envelope-normalization' import { AgentHookServerIngestStructuredChildren } from './server-ingest-structured-children' export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestStructuredChildren { /** Ingest a payload from the relay JSON-RPC channel (not the local HTTP server); connectionId is stamped here. Main is still the SSH trust boundary, so re-run the canonical normalizer before caching. */ - ingestRemote( - envelope: { - paneKey: string - tabId?: string - worktreeId?: string - env?: string - version?: string - launchToken?: string - hasExplicitPrompt?: boolean - promptInteractionKey?: string - agentPresence?: unknown - hookEventName?: string - source?: unknown - providerPromptId?: unknown - grokPromptBoundary?: unknown - compactTrigger?: unknown - toolUseId?: string - toolAgentId?: string - teammateName?: string - toolAgentType?: string - providerSession?: unknown - providerSessionOnly?: unknown - isReplay?: boolean - /** Payload fields the relay dropped to fit an oversized frame; validated below. */ - shedFields?: unknown - claudeRunningNonAgentTask?: unknown - /** The producing peer's advertised run-capability set — a property of the peer/connection that built this envelope, not an orthogonal call parameter. Absent (older relay/HTTP paths) defaults to the unadvertised-legacy-peer set. */ - advertisedAgentStatusCapabilities?: readonly string[] - statusUnavailable?: unknown - evidenceAgeMs?: unknown - payload: unknown - }, - connectionId: string | null - ): void { + ingestRemote(envelope: RemoteAgentStatusEnvelope, connectionId: string | null): void { if ( !canAdmitLegacyAgentStatus( 'main-status-update', @@ -194,7 +164,13 @@ export abstract class AgentHookServerIngestRemote extends AgentHookServerIngestS hookEventName, isReplay: envelope.isReplay === true, hasExplicitPrompt: envelope.hasExplicitPrompt === true, - launchToken: envelope.launchToken + launchToken: envelope.launchToken, + retainedLaunchTokenHash: envelope.launchToken?.trim() + ? undefined + : this.retainedOwnerLaunchTokenHash(paneKey, { + worktreeId, + connectionId: trimmedConnectionId + }) }) if (statusDisposition === 'suppress') { return diff --git a/src/main/agent-hooks/server/server-ingest-terminal.ts b/src/main/agent-hooks/server/server-ingest-terminal.ts index 63355e3592b..004a6462d27 100644 --- a/src/main/agent-hooks/server/server-ingest-terminal.ts +++ b/src/main/agent-hooks/server/server-ingest-terminal.ts @@ -47,12 +47,23 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges return } const tabId = paneKey !== physicalPaneKey ? parsedPaneKey?.tabId : reportedTabId + const worktreeId = event.worktreeId?.trim() || undefined + const connectionId = + typeof event.connectionId === 'string' && event.connectionId.trim().length > 0 + ? event.connectionId.trim() + : null + const retainedLaunchTokenHash = this.retainedOwnerLaunchTokenHash(paneKey, { + worktreeId, + connectionId + }) // Why: a verified process-lifetime Working proves a new agent run, as a hook new-turn event does. const disposition = this.getAgentStatusDisposition( paneKey, event.origin === 'process' && event.payload.state === 'working' ? { processNewTurn: true } - : undefined + : this.restartedStatusLaunchTokenHashByPaneKey.get(paneKey)?.allowRetainedOwner + ? { retainedLaunchTokenHash } + : undefined ) if (disposition === 'suppress') { return @@ -60,14 +71,6 @@ export abstract class AgentHookServerIngestTerminal extends AgentHookServerInges if (disposition === 'restart') { this.observations.rebind(paneKey) } - const worktreeId = - event.worktreeId !== undefined && event.worktreeId.trim().length > 0 - ? event.worktreeId.trim() - : undefined - const connectionId = - typeof event.connectionId === 'string' && event.connectionId.trim().length > 0 - ? event.connectionId.trim() - : null const terminalHandle = typeof event.terminalHandle === 'string' && event.terminalHandle.trim().length > 0 ? event.terminalHandle.trim() diff --git a/src/main/agent-hooks/server/server-remote-envelope-normalization.ts b/src/main/agent-hooks/server/server-remote-envelope-normalization.ts index 3765c8c0a50..0f7f009d1ad 100644 --- a/src/main/agent-hooks/server/server-remote-envelope-normalization.ts +++ b/src/main/agent-hooks/server/server-remote-envelope-normalization.ts @@ -5,6 +5,38 @@ import { } from '../../../shared/agent-hook-listener/listener-limits' import { isAgentHookSource, type AgentHookSource } from '../../../shared/agent-hook-relay' +export type RemoteAgentStatusEnvelope = { + paneKey: string + tabId?: string + worktreeId?: string + env?: string + version?: string + launchToken?: string + hasExplicitPrompt?: boolean + promptInteractionKey?: string + agentPresence?: unknown + hookEventName?: string + source?: unknown + providerPromptId?: unknown + grokPromptBoundary?: unknown + compactTrigger?: unknown + toolUseId?: string + toolAgentId?: string + teammateName?: string + toolAgentType?: string + providerSession?: unknown + providerSessionOnly?: unknown + isReplay?: boolean + /** Payload fields the relay dropped to fit an oversized frame; validated below. */ + shedFields?: unknown + claudeRunningNonAgentTask?: unknown + /** The producing peer's advertised run-capability set — a property of the peer/connection that built this envelope, not an orthogonal call parameter. Absent (older relay/HTTP paths) defaults to the unadvertised-legacy-peer set. */ + advertisedAgentStatusCapabilities?: readonly string[] + statusUnavailable?: unknown + evidenceAgeMs?: unknown + payload: unknown +} + export type RemoteEnvelopeFields = { hookEventName?: string source?: AgentHookSource diff --git a/src/main/agent-hooks/server/server-row-ownership.ts b/src/main/agent-hooks/server/server-row-ownership.ts index cde178e335d..e8540392075 100644 --- a/src/main/agent-hooks/server/server-row-ownership.ts +++ b/src/main/agent-hooks/server/server-row-ownership.ts @@ -79,7 +79,7 @@ export abstract class AgentHookServerRowOwnership extends AgentHookServerListene } protected sameTerminalOwner( - previous: EnrichedAgentHookEventPayload, + previous: Pick, incoming: Pick ): boolean { if ( @@ -112,6 +112,20 @@ export abstract class AgentHookServerRowOwnership extends AgentHookServerListene ) } + protected retainedOwnerLaunchTokenHash( + paneKey: string, + incoming: Pick + ): string | undefined { + const fence = this.restartedStatusLaunchTokenHashByPaneKey.get(paneKey) + const authority = this.persistedAuthorityCommitmentsByPaneKey.get(paneKey) + return fence?.allowRetainedOwner && + authority?.worktreeId && + incoming.worktreeId && + this.sameTerminalOwner(authority, incoming) + ? authority.launchTokenHash + : undefined + } + protected commitStatusRowMutation( before: EnrichedAgentHookEventPayload | null | undefined, after: EnrichedAgentHookEventPayload | null | undefined, diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 172eaeaf1ef..db61f046e24 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -148,7 +148,10 @@ export abstract class AgentHookServerState { protected promptSentHashSalt = randomBytes(16).toString('hex') protected closedAgentStatusTabIds = new Set() protected closedAgentStatusPaneKeys = new Set() - protected restartedStatusLaunchTokenHashByPaneKey = new Map() + protected restartedStatusLaunchTokenHashByPaneKey = new Map< + string, + { hash: string; allowRetainedOwner?: true } + >() protected connectionTimestampWatermarkById = new Map() // Why: survives the row itself. A transport clear deletes the pane's status row on purpose // (absence, not completion), but the *age* of the evidence a later replay restates is not a @@ -191,6 +194,7 @@ export abstract class AgentHookServerState { isReplay?: boolean hasExplicitPrompt?: boolean launchToken?: string + retainedLaunchTokenHash?: string } ): 'accept' | 'restart' | 'suppress' protected abstract isClosedAgentStatusTabForPaneKey(paneKey: string): boolean diff --git a/src/main/agent-hooks/server/server-status-disposition.ts b/src/main/agent-hooks/server/server-status-disposition.ts index ef633d7c2c8..8b709aeb46a 100644 --- a/src/main/agent-hooks/server/server-status-disposition.ts +++ b/src/main/agent-hooks/server/server-status-disposition.ts @@ -50,6 +50,8 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt isReplay?: boolean hasExplicitPrompt?: boolean launchToken?: string + /** Host/workspace provenance matched internally to a retained authority commitment. */ + retainedLaunchTokenHash?: string /** A process-lifetime Working: a fresh command whose foreground argv proves a new agent run. */ processNewTurn?: boolean } @@ -89,16 +91,18 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt ) { const startedLaunchToken = event.launchToken?.trim() if (startedLaunchToken) { - this.restartedStatusLaunchTokenHashByPaneKey.set( - ownerPaneKey, - createHash('sha256').update(startedLaunchToken).digest('hex') - ) + this.restartedStatusLaunchTokenHashByPaneKey.set(ownerPaneKey, { + hash: createHash('sha256').update(startedLaunchToken).digest('hex') + }) return 'accept' } } if (event && event.processNewTurn !== true && tokenFence) { const launchToken = event.launchToken?.trim() - if (!launchToken || createHash('sha256').update(launchToken).digest('hex') !== tokenFence) { + const tokenHash = + event.retainedLaunchTokenHash ?? + (launchToken ? createHash('sha256').update(launchToken).digest('hex') : undefined) + if (tokenHash !== tokenFence.hash) { return 'suppress' } } @@ -144,10 +148,9 @@ export abstract class AgentHookServerStatusDisposition extends AgentHookServerSt this.closedAgentStatusPaneKeys.delete(ownerPaneKey) const launchToken = event?.launchToken?.trim() if (launchToken) { - this.restartedStatusLaunchTokenHashByPaneKey.set( - ownerPaneKey, - createHash('sha256').update(launchToken).digest('hex') - ) + this.restartedStatusLaunchTokenHashByPaneKey.set(ownerPaneKey, { + hash: createHash('sha256').update(launchToken).digest('hex') + }) } else { this.restartedStatusLaunchTokenHashByPaneKey.delete(ownerPaneKey) } diff --git a/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts b/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts index fdeea389d61..d23efd2fb2b 100644 --- a/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts +++ b/src/main/ipc/worktrees-authoritative-local-metadata-pruning.test.ts @@ -12,6 +12,8 @@ import { mockSelectedWslProjectRuntime } from './worktrees-test-fixtures' import { pruneMetadataMissingFromAuthoritativeLocalScan } from './worktrees/listing/authoritative-local-worktree-metadata-pruning' import { listDetectedWorktreesForCapturedRepo } from './worktrees/listing/detected-provider-listing' import { getLocalWorktreeScanGeneration } from '../local-worktree-scan-generation' +import { agentHookServer } from '../agent-hooks/server' +import { makePaneKey } from '../../shared/stable-pane-id' import { isRegisteredWorktreePath, registerWorktreeRootsForRepo @@ -207,10 +209,33 @@ describe('authoritative local worktree metadata pruning integration', () => { await Promise.resolve() return rows }) + // A worktree deleted outside Orca strands its status row unless the prune retires it. + const stalePane = makePaneKey('tab-stale', '11111111-1111-4111-8111-111111111111') + const livePane = makePaneKey('tab-live', '22222222-2222-4222-8222-222222222222') + const working = { state: 'working', prompt: 'p', agentType: 'codex' } as const + agentHookServer.ingestTerminalStatus({ + paneKey: stalePane, + worktreeId: staleId, + connectionId: null, + payload: working + }) + agentHookServer.ingestTerminalStatus({ + paneKey: livePane, + worktreeId: `${REPO_ID}::/workspace/live`, + connectionId: null, + payload: working + }) await Promise.all([listDetected(), listDetected(), listDetected()]) await listDetected() + try { + expect(agentHookServer.getStatusSnapshot().map((row) => row.paneKey)).toEqual([livePane]) + } finally { + agentHookServer.dropStatusEntriesByTabPrefix('tab-stale') + agentHookServer.dropStatusEntriesByTabPrefix('tab-live') + } + expect(store.captureNativeLocalWorktreeMetadataScanExpectation).toHaveBeenCalledTimes(1) expect(store.pruneSessionlessMissingLocalWorktreeMetadataForRepo).toHaveBeenCalledTimes(1) const firstPruneCall = store.pruneSessionlessMissingLocalWorktreeMetadataForRepo.mock diff --git a/src/main/ipc/worktrees-removal-recovery.test.ts b/src/main/ipc/worktrees-removal-recovery.test.ts index b1e91daea9d..5827eb65391 100644 --- a/src/main/ipc/worktrees-removal-recovery.test.ts +++ b/src/main/ipc/worktrees-removal-recovery.test.ts @@ -6,6 +6,8 @@ import { join } from 'node:path' import type { GitWorktreeInfo } from '../../shared/worktree/types' import type { RedactableSpan } from '../observability/redactor' import { _resetTracerForTests, setActiveSink } from '../observability/tracer' +import { agentHookServer } from '../agent-hooks/server' +import { makePaneKey } from '../../shared/stable-pane-id' import { ORIGINAL_PLATFORM, setPlatform, @@ -158,15 +160,35 @@ describe('registerWorktreeHandlers', () => { store.getWorktreeMeta.mockReturnValue(makeWorktreeMeta({ hostId: 'local' })) mockKnownFeatureWorktree() removeWorktreeMock.mockResolvedValue({}) - - await handlers['worktrees:remove'](null, { worktreeId, hostId: 'local' }) - - expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') - expect(advertisedUrlWatcherForgetWorktreeMock).not.toHaveBeenCalled() - expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled() - expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { - repoId: 'repo-1' + // Both hosts' agents share one tab; only the removed host's pane may be retired. + const localPane = makePaneKey('tab-shared', '11111111-1111-4111-8111-111111111111') + const sshPane = makePaneKey('tab-shared', '22222222-2222-4222-8222-222222222222') + const payload = { state: 'working', prompt: 'stranded', agentType: 'codex' } as const + agentHookServer.ingestTerminalStatus({ + paneKey: localPane, + tabId: 'tab-shared', + worktreeId, + connectionId: null, + payload }) + agentHookServer.ingestRemote( + { paneKey: sshPane, tabId: 'tab-shared', worktreeId, payload }, + 'conn-1' + ) + + try { + await handlers['worktrees:remove'](null, { worktreeId, hostId: 'local' }) + + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'local') + expect(advertisedUrlWatcherForgetWorktreeMock).not.toHaveBeenCalled() + expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled() + expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', { + repoId: 'repo-1' + }) + expect(agentHookServer.getStatusSnapshot().map((row) => row.paneKey)).toEqual([sshPane]) + } finally { + agentHookServer.dropStatusEntriesByTabPrefix('tab-shared') + } }) it('tombstones a cleanup-batch removal without scheduling singular sidecar writes', async () => { @@ -698,4 +720,47 @@ describe('registerWorktreeHandlers', () => { }) expect(getSshPtyProviderMock).not.toHaveBeenCalled() }) + // A scan the host answered is the only evidence that ever retires an off-host WorktreeMeta row, + // so it must retire that worktree's hook-status rows too, or they stay stranded in last-status.json. + it("retires the scan-proven host rows from the agent status store, and only that host's", async () => { + const worktreeId = 'repo-1::/remote/deleted' + store.getRepos.mockReturnValue([ + { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + ]) + store.getProjectHostSetups.mockReturnValue([]) + store.getAllWorktreeMeta.mockReturnValue({ + [worktreeId]: makeWorktreeMeta({ hostId: 'ssh:target-a' }) + }) + const scannedPane = makePaneKey('tab-scan', '33333333-3333-4333-8333-333333333333') + const otherHostPane = makePaneKey('tab-scan', '44444444-4444-4444-8444-444444444444') + const payload = { state: 'working', prompt: 'stranded', agentType: 'codex' } as const + agentHookServer.ingestRemote( + { paneKey: scannedPane, tabId: 'tab-scan', worktreeId, payload }, + 'target-a' + ) + agentHookServer.ingestRemote( + { paneKey: otherHostPane, tabId: 'tab-scan', worktreeId, payload }, + 'target-b' + ) + + try { + await handlers['worktrees:forgetRemovedForExecutionHost'](null, { + repoId: 'repo-1', + executionHostId: 'ssh:target-a', + worktreeIds: [worktreeId] + }) + + expect(store.removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:target-a') + expect(agentHookServer.getStatusSnapshot().map((row) => row.paneKey)).toEqual([otherHostPane]) + } finally { + agentHookServer.dropStatusEntriesByTabPrefix('tab-scan') + } + }) }) diff --git a/src/main/ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning.ts b/src/main/ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning.ts index ca5c3019844..3349a986519 100644 --- a/src/main/ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning.ts +++ b/src/main/ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning.ts @@ -8,6 +8,7 @@ import { } from '../../../../shared/worktree/id' import type { GitWorktreeInfo } from '../../../../shared/worktree/types' import { isWslUncPath } from '../../../../shared/wsl-paths' +import { agentHookServer } from '../../../agent-hooks/server' import type { Store } from '../../../persistence/loading-store/store' import type { NativeLocalWorktreeMetadataScanExpectation } from '../../../persistence/tracking-repos/missing-local-worktree-metadata-pruning' import { pruneWorkspaceCleanupScanSnapshots } from '../../../workspace-cleanup-scan-snapshot' @@ -141,6 +142,9 @@ export async function pruneMetadataMissingFromAuthoritativeLocalScan({ })) void pruneWorkspaceCleanupScanSnapshots(snapshotDirectory, targets) void pruneWorkspaceSpaceAnalysisSnapshots(snapshotDirectory, targets) + for (const worktreeId of removedIds) { + agentHookServer.dropStatusEntriesForRemovedWorktree(worktreeId, LOCAL_EXECUTION_HOST_ID) + } } return result(removedIds, generationCurrent()) } diff --git a/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts index ac231a4c697..1e4fecfe437 100644 --- a/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-host-catalog-handlers.ts @@ -14,6 +14,7 @@ import type { DetectedWorktree } from '../../../../shared/worktree/types' import { isFolderRepo } from '../../../../shared/repo-kind' import { projectResolvedWorktreeLineage } from '../../../../shared/resolved-worktree-lineage' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import { agentHookServer } from '../../../agent-hooks/server' import { pruneWorkspaceCleanupScanSnapshots } from '../../../workspace-cleanup-scan-snapshot' import { pruneWorkspaceSpaceAnalysisSnapshots } from '../../../workspace-space-analysis-snapshot' import { findExactRepoOwner, hasConflictingStoredWorktreeOwner } from './worktree-host-ownership' @@ -144,6 +145,10 @@ export function registerHostCatalogHandlers(context: WorktreeIpcContext): void { continue } store.removeWorktreeMeta(worktreeId, requestedExecutionHostId) + // Why here too: a scan the host answered is positive evidence of removal, and this is the only + // path that ever retires an off-host row — so it owes the status store the same drop the + // in-Orca delete does, or the SSH rows stay stranded in `last-status.json`. + agentHookServer.dropStatusEntriesForRemovedWorktree(worktreeId, parsedHost.id) forgottenWorktreeIds.push(worktreeId) } if (forgottenWorktreeIds.length > 0) { diff --git a/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts b/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts index ecc982076c5..780e443fdc9 100644 --- a/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts +++ b/src/main/ipc/worktrees/removal/worktree-removal-ownership.ts @@ -8,6 +8,7 @@ import type { Repo } from '../../../../shared/repo-types' import { hasWorktreeRemovalRepoOwnerOnOtherHost } from '../../../worktree-removal-repo-owner' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import { advertisedUrlWatcher } from '../../../ports/advertised-url-watcher' +import { agentHookServer } from '../../../agent-hooks/server' import { localhostWorktreeLabelProxy } from '../../../localhost-worktree-label-proxy' import { deleteWorktreeHistoryDir } from '../../../terminal-history-deletion' import { pruneWorktreePRRefreshAliases } from '../../../github/pr-refresh-coordinator' @@ -88,6 +89,8 @@ export function removeWorktreeMetadataAndTransientState( } else { store.removeWorktreeMeta(worktreeId) } + // Why outside the same-id gate: retirement is per host and per pane, so a surviving owner keeps its own. + agentHookServer.dropStatusEntriesForRemovedWorktree(worktreeId, hostId ?? persistedHostId) if (!preservesSameIdOwner) { advertisedUrlWatcher.forgetWorktree(worktreeId) // Why: drop this worktree's localhost label routes so they don't accumulate in the proxy's route maps all session. diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index a71307a8df5..7e75c5024a2 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -257,6 +257,8 @@ async function startOrcadRuntime( checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), reconcileAgentStatusForEndedProcess: (paneKeys) => agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), + dropAgentStatusForRemovedWorktree: (worktreeId, host) => + agentHookServer.dropStatusEntriesForRemovedWorktree(worktreeId, host), buildAgentHookPtyEnv: () => isAgentStatusHooksEnabled(profileStore.getSettings()) ? agentHookServer.buildPtyEnv() : {}, // Why the dedupe here and not in the instance: `apply` closes and reconstructs diff --git a/src/main/runtime/agent-status-store-wiring.test-fixture.ts b/src/main/runtime/agent-status-store-wiring.test-fixture.ts index 1f399e0464f..12ef6dba7c7 100644 --- a/src/main/runtime/agent-status-store-wiring.test-fixture.ts +++ b/src/main/runtime/agent-status-store-wiring.test-fixture.ts @@ -28,6 +28,7 @@ export function makeAgentStatusStoreWiring(): { reconcileAgentStatusForEndedProcess: ( paneKeys: Parameters[0] ) => void + dropAgentStatusForRemovedWorktree: AgentHookServer['dropStatusEntriesForRemovedWorktree'] } /** Call once the runtime exists; returns the republish teardown. */ attach: (runtime: WiredRuntime) => () => void @@ -44,7 +45,9 @@ export function makeAgentStatusStoreWiring(): { statusStore.getStatusSnapshotForPane(paneKey), reconcileAgentStatusForEndedProcess: (paneKeys) => { statusStore.reconcileEndedProcessForPaneKeys(paneKeys) - } + }, + dropAgentStatusForRemovedWorktree: (worktreeId, host) => + statusStore.dropStatusEntriesForRemovedWorktree(worktreeId, host) }, attach: (runtime) => installHookStatusSessionTabsRepublish(statusStore, () => runtime) } diff --git a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts index 37f8121f99a..6ecdc714935 100644 --- a/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts +++ b/src/main/runtime/orca-runtime-preserved-branch-cleanup.ts @@ -1,5 +1,6 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithTerminalDrivers } from './orca-runtime-terminal-drivers' +import { ALL_EXECUTION_HOSTS_SCOPE, type ExecutionHostScope } from '../../shared/execution-host' import { RuntimePreservedBranchCleanup } from './runtime-preserved-branch-cleanup' import type { IPtyProvider } from '../providers/types' import type { @@ -98,6 +99,10 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin | ((paneKeys: Iterable) => void) | null + protected readonly dropAgentStatusForRemovedWorktreeFn: + | ((worktreeId: string, host?: ExecutionHostScope) => void) + | null + protected readonly canRecoverPersistentLocalPtysFn: () => boolean protected readonly getPairedDeviceNameFn: (pairedDeviceId: string) => string | null @@ -247,6 +252,8 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin if (this.store) { this.removeWorktreeMetadataAndHistory(this.store, worktreeId) } + // Why every host after the local-only hub: this store mints folder ids, so none is shared. + this.dropAgentStatusForRemovedWorktreeFn?.(worktreeId, ALL_EXECUTION_HOSTS_SCOPE) } }) diff --git a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts index 115a5be8f0d..f66240f5739 100644 --- a/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts +++ b/src/main/runtime/orca-runtime-resolve-worktree-removal-target.ts @@ -148,6 +148,8 @@ export class OrcaRuntimeWithResolveWorktreeRemovalTarget extends OrcaRuntimeWith } else { store.removeWorktreeMeta(worktreeId) } + // Why outside the same-id gate: retirement is per host and per pane, so a surviving owner keeps its own. + this.dropAgentStatusForRemovedWorktreeFn?.(worktreeId, hostId ?? persistedHostId) if (!preservesSameIdOwner) { // A paired PTY can outlive the delete acknowledgement; it must not be // rescued into a newly-created occupant of the same path-derived ID. diff --git a/src/main/runtime/orca-runtime-state-fields.ts b/src/main/runtime/orca-runtime-state-fields.ts index ede9c6edce5..404ddb35da3 100644 --- a/src/main/runtime/orca-runtime-state-fields.ts +++ b/src/main/runtime/orca-runtime-state-fields.ts @@ -1,5 +1,6 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithLinearCommands } from './orca-runtime-linear-commands' +import type { ExecutionHostScope } from '../../shared/execution-host' import type { RuntimeStore } from './runtime-store-contract' import type { StatsCollector } from '../stats/collector' import type { IPtyProvider } from '../providers/types' @@ -86,6 +87,7 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { paneKey: string ) => Promise<'live' | 'unverifiable' | 'exited' | null> reconcileAgentStatusForEndedProcess?: (paneKeys: Iterable) => void + dropAgentStatusForRemovedWorktree?: (worktreeId: string, host?: ExecutionHostScope) => void canRecoverPersistentLocalPtys?: () => boolean // Why: the device registry lives on the RPC server, which is constructed with this runtime; // a closure defers the lookup past that ordering instead of inverting ownership. @@ -230,6 +232,7 @@ export class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCommands { deps?.retireAgentHookCompatibilityAuthority ?? null this.checkHookAgentPresenceFn = deps?.checkHookAgentPresence ?? null this.reconcileAgentStatusForEndedProcessFn = deps?.reconcileAgentStatusForEndedProcess ?? null + this.dropAgentStatusForRemovedWorktreeFn = deps?.dropAgentStatusForRemovedWorktree ?? null this.canRecoverPersistentLocalPtysFn = deps?.canRecoverPersistentLocalPtys ?? (() => true) this.getPairedDeviceNameFn = deps?.getPairedDeviceName ?? (() => null) // Why: configure the shared AiVault scan cache from a serve-mode-reachable diff --git a/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts b/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts index d0cd3608414..957f4b66ca4 100644 --- a/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts +++ b/src/main/runtime/orca-runtime-test-scenario-builders.spec.ts @@ -346,12 +346,16 @@ function createMobileCreateTestNotifier( } } -function createWorktreeRemovalRuntime(runtimeStore: unknown = store): RuntimeService { +function createWorktreeRemovalRuntime( + runtimeStore: unknown = store, + deps: ConstructorParameters[2] = {} +): RuntimeService { const emptyPtyProvider = { listProcesses: vi.fn(async () => []), shutdown: vi.fn(async () => {}) } return new OrcaRuntimeService(runtimeStore as never, undefined, { + ...deps, getLocalProvider: () => emptyPtyProvider as never, getSshProvider: () => emptyPtyProvider as never }) diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts index 2d1af59155b..d371ac3f96f 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts @@ -36,6 +36,7 @@ import { } from '../orca-runtime-test-fixtures.spec' import { createWorktreeRemovalRuntime } from '../orca-runtime-test-scenario-builders.spec' import { getLocalWorktreeScanGeneration } from '../../local-worktree-scan-generation' +import { makeAgentStatusStoreWiring } from '../agent-status-store-wiring.test-fixture' describe('OrcaRuntimeService', () => { it('creates the first terminal by id when duplicate repo entries expose the same path', async () => { @@ -493,6 +494,48 @@ describe('OrcaRuntimeService', () => { ) }) + it('retires the removed worktree agent status rows from the host store', async () => { + const statusWiring = makeAgentStatusStoreWiring() + const runtime = createWorktreeRemovalRuntime(store, statusWiring.deps) + statusWiring.statusStore.ingestTerminalStatus({ + paneKey: 'tab-removed:11111111-1111-4111-8111-111111111111', + tabId: 'tab-removed', + worktreeId: TEST_WORKTREE_ID, + connectionId: null, + payload: { state: 'working', prompt: 'stranded', agentType: 'codex' } + }) + vi.mocked(removeWorktree).mockResolvedValue({}) + + await runtime.removeManagedWorktree(TEST_WORKTREE_ID) + + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([]) + statusWiring.statusStore.stop() + }) + + it('retires a deleted SSH folder workspace agent status rows', async () => { + const statusWiring = makeAgentStatusStoreWiring() + const folderStore = { + ...store, + getFolderWorkspaces: () => [{ id: 'ws-1', folderPath: '/srv/app', connectionId: 'user@box' }], + removeFolderWorkspace: () => true + } + const runtime = createWorktreeRemovalRuntime(folderStore, statusWiring.deps) + statusWiring.statusStore.ingestRemote( + { + paneKey: 'tab-folder:11111111-1111-4111-8111-111111111111', + tabId: 'tab-folder', + worktreeId: 'folder:ws-1', + payload: { state: 'working', prompt: 'stranded', agentType: 'codex' } + }, + 'user@box' + ) + + await runtime.deleteFolderWorkspace('ws-1') + + expect(statusWiring.statusStore.getStatusSnapshot()).toEqual([]) + statusWiring.statusStore.stop() + }) + it('passes project shared links through the runtime removal preflight and cleanup', async () => { const runtime = createWorktreeRemovalRuntime() vi.mocked(loadHooks).mockReturnValue({ diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 9da3d7212ef..bf9a8ad33a5 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -122,6 +122,8 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), reconcileAgentStatusForEndedProcess: (paneKeys) => agentHookServer.reconcileEndedProcessForPaneKeys(paneKeys), + dropAgentStatusForRemovedWorktree: (worktreeId, host) => + agentHookServer.dropStatusEntriesForRemovedWorktree(worktreeId, host), canRecoverPersistentLocalPtys: () => getDaemonProvider() !== null, // Why: evaluated per call, not captured — the RPC server that owns the device registry is // constructed with this runtime and does not exist yet at this point. From 0e9e273b916c2dd476c32b3c7e22bbbb0a6b7265 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:15:25 -0400 Subject: [PATCH 05/48] fix(relay): deploy driver builds the checked commit, prompts on their own line, summarises progress (#25755) * fix(relay): publish the reviewed commit before main can move, and quiet the deploy driver The publish workflow builds main's head at dispatch. The driver checked main at preflight but dispatched the publish about four minutes later, after the inspects and the typed phrase, so a busy main stopped the first real deploy. It now dispatches the publish seconds after the check, before anything else, and a build of a moved main stops with the --commit/--publish-run command that deploys it once reviewed. Typed prompts end in a newline, and runs are summarised (status changes plus every 5 min) instead of streaming gh run watch. * fix(relay): a main-moved stop prints only the command that reuses the build The generic re-run line named the reviewed commit without --publish-run, which would only build the moved main again. --- .../scripts/drive-relay-director-deploy.mjs | 113 +++++++++++------- .../drive-relay-director-deploy.test.mjs | 88 +++++++++++--- cloud/docs/relay-workflows.md | 13 +- 3 files changed, 148 insertions(+), 66 deletions(-) diff --git a/cloud/dev/scripts/drive-relay-director-deploy.mjs b/cloud/dev/scripts/drive-relay-director-deploy.mjs index 1f783cd179c..060f35418e4 100644 --- a/cloud/dev/scripts/drive-relay-director-deploy.mjs +++ b/cloud/dev/scripts/drive-relay-director-deploy.mjs @@ -60,6 +60,9 @@ const DIRECTOR_5XX_FILTER = [ const LOG_COUNT_LIMIT = 5_000 const LOG_ATTEMPTS = 6 const LOG_INTERVAL_MS = 10_000 +const WATCH_INTERVAL_MS = 10_000 +// A run still going is reported this often, so a long monitor never looks hung. +const WATCH_REPORT_MS = 5 * 60_000 const REHOME_HISTORY_RUNS = 5 const RUN_ID = /^[1-9][0-9]*$/ @@ -118,6 +121,7 @@ export function createDriver(config, deps) { // A pause or enable this run cannot vouch for: `changing` while it may still apply on its own, // `unconfirmed` once it finished without a usable result. { kind, name, runId?, url? } let uncertain + let movedBuild // { commit, runId }: a publish that built a newer main than the reviewed commit let login const ownRunIds = new Set() let enabled = false @@ -205,18 +209,24 @@ export function createDriver(config, deps) { ) } + // One line per status change and one every WATCH_REPORT_MS, never a stream of job steps. async function waitForRun(run) { + const started = deps.now() + let reported + let reportedAt for (;;) { - deps.stream( - 'gh', - words(`run watch ${run.runId} -R ${REPOSITORY} --exit-status --interval 10`) - ) const view = viewRun(run.runId) if (view.status === 'completed') { log(`${run.name}: ${view.conclusion} ${run.url}`) return { ...run, conclusion: view.conclusion, attempt: view.attempt } } - await deps.sleep(LOG_INTERVAL_MS) + if (view.status !== reported || deps.now() - reportedAt >= WATCH_REPORT_MS) { + const minutes = Math.floor((deps.now() - started) / 60_000) + log(`${run.name}: ${view.status}, ${minutes} min`) + reported = view.status + reportedAt = deps.now() + } + await deps.sleep(WATCH_INTERVAL_MS) } } @@ -298,7 +308,8 @@ export function createDriver(config, deps) { async function typed(phrase, meaning = '') { if (typedPhrases.has(phrase)) return phrase - const answer = (await deps.prompt(`Type ${phrase} to continue${meaning}: `)).trim() + // Ends in a newline, so the prompt is never left mid-line where it can be missed. + const answer = (await deps.prompt(`Type ${phrase} to continue${meaning}:\n`)).trim() if (answer !== phrase) throw new DriverStop(`expected ${phrase}; nothing further was dispatched`) typedPhrases.set(phrase, answer) @@ -314,8 +325,9 @@ export function createDriver(config, deps) { throw new DriverStop(`${runUrl(runId)} is not a successful ${WORKFLOWS.publish.file} run`) } if (view.headSha !== config.commit) { + movedBuild = { commit: view.headSha, runId } throw new DriverStop( - `publish ${runUrl(runId)} built ${view.headSha}, not the reviewed ${config.commit}; do not deploy it` + `publish ${runUrl(runId)} built ${view.headSha}, not the reviewed ${config.commit}: main moved` ) } const tag = `${IMAGE_REPOSITORY}:sha-${config.commit}` @@ -488,24 +500,6 @@ export function createDriver(config, deps) { } else { requireQuietLane() } - if (published) { - published.digest = await publishedDigest(published.runId) - } else { - const main = gh(['api', `repos/${REPOSITORY}/commits/${WORKFLOW_REF}`, '--jq', '.sha']).trim() - if (main !== config.commit) { - throw new DriverStop( - `${WORKFLOW_REF} is at ${main}, not the reviewed ${config.commit}; review the difference and run with --commit ${main}` - ) - } - } - known.director = readDirector() - if (known.director.servingDigest !== published?.digest) { - rollbackPoint = { - revision: known.director.servingRevision, - digest: known.director.servingDigest - } - } - log(describeDirector(known.director)) let claim if (config.pauseRun) { // Only this operator's own rehome-control run can prove a pause belongs to this driver. @@ -530,6 +524,27 @@ export function createDriver(config, deps) { ) } } + if (published) { + published.digest = await publishedDigest(published.runId) + } else { + const main = gh(['api', `repos/${REPOSITORY}/commits/${WORKFLOW_REF}`, '--jq', '.sha']).trim() + if (main !== config.commit) { + throw new DriverStop( + `${WORKFLOW_REF} is at ${main}, not the reviewed ${config.commit}; review the difference and run with --commit ${main}` + ) + } + // The workflow builds main's head at dispatch, so it is dispatched seconds after the check + // rather than after the inspects and the typed phrase; publishing changes nothing serving. + if (!config.dryRun) await publishStep.run(publishStep) + } + known.director = readDirector() + if (known.director.servingDigest !== published?.digest) { + rollbackPoint = { + revision: known.director.servingRevision, + digest: known.director.servingDigest + } + } + log(describeDirector(known.director)) // A director safety pause moves the generation without a run; the inspect then fails closed. const generation = config.rehomeGeneration ?? (await lastKnownRehomeGeneration()) if (config.dryRun) { @@ -598,6 +613,20 @@ export function createDriver(config, deps) { ] } + const publishStep = { + name: 'publish', + workflow: WORKFLOWS.publish, + inputs: publishInputs, + when: () => !published, + run: async (step) => { + const run = await dispatch(step) + requireSuccess(run) + published = { runId: run.runId } + published.digest = await publishedDigest(run.runId) + log(`published ${IMAGE_REPOSITORY}@${published.digest}`) + } + } + // The one ordered plan. `when` reads live state, so a re-run skips what is already done; a dry run // prints every step whose need it cannot know yet. function plan() { @@ -605,19 +634,7 @@ export function createDriver(config, deps) { let verified let monitor return [ - { - name: 'publish', - workflow: WORKFLOWS.publish, - inputs: publishInputs, - when: () => !published, - run: async (step) => { - const run = await dispatch(step) - requireSuccess(run) - published = { runId: run.runId } - published.digest = await publishedDigest(run.runId) - log(`published ${IMAGE_REPOSITORY}@${published.digest}`) - } - }, + publishStep, { name: 'pause', workflow: WORKFLOWS.rehome, @@ -806,11 +823,14 @@ export function createDriver(config, deps) { ] } - function rerunCommand(pauseRun = owned?.runId) { + function rerunCommand( + pauseRun = owned?.runId, + build = published?.digest ? { commit: config.commit, runId: published.runId } : undefined + ) { return [ 'node dev/scripts/drive-relay-director-deploy.mjs', - `--commit ${config.commit}`, - ...(published?.digest ? [`--publish-run ${published.runId}`] : []), + `--commit ${build?.commit ?? config.commit}`, + ...(build ? [`--publish-run ${build.runId}`] : []), ...(pauseRun ? [`--pause-run ${pauseRun}`] : []), ...(config.leaveRehomePaused ? ['--leave-rehome-paused'] : []), ...config.configure.map( @@ -875,7 +895,14 @@ export function createDriver(config, deps) { ` ${ghCommand(WORKFLOWS.director, directorDeployInputs({ imageDigest: rollbackPoint.digest, predecessorDigest: rollbackPoint.digest, rehomeGeneration: owned?.generation ?? '' }))}` ) } - if (!config.dryRun && !uncertain) { + if (movedBuild) { + // Re-running the reviewed commit would only build the moved main again. + lines.push( + '', + `Review ${config.commit}..${movedBuild.commit}, then deploy that build without rebuilding:`, + ` cd cloud && ${rerunCommand(undefined, movedBuild)}` + ) + } else if (!config.dryRun && !uncertain) { lines.push( '', `Re-run to finish from here (it re-reads everything and skips what is done):`, @@ -947,8 +974,6 @@ function defaultDependencies() { maxBuffer: 256 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }), - stream: (program, args) => - spawnSync(program, args, { stdio: ['ignore', 'inherit', 'inherit'] }).status, now: () => Date.now(), sleep: (ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms)), print: (line) => process.stdout.write(`${line}\n`), diff --git a/cloud/dev/scripts/drive-relay-director-deploy.test.mjs b/cloud/dev/scripts/drive-relay-director-deploy.test.mjs index 460bdf9b0a6..db80ef22c7b 100644 --- a/cloud/dev/scripts/drive-relay-director-deploy.test.mjs +++ b/cloud/dev/scripts/drive-relay-director-deploy.test.mjs @@ -264,6 +264,7 @@ function gh(world, args, input) { key: `${workflow.file}:${inputs.mode ?? 'deploy'}`, dispatched: true, headSha: world.main, + polls: world.polls?.[`${workflow.file}:${inputs.mode ?? 'deploy'}`] ?? 0, artifacts: {}, log: '' } @@ -284,9 +285,12 @@ function gh(world, args, input) { return world.unreadableLogs?.(run) ? { status: 1, stdout: '', stderr: 'HTTP 502' } : ok(run.log) } if (args[1] === 'view') { + world.onView?.(run) + const running = run.polls > 0 + if (running) run.polls -= 1 return ok({ - status: 'completed', - conclusion: run.conclusion, + status: running ? 'in_progress' : 'completed', + conclusion: running ? '' : run.conclusion, attempt: 1, headSha: run.headSha, headBranch: 'main', @@ -353,7 +357,6 @@ function dependencies(world) { return { run: (program, args, input) => program === 'gh' ? gh(world, args, input) : gcloud(world, args), - stream: () => 0, now: () => world.now, sleep: async (ms) => { world.now += ms @@ -418,13 +421,13 @@ const MONITOR = `${WORKFLOWS.monitor.file}:dry-run` const report = (world) => world.printed.join('\n') const live = (world) => [world.control.generation, world.control.enabled] -test('publishes before pausing, types every phrase, and enables on the digests now serving', async () => { +test('publishes first, types every phrase, and enables on the digests now serving', async () => { const world = fakeWorld() assert.equal((await start(world)).done, true) assert.deepEqual(keys(world), [ + 'publish-relay-production:publish', 'operate-relay-asia-admission:inspect', 'operate-relay-production-rehome:inspect', - 'publish-relay-production:publish', 'operate-relay-production-rehome:pause', 'deploy-relay-production-director:deploy', 'operate-relay-production-rehome:inspect', @@ -436,6 +439,7 @@ test('publishes before pausing, types every phrase, and enables on the digests n 'PAUSE_REGIONAL_REHOMING', 'ENABLE_REGIONAL_REHOMING' ]) + assert.ok(world.questions.every((question) => question.endsWith('\n'))) assert.ok( world.questions.some((question) => question.includes( @@ -467,10 +471,11 @@ test('publishes before pausing, types every phrase, and enables on the digests n assert.deepEqual(live(world), [41, true]) }) -test('a wrong phrase stops before the first mutation', async () => { +test('a wrong phrase stops before rehome or the director is touched', async () => { const world = fakeWorld({ answer: () => 'yes' }) assert.match((await stopped(start(world))).message, /expected DEPLOY aaaaaaaaaaaa/) assert.deepEqual(keys(world), [ + 'publish-relay-production:publish', 'operate-relay-asia-admission:inspect', 'operate-relay-production-rehome:inspect' ]) @@ -482,7 +487,9 @@ test('F2: rehome found paused is never adopted; --leave-rehome-paused deploys an (await stopped(start(world))).message, /did not pause it.*--pause-run.*--leave-rehome-paused/s ) - assert.equal(dispatched(world, PUBLISH).length, 0) + assert.match(report(world), /drive-relay-director-deploy\.mjs .*--publish-run \d+/) + await rerun(world).catch(() => {}) + assert.equal(dispatched(world, PUBLISH).length, 1) await start(world, ['--leave-rehome-paused']) assert.equal( dispatched(world, REHOME('pause')).length + dispatched(world, REHOME('enable')).length, @@ -509,11 +516,60 @@ test('F2: main moving is caught before any rehome change, and after the pause it assert.deepEqual(live(world), [41, true]) }) +test('ops-log 22:59Z: main moving during the inspects and the typed phrase no longer stops the deploy', async () => { + const world = fakeWorld() + const deps = dependencies(world) + const run = deps.run + deps.run = (program, args, input) => { + const result = run(program, args, input) + if (args[0] === 'workflow' && JSON.parse(input).mode === 'inspect') world.main = 'b'.repeat(40) + return result + } + assert.equal((await start(world, [], deps)).done, true) + assert.equal(dispatched(world, PUBLISH)[0].headSha, COMMIT) + assert.equal(dispatched(world, DEPLOY)[0].inputs['image-digest'], NEW) +}) + +test('main moving between the check and the publish stops untouched, naming the reuse command', async () => { + const world = fakeWorld() + const deps = dependencies(world) + const run = deps.run + deps.run = (program, args, input) => { + if (args[0] === 'workflow') world.main = 'b'.repeat(40) + return run(program, args, input) + } + assert.match((await stopped(start(world, [], deps))).message, /main moved/) + const publishRun = String(dispatched(world, PUBLISH)[0].id) + assert.deepEqual(keys(world), ['publish-relay-production:publish']) + // The only command printed is the one that reuses the build. + const commands = world.printed.filter((line) => line.includes('&& node dev/scripts/')) + assert.equal(commands.length, 1) + assert.match(commands[0], new RegExp(`--commit ${world.main} --publish-run ${publishRun}$`)) + assert.equal((await rerun(world)).done, true) + assert.equal(dispatched(world, PUBLISH).length, 1) + assert.deepEqual(live(world), [41, true]) +}) + +test('a running workflow is summarised, not streamed', async () => { + const world = fakeWorld({ polls: { [PUBLISH]: 40 } }) + assert.equal((await start(world)).done, true) + assert.deepEqual( + world.printed + .filter((line) => / publish: (in_progress|success)/.test(line)) + .map((line) => line.slice(25)), + [ + 'publish: in_progress, 0 min', + 'publish: in_progress, 5 min', + `publish: success https://github.com/${REPOSITORY}/actions/runs/${dispatched(world, PUBLISH)[0].id}` + ] + ) +}) + test('a publish whose log disagrees with the registry stops before rehome is touched', async () => { const world = fakeWorld({ pushLogDigest: CELL }) assert.match((await stopped(start(world))).message, /tag moved/) assert.equal(dispatched(world, REHOME('pause')).length, 0) - assert.match(report(world), /rehome: generation 39 as last read, enabled/) + assert.match(report(world), /rehome: not read; this run did not change it/) }) test('dry run dispatches nothing and prints every step', async () => { @@ -566,13 +622,12 @@ test('F1: an interrupt while the pause is in flight says rehome is changing, and const world = fakeWorld() const deps = dependencies(world) let driver - deps.stream = () => { + world.onView = () => { if (world.dispatches().at(-1)?.inputs.mode === 'pause' && !world.interrupted) { world.interrupted = true driver.interrupt('SIGINT') throw new Error('killed') } - return 0 } driver = createDriver( parseDriverArguments(['--commit', COMMIT, '--log-directory', logDirectory()]), @@ -594,9 +649,8 @@ test('F1: an interrupt while the enable is in flight never says rehome is paused const world = fakeWorld() const deps = dependencies(world) let driver - deps.stream = () => { + world.onView = () => { if (world.dispatches().at(-1)?.inputs.mode === 'enable') driver.interrupt('SIGTERM') - return 0 } driver = createDriver( parseDriverArguments(['--commit', COMMIT, '--log-directory', logDirectory()]), @@ -840,10 +894,11 @@ test('Q1 and C1: after a hard kill mid-pause, the re-run names the pause its log const deps = dependencies(world) const directory = logDirectory() let logAtKill - deps.stream = () => { - if (world.dispatches().at(-1)?.key !== REHOME('pause')) return 0 + world.onView = () => { + if (world.dispatches().at(-1)?.key !== REHOME('pause')) return // SIGKILL writes no stop report: only what was logged before the watch survives. logAtKill = readFileSync(join(directory, readdirSync(directory)[0]), 'utf8') + world.onView = undefined throw new Error('SIGKILL') } await stopped( @@ -934,16 +989,15 @@ test('C2: --leave-rehome-paused refuses an enabled switch instead of pausing it' (await stopped(start(world, ['--leave-rehome-paused']))).message, /accepts only a paused switch/ ) - assert.equal(dispatched(world, REHOME('pause')).length + dispatched(world, PUBLISH).length, 0) + assert.equal(dispatched(world, REHOME('pause')).length, 0) }) test('G4: an interrupt during the enable prints both commands that can finish', async () => { const world = fakeWorld() const deps = dependencies(world) let driver - deps.stream = () => { + world.onView = () => { if (world.dispatches().at(-1)?.inputs.mode === 'enable') driver.interrupt('SIGINT') - return 0 } driver = createDriver( parseDriverArguments(['--commit', COMMIT, '--log-directory', logDirectory()]), diff --git a/cloud/docs/relay-workflows.md b/cloud/docs/relay-workflows.md index 748db4af05d..016f8ebd80a 100644 --- a/cloud/docs/relay-workflows.md +++ b/cloud/docs/relay-workflows.md @@ -720,11 +720,14 @@ do, so it never reports success over a pause it cannot explain. The sequence: -1. **Preflight, read-only.** No `cloud-*` workflow is queued or running (all pages; the hourly - clock-skew monitor and `cloud-verify` excepted), and `main` is the reviewed commit. -2. **Publish**, after the operator types `DEPLOY `. It runs before rehome is touched, - so a moved `main` or a bad build needs no cleanup. The digest is the registry digest of - `relay:sha-`, and the run's own push line must name the same digest. +1. **Publish.** No `cloud-*` workflow is queued or running (all pages; the hourly clock-skew + monitor and `cloud-verify` excepted), and `main` is the reviewed commit. The publish workflow + builds whatever `main` is when it is dispatched, so the driver dispatches it straight after that + check, before the inspects and the typed phrase. It changes nothing serving, so a bad build needs + no cleanup. The digest is the registry digest of `relay:sha-`, and the run's own push + line must name the same digest. If `main` still moved in those seconds, the driver stops and + names the `--commit --publish-run ` that deploys that build once it is reviewed. +2. **Preflight, read-only**, then the operator types `DEPLOY `. 3. **Pause**, only if rehome is enabled, after the operator types `PAUSE_REGIONAL_REHOMING`. 4. **Deploy** with that digest, the paused generation, `preserve` for both regional inputs, no prune, and the old serving digest as predecessor. From c7d74b1160e9f1f0d72ad55292dc5ee14fe2e1d5 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:15:27 -0400 Subject: [PATCH 06/48] feat(relay): give Asia cell c34 a promotion wave so it can become a general cell (#25757) * feat(relay): give Asia cell c34 a promotion wave so it can become a general cell c34 launched on 2026-10-05 as a migration-only spare with no promotion path. This adds it to the Asia admission promotion waves, the workflow's promote and canary cases, and the canary evidence map, so the reviewed Asia workflow can promote it with the same five-minute canary c30 and c31 ran. The same-cap migration-only list is deliberately unchanged: a same-cap job reads a cell's class from that list, and c34 must be rolled to the director's image as a migration-only cell before promotion can run. The list moves after promotion, in its own change. Claude-Session: 1145a80d-dec4-4a9b-9373-bbbb876b9041 * docs(relay): scope the c34 same-cap pause to the window after promotion Claude-Session: 1145a80d-dec4-4a9b-9373-bbbb876b9041 * docs(relay): rewrap the c34 paragraph Claude-Session: 1145a80d-dec4-4a9b-9373-bbbb876b9041 --- .../cloud-operate-relay-asia-admission.yml | 6 ++-- .../scripts/operate-relay-asia-admission.mjs | 4 +-- .../operate-relay-asia-admission.test.mjs | 33 +++++++++++++++---- .../scripts/relay-asia-rollout-evidence.mjs | 3 ++ .../relay-asia-rollout-evidence.test.mjs | 13 ++++++++ cloud/docs/relay-workflows.md | 23 ++++++++----- 6 files changed, 61 insertions(+), 21 deletions(-) diff --git a/.github/workflows/cloud-operate-relay-asia-admission.yml b/.github/workflows/cloud-operate-relay-asia-admission.yml index 93aec57f11f..a5bb87d1d98 100644 --- a/.github/workflows/cloud-operate-relay-asia-admission.yml +++ b/.github/workflows/cloud-operate-relay-asia-admission.yml @@ -39,7 +39,7 @@ on: required: false type: string evidence-run-id: - description: Staging evidence run ID for C27, C27 canary run ID for C28/C29; C30-C33 take none and each proves itself by its own canary + description: Staging evidence run ID for C27, C27 canary run ID for C28/C29; C30-C34 take none and each proves itself by its own canary required: false type: string evidence-run-attempt: @@ -156,7 +156,7 @@ jobs: evidence_kind=c27 artifact_name="relay-asia-c27-canary-${EVIDENCE_RUN_ID}-${EVIDENCE_RUN_ATTEMPT}" ;; - production-gce-c30|production-gce-c31) + production-gce-c30|production-gce-c31|production-gce-c34) # No earlier proof binds a later cell's generation; its own canary below rolls it back on failure. canary_cell="${TARGET_CELL_IDS}" ;; @@ -330,7 +330,7 @@ jobs: verify_cells=production-gce-c27,production-gce-c28,production-gce-c29 expected_states='{"production-gce-c27":"general","production-gce-c28":"migration-only","production-gce-c29":"migration-only"}' ;; - production-gce-c30|production-gce-c31|production-gce-c32|production-gce-c33) + production-gce-c30|production-gce-c31|production-gce-c32|production-gce-c33|production-gce-c34) verify_cells="${CANARY_CELL}" expected_states="{\"${CANARY_CELL}\":\"general\"}" ;; diff --git a/cloud/dev/scripts/operate-relay-asia-admission.mjs b/cloud/dev/scripts/operate-relay-asia-admission.mjs index c79323a9b39..5139ad8004c 100644 --- a/cloud/dev/scripts/operate-relay-asia-admission.mjs +++ b/cloud/dev/scripts/operate-relay-asia-admission.mjs @@ -32,14 +32,14 @@ const SHAPES = { ['production-gce-c33'], ['production-gce-c34'] ], - // C34 is a migration-only spare: no promotion wave until a reviewed change adds one. promotionWaves: [ ['production-gce-c27'], ['production-gce-c28', 'production-gce-c29'], ['production-gce-c30'], ['production-gce-c31'], ['production-gce-c32'], - ['production-gce-c33'] + ['production-gce-c33'], + ['production-gce-c34'] ] } } diff --git a/cloud/dev/scripts/operate-relay-asia-admission.test.mjs b/cloud/dev/scripts/operate-relay-asia-admission.test.mjs index a1b8eb3853c..157d4dab351 100644 --- a/cloud/dev/scripts/operate-relay-asia-admission.test.mjs +++ b/cloud/dev/scripts/operate-relay-asia-admission.test.mjs @@ -557,7 +557,7 @@ test('accepts only reviewed Asia admission waves', () => { ...['production-gce-c32', 'production-gce-c33'].flatMap((cellId) => [ 'inspect', 'verify', 'register', 'registered', 'promote', 'recover-promotion', 'rollback' ].map((mode) => [mode, cellId])), - ...['inspect', 'verify', 'register', 'registered', 'rollback'] + ...['inspect', 'verify', 'register', 'registered', 'promote', 'recover-promotion', 'rollback'] .map((mode) => [mode, 'production-gce-c34']), ['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33,production-gce-c34'] ] @@ -588,9 +588,8 @@ test('accepts only reviewed Asia admission waves', () => { ['promote', 'production-gce-c27,production-gce-c30'], ['promote', 'production-gce-c28,production-gce-c29,production-gce-c30'], ['promote', 'production-gce-c30,production-gce-c31'], - // The C34 spare stays migration-only: no reviewed promotion wave names it. - ['promote', 'production-gce-c34'], - ['recover-promotion', 'production-gce-c34'], + ['promote', 'production-gce-c33,production-gce-c34'], + ['promote', 'production-gce-c31,production-gce-c34'], ['register', 'production-gce-c33,production-gce-c34'], ['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33'], ['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30,production-gce-c31,production-gce-c32,production-gce-c33'], @@ -678,6 +677,25 @@ test('registers the C34 spare alone as migration-only in asia-east2', async () = assert.equal(subject.selector().membership.general.includes('production-gce-c34'), false) }) +test('promotes the registered C34 spare to general and leaves every other cell alone', async () => { + const general = [ + ...launchCells, 'production-gce-c30', 'production-gce-c31', 'production-gce-c32', 'production-gce-c33' + ] + const membership = { + existingOnly: [], + migrationOnly: ['production-gce-c17', 'production-gce-c34'], + general: [...general] + } + const subject = harness({ generation: 345, membership }) + const result = await operateRelayAsiaAdmission({ + environment: 'production', mode: 'promote', cells: ['production-gce-c34'], + expectedGeneration: 345, imageDigest: digest, attemptId: 'asia_promote_c34', token: 'not-logged' + }, subject) + assert.deepEqual(result.states, { 'production-gce-c34': 'general' }) + assert.deepEqual(subject.selector().membership.migrationOnly, ['production-gce-c17']) + assert.deepEqual(subject.selector().membership.general, [...general, 'production-gce-c34'].sort()) +}) + const usRegions = { 'production-gce-c32': 'us-central1', 'production-gce-c33': 'us-central1' } test('registers C32 and C33 one at a time in us-central1 at the Asia shape', async () => { @@ -817,7 +835,8 @@ function promotionOutputs(cellIds) { test('runs each later cell\'s own canary with load aimed at that cell\'s region', () => { for (const [cellId, region] of [ ['production-gce-c30', 'asia-east2'], ['production-gce-c31', 'asia-east2'], - ['production-gce-c32', 'us-central1'], ['production-gce-c33', 'us-central1'] + ['production-gce-c32', 'us-central1'], ['production-gce-c33', 'us-central1'], + ['production-gce-c34', 'asia-east2'] ]) { const outputs = promotionOutputs(cellId) assert.equal(outputs?.canary, 'true', cellId) @@ -825,10 +844,10 @@ test('runs each later cell\'s own canary with load aimed at that cell\'s region' assert.equal(outputs.canary_region, region, cellId) assert.equal(outputs.evidence_kind, 'none') } - assert.equal(promotionOutputs('production-gce-c34'), null) assert.equal(promotionOutputs('production-gce-c32,production-gce-c33'), null) + assert.equal(promotionOutputs('production-gce-c33,production-gce-c34'), null) const canaryStart = workflowBlock('case "${CANARY_CELL}" in', '\n esac') - for (const cellId of ['production-gce-c32', 'production-gce-c33']) { + for (const cellId of ['production-gce-c32', 'production-gce-c33', 'production-gce-c34']) { const result = spawnSync('bash', ['-euo', 'pipefail', '-c', `${canaryStart}\necho "\${verify_cells} \${expected_states}"`], { env: { ...process.env, CANARY_CELL: cellId }, encoding: 'utf8' diff --git a/cloud/dev/scripts/relay-asia-rollout-evidence.mjs b/cloud/dev/scripts/relay-asia-rollout-evidence.mjs index ebc32e763c9..09c1942eb86 100644 --- a/cloud/dev/scripts/relay-asia-rollout-evidence.mjs +++ b/cloud/dev/scripts/relay-asia-rollout-evidence.mjs @@ -24,6 +24,9 @@ const PRODUCTION_CANARIES = { }, 'production-gce-c33': { kind: 'production-c33-canary', origin: 'https://c33.relay.onorca.dev', region: US_REGION + }, + 'production-gce-c34': { + kind: 'production-c34-canary', origin: 'https://c34.relay.onorca.dev', region: ASIA_REGION } } const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/ diff --git a/cloud/dev/scripts/relay-asia-rollout-evidence.test.mjs b/cloud/dev/scripts/relay-asia-rollout-evidence.test.mjs index b18ab959825..b684bfed789 100644 --- a/cloud/dev/scripts/relay-asia-rollout-evidence.test.mjs +++ b/cloud/dev/scripts/relay-asia-rollout-evidence.test.mjs @@ -402,6 +402,19 @@ test('builds a C31 canary that only C31 placement satisfies', () => { assert.throws(() => buildProductionCanaryEvidence(onC30), /C31 canary load was not placed only on C31/) }) +test('builds a C34 canary that only C34 placement satisfies', () => { + const c34 = 'production-gce-c34' + const evidence = buildProductionCanaryEvidence(canaryInput({}, c34)) + assert.equal(evidence.kind, 'production-c34-canary') + assert.equal(verifyRolloutEvidence( + evidence, workflowRun(evidence), + verifyExpected('production-c34-canary', { cellIds: [c34], selectorGeneration: 9 }) + ), evidence) + const onC31 = canaryInput({}, c34) + onC31.loadReport.assignedCellOrigins = ['https://c31.relay.onorca.dev'] + assert.throws(() => buildProductionCanaryEvidence(onC31), /C34 canary load was not placed only on C34/) +}) + test('records but does not gate organic US-targeted fallbacks during a canary', () => { // Mirrors C31's 2026-10-01 canary: US-targeted fallbacks, none targeting Asia. const input = canaryInput({}, 'production-gce-c31') diff --git a/cloud/docs/relay-workflows.md b/cloud/docs/relay-workflows.md index 016f8ebd80a..c898079e43c 100644 --- a/cloud/docs/relay-workflows.md +++ b/cloud/docs/relay-workflows.md @@ -219,15 +219,20 @@ whether a US cell belongs there is decided at promotion, not assumed. Both were on 2026-10-01, so the same-cap job now rolls them as general cells. They stay out of the fleet pool list because their pool is the US default of 10. -C34 is an Asia spare at the C31 shape in `asia-east2-c`, so the six Asia cells spread 2/2/2. It is -its own topology wave and registers alone as migration-only, then the director is configured with -`cell-ids` set to C34. It has no promotion wave: the Asia admission script and workflow refuse -`promote` for it, and placement and regional rehome select only general cells. It is a -migration-only landing zone that only an explicit evacuation or migration naming it can target. -Do not name it in the multi-target `promote-general-cell` or `retire-migration-cell` modes, which -accept any migration-only cell. It is a declared rehome source, sits in the same-cap migration-only -list, and stays out of the fleet pool list. Promoting it later takes its own reviewed change adding a -promotion wave and canary entry. +C34 is a sixth Asia cell at the C31 shape in `asia-east2-c`, so the six Asia cells spread 2/2/2. It +was its own topology wave, registered alone as migration-only, and the director was configured with +`cell-ids` set to C34, all on 2026-10-05. It launched as a migration-only spare and now has a +promotion wave of its own, with the same five-minute canary C30 and C31 ran. The canary lands on +C34 because it is the emptiest general Asia cell once promoted. Promotion compares the director's +serving digest and C34's runtime digest with the one `image-digest` input, so C34 must first be +rolled to the director's image. That roll is a same-cap wave that enters and leaves +migration-only, so it moves nobody. C34 stays in the same-cap migration-only list until its promotion +succeeds, because a same-cap job reads a cell's class from that list, not from the selector. It +then moves to the general list and the fleet pool list together, as its own reviewed change, +before any same-cap wave names C34 again. Between promotion and that change, do not run a same-cap +wave on C34; a rollback there would demote it. Do not name it in the multi-target +`promote-general-cell` or `retire-migration-cell` modes, which accept any migration-only cell. It +is a declared rehome source. Rollback returns Asia cells to migration-only; it does not destroy the network or use existing-only. The production topology dispatch remains unavailable until the From 9b7c9369294d79e24ecbd0f57d007a7649775cca Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:15:53 +0000 Subject: [PATCH 07/48] fix(dock): clear stale workspace unread counts (#24877) * fix(dock): count only the unread the app shows Tab markers outlive the workspace unread flag, so the badge could sit at 1 with no sidebar dot to find or clear. Fixes #23363 * fix(dock): follow the sidebar's host filter in the unread count Counting folder workspaces and per-host rows by flag put rows on the Dock that a host-scoped sidebar does not show. * fix(dock): preserve owned folder alerts without expanding unread counts * fix(dock): honor the existing other-device worktree filter --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> --- src/renderer/src/App.tsx | 2 +- .../src/hooks/useUnreadDockBadge.test.ts | 140 ++++++--- src/renderer/src/hooks/useUnreadDockBadge.ts | 13 +- .../lib/unread-badge-count-migration.test.ts | 136 +++++++++ .../lib/unread-badge-count-selector.test.ts | 271 ++++++++++++++++++ .../src/lib/unread-badge-count-selector.ts | 132 +++++++-- .../src/lib/unread-badge-count.test.ts | 165 +++++++++-- src/renderer/src/lib/unread-badge-count.ts | 112 +++++--- 8 files changed, 846 insertions(+), 125 deletions(-) create mode 100644 src/renderer/src/lib/unread-badge-count-migration.test.ts create mode 100644 src/renderer/src/lib/unread-badge-count-selector.test.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1c1f72e5db8..b51cd836df2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -37,7 +37,7 @@ function App(): React.JSX.Element { const layout = useAppChromeLayout() const floatingWorkspace = useFloatingWorkspacePanel() const onboardingGate = useOnboardingAndFeatureTips() - const clearUnreadDockBadge = useUnreadDockBadge() + const clearUnreadDockBadge = useUnreadDockBadge(floatingWorkspace.open) // Why enabled && open: the overlay only renders while the feature is on, and its panel is // aria-hidden while closed — so that pair is what "on screen" means for the floating workspace. diff --git a/src/renderer/src/hooks/useUnreadDockBadge.test.ts b/src/renderer/src/hooks/useUnreadDockBadge.test.ts index 0fa95e03a17..5dd6cb5aa8a 100644 --- a/src/renderer/src/hooks/useUnreadDockBadge.test.ts +++ b/src/renderer/src/hooks/useUnreadDockBadge.test.ts @@ -3,7 +3,9 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as UnreadBadgeCountModule from '@/lib/unread-badge-count' +import { makeFolderWorkspace } from '@/store/slices/worktrees-slice-test-fixtures' import { makeTab, makeWorktree } from '@/store/slices/store-test-helpers' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../shared/constants' const { getUnreadBadgeCount } = vi.hoisted(() => ({ getUnreadBadgeCount: vi.fn() })) @@ -27,6 +29,7 @@ describe('useUnreadDockBadge', () => { { ...initialState, worktreesByRepo: {}, + folderWorkspaces: [], tabsByWorktree: {}, unreadTerminalTabs: {} }, @@ -71,20 +74,10 @@ describe('useUnreadDockBadge', () => { it('does not rescan workspaces for unrelated remote activity or parent renders', () => { const worktrees = Array.from({ length: 100 }, (_, index) => - makeWorktree({ id: `repo::worktree-${index}`, repoId: 'repo' }) + makeWorktree({ id: `repo::worktree-${index}`, repoId: 'repo', isUnread: index === 99 }) ) - const tabsByWorktree = Object.fromEntries( - worktrees.map((worktree, index) => [ - worktree.id, - [makeTab({ id: `tab-${index}`, worktreeId: worktree.id })] - ]) - ) - useAppStore.setState({ - worktreesByRepo: { repo: worktrees }, - tabsByWorktree, - unreadTerminalTabs: { 'tab-99': true } - }) - const hook = renderHook(() => useUnreadDockBadge()) + useAppStore.setState({ worktreesByRepo: { repo: worktrees } }) + const hook = renderHook(() => useUnreadDockBadge(false)) expect(getUnreadBadgeCount).toHaveBeenCalledTimes(1) act(() => { @@ -100,27 +93,30 @@ describe('useUnreadDockBadge', () => { expect(getUnreadBadgeCount).toHaveBeenCalledTimes(1) }) - it('recounts when worktree, tab, or unread references change', () => { - renderHook(() => useUnreadDockBadge()) - const worktree = makeWorktree({ - id: 'repo::unread', - repoId: 'repo' - }) + it('recounts when a workspace unread flag changes, not when a tab marker does', () => { + renderHook(() => useUnreadDockBadge(false)) + const worktree = makeWorktree({ id: 'repo::unread', repoId: 'repo' }) const tab = makeTab({ id: 'tab-unread', worktreeId: worktree.id }) act(() => useAppStore.setState({ worktreesByRepo: { repo: [worktree] } })) expect(getUnreadBadgeCount).toHaveBeenCalledTimes(2) expect(setUnreadDockBadgeCount).toHaveBeenLastCalledWith(0) - act(() => useAppStore.setState({ tabsByWorktree: { [worktree.id]: [tab] } })) - expect(getUnreadBadgeCount).toHaveBeenCalledTimes(3) + act(() => + useAppStore.setState({ + tabsByWorktree: { [worktree.id]: [tab] }, + unreadTerminalTabs: { [tab.id]: 'terminal-bell' } + }) + ) + expect(getUnreadBadgeCount).toHaveBeenCalledTimes(2) + expect(setUnreadDockBadgeCount).toHaveBeenLastCalledWith(0) - act(() => useAppStore.setState({ unreadTerminalTabs: { [tab.id]: true } })) - expect(getUnreadBadgeCount).toHaveBeenCalledTimes(4) + act(() => useAppStore.getState().markWorktreeUnread(worktree.id)) + expect(getUnreadBadgeCount).toHaveBeenCalledTimes(3) expect(setUnreadDockBadgeCount).toHaveBeenLastCalledWith(1) - act(() => useAppStore.setState({ unreadTerminalTabs: {} })) - expect(getUnreadBadgeCount).toHaveBeenCalledTimes(5) + act(() => useAppStore.getState().clearWorktreeUnread(worktree.id)) + expect(getUnreadBadgeCount).toHaveBeenCalledTimes(4) expect(setUnreadDockBadgeCount).toHaveBeenLastCalledWith(0) }) @@ -129,7 +125,7 @@ describe('useUnreadDockBadge', () => { // non-memoised overlay — for a badge integer that did not move. it('leaves the App root asleep through title frames and wakes it only on a badge change', () => { const worktrees = Array.from({ length: 20 }, (_, index) => - makeWorktree({ id: `repo::worktree-${index}`, repoId: 'repo' }) + makeWorktree({ id: `repo::worktree-${index}`, repoId: 'repo', isUnread: index === 19 }) ) const tabsByWorktree = Object.fromEntries( worktrees.map((worktree, index) => [ @@ -137,15 +133,11 @@ describe('useUnreadDockBadge', () => { [makeTab({ id: `tab-${index}`, worktreeId: worktree.id })] ]) ) - useAppStore.setState({ - worktreesByRepo: { repo: worktrees }, - tabsByWorktree, - unreadTerminalTabs: { 'tab-19': true } - }) + useAppStore.setState({ worktreesByRepo: { repo: worktrees }, tabsByWorktree }) let renders = 0 renderHook(() => { renders += 1 - return useUnreadDockBadge() + return useUnreadDockBadge(false) }) const rendersAfterMount = renders @@ -157,24 +149,92 @@ describe('useUnreadDockBadge', () => { expect(useAppStore.getState().tabsByWorktree).not.toBe(tabsByWorktree) expect(renders).toBe(rendersAfterMount) - // A tab becomes unread. - act(() => useAppStore.setState({ unreadTerminalTabs: { 'tab-19': true, 'tab-0': true } })) + act(() => useAppStore.getState().markWorktreeUnread('repo::worktree-0')) expect(renders).toBe(rendersAfterMount + 1) expect(setUnreadDockBadgeCount).toHaveBeenLastCalledWith(2) - // A tab is read. - act(() => useAppStore.setState({ unreadTerminalTabs: { 'tab-19': true } })) + act(() => useAppStore.getState().clearWorktreeUnread('repo::worktree-0')) expect(renders).toBe(rendersAfterMount + 2) expect(setUnreadDockBadgeCount).toHaveBeenLastCalledWith(1) + }) - // A tab holding unread state closes. + it('keeps the root asleep through folder title frames and unrelated attention writes', () => { + const folder = makeFolderWorkspace({ id: 'bell-folder', isUnread: true }) + const key = `folder:${folder.id}` + const tab = makeTab({ id: 'folder-bell', worktreeId: key }) + useAppStore.setState({ + projectGroups: [ + { + id: folder.projectGroupId, + name: 'folder-group', + parentPath: '/work', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 0, + updatedAt: 0 + } + ], + folderWorkspaces: [folder], + tabsByWorktree: { [key]: [tab] }, + unreadTerminalTabs: { [tab.id]: 'terminal-bell' } + }) + let renders = 0 + renderHook(() => { + renders += 1 + return useUnreadDockBadge(false) + }) + const initialRenders = renders + expect(setUnreadDockBadgeCount).toHaveBeenLastCalledWith(1) + for (let index = 0; index < 20; index += 1) { + act(() => useAppStore.getState().updateTabTitle(tab.id, `folder frame ${index}`)) + } act(() => useAppStore.setState({ - tabsByWorktree: { ...useAppStore.getState().tabsByWorktree, 'repo::worktree-19': [] }, - unreadTerminalTabs: {} + unreadTerminalTabs: { + ...useAppStore.getState().unreadTerminalTabs, + orphan: 'terminal-bell' + } }) ) - expect(renders).toBe(rendersAfterMount + 3) + expect(renders).toBe(initialRenders) + expect(getUnreadBadgeCount).toHaveBeenCalledTimes(1) + act(() => useAppStore.getState().clearTerminalTabUnread(tab.id)) + expect(renders).toBe(initialRenders + 1) expect(setUnreadDockBadgeCount).toHaveBeenLastCalledWith(0) }) + + it('adds the floating terminal only while its launcher shows the unread dot', () => { + const tab = makeTab({ id: 'floating-tab', worktreeId: FLOATING_TERMINAL_WORKTREE_ID }) + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), floatingTerminalEnabled: true }, + worktreesByRepo: { + repo: [makeWorktree({ id: 'repo::unread', repoId: 'repo', isUnread: true })] + }, + tabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [tab] } + }) + const hook = renderHook(({ open }) => useUnreadDockBadge(open), { + initialProps: { open: false } + }) + expect(setUnreadDockBadgeCount).toHaveBeenLastCalledWith(1) + + act(() => useAppStore.getState().markTerminalTabUnread(tab.id, 'terminal-bell')) + expect(setUnreadDockBadgeCount).toHaveBeenLastCalledWith(2) + + // An open panel hides the launcher dot; the bell is then only a tab highlight. + hook.rerender({ open: true }) + expect(setUnreadDockBadgeCount).toHaveBeenLastCalledWith(1) + + hook.rerender({ open: false }) + expect(setUnreadDockBadgeCount).toHaveBeenLastCalledWith(2) + + act(() => + useAppStore.setState({ + settings: { ...getDefaultSettings('/tmp'), floatingTerminalEnabled: false } + }) + ) + expect(setUnreadDockBadgeCount).toHaveBeenLastCalledWith(1) + }) }) diff --git a/src/renderer/src/hooks/useUnreadDockBadge.ts b/src/renderer/src/hooks/useUnreadDockBadge.ts index 1136c9e8300..4d66d42d3d4 100644 --- a/src/renderer/src/hooks/useUnreadDockBadge.ts +++ b/src/renderer/src/hooks/useUnreadDockBadge.ts @@ -1,6 +1,7 @@ import { useEffect, useMemo } from 'react' import { createUnreadBadgeCountSelector } from '@/lib/unread-badge-count-selector' import { useAppStore } from '@/store' +import { selectFloatingWorkspaceHasUnread } from '@/store/selectors' function setUnreadDockBadgeCountBestEffort(count: number): void { const setBadge = window.api?.app?.setUnreadDockBadgeCount @@ -16,12 +17,20 @@ export function clearUnreadDockBadgeCount(): void { setUnreadDockBadgeCountBestEffort(0) } -export function useUnreadDockBadge(): typeof clearUnreadDockBadgeCount { +export function useUnreadDockBadge( + floatingTerminalOpen: boolean +): typeof clearUnreadDockBadgeCount { // Why a selector and not the raw maps: this hook is mounted on the App root, so subscribing to // `tabsByWorktree` re-rendered the entire shell on every title frame. The selector both skips the // rescan and keeps the subscription quiet unless the badge integer itself changes. const selectUnreadBadgeCount = useMemo(() => createUnreadBadgeCountSelector(), []) - const unreadCount = useAppStore(selectUnreadBadgeCount) + const unreadWorkspaceCount = useAppStore(selectUnreadBadgeCount) + // Why: the floating terminal has no workspace flag, so it counts exactly while its launcher shows the dot. + const floatingTerminalHasUnread = useAppStore( + (s) => s.settings?.floatingTerminalEnabled === true && selectFloatingWorkspaceHasUnread(s) + ) + const unreadCount = + unreadWorkspaceCount + (floatingTerminalHasUnread && !floatingTerminalOpen ? 1 : 0) // oxlint-disable-next-line react-doctor/no-derived-state-effect -- Why: this syncs an external OS dock badge, not React render state. useEffect(() => { diff --git a/src/renderer/src/lib/unread-badge-count-migration.test.ts b/src/renderer/src/lib/unread-badge-count-migration.test.ts new file mode 100644 index 00000000000..b8ebb7a3fc2 --- /dev/null +++ b/src/renderer/src/lib/unread-badge-count-migration.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { makeTab, makeWorktree, TEST_REPO } from '@/store/slices/store-test-helpers' +import { makeFolderWorkspace } from '@/store/slices/worktrees-slice-test-fixtures' +import type { ProjectGroup } from '../../../shared/project-group-types' +import { getUnreadBadgeCount, type UnreadBadgeCountSources } from './unread-badge-count' + +const projectGroup = { + id: 'group-1', + name: 'platform', + parentPath: '/work', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 0, + updatedAt: 0 +} satisfies ProjectGroup + +const visibility = { + projectGroups: [projectGroup], + repoMap: new Map([[TEST_REPO.id, TEST_REPO]]), + visibleHostIds: null, + defaultHostId: 'local' as const, + hiddenOtherDevicePairings: null +} + +// The old count is the compatibility ceiling, including its orphan-marker behavior. +function baselineCount(sources: UnreadBadgeCountSources): number { + const workspaces = new Set() + for (const rows of Object.values(sources.worktreesByRepo)) { + for (const row of rows) { + if (row.isUnread) { + workspaces.add(row.id) + } + } + } + const markers = new Set(Object.keys(sources.unreadTerminalTabs ?? {})) + for (const [key, tabs] of Object.entries(sources.tabsByWorktree ?? {})) { + for (const tab of tabs) { + if (markers.delete(tab.id)) { + workspaces.add(key) + } + } + } + return workspaces.size + markers.size +} + +describe('Dock count migration does not introduce hidden notifications', () => { + it('never exceeds the old count across workspace, live-owner, and marker combinations', () => { + // Host/device/group membership can only subtract from the baseline count. + for (let bits = 0; bits < 256; bits += 1) { + const enabled = (bit: number): boolean => (bits & (1 << bit)) !== 0 + const sources = { + ...visibility, + worktreesByRepo: { + repo: [ + makeWorktree({ + id: 'same-id', + repoId: TEST_REPO.id, + hostId: 'local', + isUnread: enabled(0), + isArchived: enabled(1) + }), + makeWorktree({ + id: 'same-id', + repoId: TEST_REPO.id, + hostId: 'ssh:remote', + isUnread: enabled(2), + isArchived: false + }) + ] + }, + folderWorkspaces: [makeFolderWorkspace({ id: 'folder', isUnread: enabled(3) })], + tabsByWorktree: { + 'folder:folder': enabled(4) ? [makeTab({ id: 'bell', worktreeId: 'folder:folder' })] : [] + }, + unifiedTabsByWorktree: { + 'folder:folder': enabled(5) ? [{ id: 'chat', contentType: 'agent-session' }] : [] + }, + unreadTerminalTabs: { + ...(enabled(6) ? { bell: 'terminal-bell' as const } : {}), + ...(enabled(7) ? { chat: 'terminal-bell' as const } : {}), + orphan: 'terminal-bell' as const + } + } satisfies UnreadBadgeCountSources + const hostFilters: UnreadBadgeCountSources['visibleHostIds'][] = [ + null, + new Set(['local']), + new Set(['ssh:remote']), + new Set() + ] + const groupStates = [ + [projectGroup], + [{ ...projectGroup, isCollapsed: true }], + [{ ...projectGroup, parentPath: null }], + [] + ] + for (const visibleHostIds of hostFilters) { + for (const projectGroups of groupStates) { + for (const hiddenOtherDevicePairings of [null, new Map()]) { + expect( + getUnreadBadgeCount({ + ...sources, + visibleHostIds, + projectGroups, + hiddenOtherDevicePairings + }), + `combination ${bits}` + ).toBeLessThanOrEqual(baselineCount(sources)) + } + } + } + } + }) + + it('preserves legitimate folder terminal alerts, dedupes siblings, and clears with the workspace', () => { + const sources = { + ...visibility, + worktreesByRepo: {}, + folderWorkspaces: [makeFolderWorkspace({ id: 'folder', isUnread: true })], + tabsByWorktree: { + 'folder:folder': ['one', 'two'].map((id) => makeTab({ id, worktreeId: 'folder:folder' })) + }, + unreadTerminalTabs: { one: 'terminal-bell', two: 'terminal-bell' } + } satisfies UnreadBadgeCountSources + expect(getUnreadBadgeCount(sources)).toBe(baselineCount(sources)) + expect(getUnreadBadgeCount(sources)).toBe(1) + expect( + getUnreadBadgeCount({ + ...sources, + folderWorkspaces: [makeFolderWorkspace({ id: 'folder', isUnread: false })] + }) + ).toBe(0) + }) +}) diff --git a/src/renderer/src/lib/unread-badge-count-selector.test.ts b/src/renderer/src/lib/unread-badge-count-selector.test.ts new file mode 100644 index 00000000000..f07810b71fa --- /dev/null +++ b/src/renderer/src/lib/unread-badge-count-selector.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTabsSliceMockApi } from '@/store/slices/tabs-slice-test-harness' +import { + createTestStore, + makeTab, + makeWorktree, + TEST_REPO +} from '@/store/slices/store-test-helpers' +import { makeFolderWorkspace } from '@/store/slices/worktrees-slice-test-fixtures' +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../shared/project-group-types' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { createUnreadBadgeCountSelector } from './unread-badge-count-selector' + +// Why: marking a folder workspace unread persists through this API; echo the write back. +Object.assign(createTabsSliceMockApi(), { + folderWorkspaces: { + update: vi.fn(async ({ updates }: { updates: Partial }) => ({ + ...makeFolderWorkspace({ connectionId: 'ssh-1' }), + ...updates + })) + } +}) + +const BELL_WORKTREE = 'repo1::/path/bell' +const OTHER_WORKTREE = 'repo1::/path/other' + +type TestStore = ReturnType + +function makeProjectGroup(overrides: Partial = {}): ProjectGroup { + return { + id: 'group-1', + name: 'platform', + parentPath: '/work', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 0, + updatedAt: 0, + ...overrides + } +} + +function createStoreOnOtherWorktree(): TestStore { + const store = createTestStore() + store.setState({ + repos: [{ ...TEST_REPO, executionHostId: 'local' }], + worktreesByRepo: { + repo1: [ + makeWorktree({ id: BELL_WORKTREE, repoId: 'repo1', path: '/path/bell' }), + makeWorktree({ id: OTHER_WORKTREE, repoId: 'repo1', path: '/path/other' }) + ] + }, + activeWorktreeId: OTHER_WORKTREE + }) + return store +} + +function addTerminalTab(store: TestStore, worktreeId: string): string { + const unifiedTab = store.getState().createUnifiedTab(worktreeId, 'terminal') + const tabs = store.getState().tabsByWorktree[worktreeId] ?? [] + store.setState({ + tabsByWorktree: { + ...store.getState().tabsByWorktree, + [worktreeId]: [...tabs, makeTab({ id: unifiedTab.entityId, worktreeId })] + } + }) + return unifiedTab.entityId +} + +// The two writes every bell and agent completion makes. +function raiseAttention(store: TestStore, worktreeId: string, tabId: string): void { + store.getState().markWorktreeUnread(worktreeId) + store.getState().markTerminalTabUnread(tabId, 'terminal-bell') +} + +function sidebarUnreadCount(store: TestStore): number { + return Object.values(store.getState().worktreesByRepo) + .flat() + .filter((worktree) => worktree.isUnread).length +} + +function dockCount(store: TestStore): number { + return createUnreadBadgeCountSelector()(store.getState()) +} + +describe('Dock unread count against the sidebar (#23363)', () => { + it('follows the workspace dot when a bell is raised and the workspace is then visited', () => { + const store = createStoreOnOtherWorktree() + const tabId = addTerminalTab(store, BELL_WORKTREE) + + raiseAttention(store, BELL_WORKTREE, tabId) + expect(dockCount(store)).toBe(1) + + store.getState().setActiveWorktree(BELL_WORKTREE) + store.getState().setActiveWorktree(OTHER_WORKTREE) + + expect(sidebarUnreadCount(store)).toBe(0) + expect(dockCount(store)).toBe(0) + }) + + it('clears with the workspace dot when the user types in a sibling tab', () => { + const store = createStoreOnOtherWorktree() + const typedTabId = addTerminalTab(store, BELL_WORKTREE) + const bellTabId = addTerminalTab(store, BELL_WORKTREE) + store.getState().setActiveWorktree(BELL_WORKTREE) + raiseAttention(store, BELL_WORKTREE, bellTabId) + + store.getState().clearTerminalTabUnread(typedTabId) + store.getState().clearWorktreeUnread(BELL_WORKTREE) + + expect(store.getState().unreadTerminalTabs[bellTabId]).toBe('terminal-bell') + expect(sidebarUnreadCount(store)).toBe(0) + expect(dockCount(store)).toBe(0) + }) + + it('counts a flagged workspace once when the marked tab is a chat tab', () => { + const store = createStoreOnOtherWorktree() + const chatTab = store + .getState() + .createUnifiedTab(BELL_WORKTREE, 'agent-session', { id: 'session-1' }) + + raiseAttention(store, BELL_WORKTREE, chatTab.id) + + expect(store.getState().unreadTerminalTabs[chatTab.id]).toBe('terminal-bell') + expect(dockCount(store)).toBe(1) + }) + + // Why one selector across writes: the App root keeps a single instance, so its cache is under test. + it('preserves a folder bell only while its workspace flag and live tab marker remain', () => { + const store = createStoreOnOtherWorktree() + const selectCount = createUnreadBadgeCountSelector() + const folderWorkspace = makeFolderWorkspace() + store.setState({ projectGroups: [makeProjectGroup()], folderWorkspaces: [folderWorkspace] }) + expect(selectCount(store.getState())).toBe(0) + + store.setState({ folderWorkspaces: [{ ...folderWorkspace, isUnread: true }] }) + expect(selectCount(store.getState())).toBe(0) + const key = folderWorkspaceKey(folderWorkspace.id) + const tabId = addTerminalTab(store, key) + store.getState().markTerminalTabUnread(tabId, 'terminal-bell') + expect(selectCount(store.getState())).toBe(1) + store.setState({ tabsByWorktree: {} }) + expect(selectCount(store.getState())).toBe(0) + const chat = store.getState().createUnifiedTab(key, 'agent-session', { id: 'folder-chat' }) + store.getState().markTerminalTabUnread(chat.id, 'terminal-bell') + expect(selectCount(store.getState())).toBe(1) + store.setState({ folderWorkspaces: [{ ...folderWorkspace, isUnread: false }] }) + expect(selectCount(store.getState())).toBe(0) + }) + + it('drops a flagged worktree when it is archived in place', () => { + const store = createStoreOnOtherWorktree() + const selectCount = createUnreadBadgeCountSelector() + store.getState().markWorktreeUnread(BELL_WORKTREE) + expect(selectCount(store.getState())).toBe(1) + + store.setState({ + worktreesByRepo: { + repo1: store + .getState() + .worktreesByRepo.repo1.map((worktree) => + worktree.id === BELL_WORKTREE ? { ...worktree, isArchived: true } : worktree + ) + } + }) + expect(selectCount(store.getState())).toBe(0) + }) + + it('preserves id-only deduplication when a second host publishes a row', () => { + const store = createTestStore() + const selectCount = createUnreadBadgeCountSelector() + const row = makeWorktree({ id: BELL_WORKTREE, repoId: 'repo1', isUnread: true }) + store.setState({ worktreesByRepo: { repo1: [row, row] } }) + expect(selectCount(store.getState())).toBe(1) + + store.setState({ worktreesByRepo: { repo1: [row, { ...row, hostId: 'ssh:remote' }] } }) + expect(selectCount(store.getState())).toBe(1) + }) + + it('recounts device visibility when creator provenance or runtime ownership changes', () => { + const store = createStoreOnOtherWorktree() + const selectCount = createUnreadBadgeCountSelector() + const row = makeWorktree({ + id: BELL_WORKTREE, + repoId: 'repo1', + isUnread: true, + creatorProvenance: { kind: 'paired-device', deviceId: 'phone' } + }) + store.setState({ worktreesByRepo: { repo1: [row] } }) + expect(selectCount(store.getState())).toBe(1) + store.setState({ hideWorkspacesFromOtherDevices: true }) + expect(selectCount(store.getState())).toBe(0) + store.setState({ + worktreesByRepo: { repo1: [{ ...row, creatorProvenance: { kind: 'host' } }] } + }) + expect(selectCount(store.getState())).toBe(1) + store.setState({ worktreesByRepo: { repo1: [row] } }) + expect(selectCount(store.getState())).toBe(0) + store.setState({ + worktreesByRepo: { repo1: [{ ...row, runtimeOwnerEnvironmentId: 'unpaired-env' }] } + }) + expect(selectCount(store.getState())).toBe(1) + }) + + describe('under the sidebar host filter', () => { + it('skips an unread SSH folder workspace until its host is shown', () => { + const store = createStoreOnOtherWorktree() + const selectCount = createUnreadBadgeCountSelector() + const folderWorkspace = makeFolderWorkspace({ connectionId: 'ssh-1' }) + store.setState({ + projectGroups: [makeProjectGroup({ connectionId: 'ssh-1' })], + folderWorkspaces: [folderWorkspace] + }) + const tabId = addTerminalTab(store, folderWorkspaceKey(folderWorkspace.id)) + store.getState().markTerminalTabUnread(tabId, 'terminal-bell') + store.getState().setVisibleWorkspaceHostIds(['local']) + + store.getState().markWorktreeUnread(folderWorkspaceKey(folderWorkspace.id)) + expect(store.getState().folderWorkspaces[0].isUnread).toBe(true) + expect(selectCount(store.getState())).toBe(0) + + store.getState().setVisibleWorkspaceHostIds(['local', 'ssh:ssh-1']) + expect(selectCount(store.getState())).toBe(1) + }) + + it('recounts when a project group or repo changes which host a flagged row is on', () => { + const store = createStoreOnOtherWorktree() + const selectCount = createUnreadBadgeCountSelector() + store.setState({ + projectGroups: [makeProjectGroup()], + folderWorkspaces: [makeFolderWorkspace({ isUnread: true })] + }) + const tabId = addTerminalTab( + store, + folderWorkspaceKey(store.getState().folderWorkspaces[0].id) + ) + store.getState().markTerminalTabUnread(tabId, 'terminal-bell') + store.getState().markWorktreeUnread(BELL_WORKTREE) + store.getState().setVisibleWorkspaceHostIds(['local']) + expect(selectCount(store.getState())).toBe(2) + + store.setState({ projectGroups: [makeProjectGroup({ connectionId: 'ssh-1' })] }) + expect(selectCount(store.getState())).toBe(1) + + store.setState({ repos: [{ ...TEST_REPO, connectionId: 'ssh-1' }] }) + expect(selectCount(store.getState())).toBe(0) + }) + + it('narrows host visibility without adding a same-id count', () => { + const store = createTestStore() + const selectCount = createUnreadBadgeCountSelector() + store.setState({ + repos: [{ ...TEST_REPO, executionHostId: 'local' }], + worktreesByRepo: { + repo1: [ + makeWorktree({ id: BELL_WORKTREE, repoId: 'repo1', hostId: 'local' }), + makeWorktree({ id: BELL_WORKTREE, repoId: 'repo1', hostId: 'ssh:ssh-1' }) + ] + } + }) + store.getState().markWorktreeUnread(BELL_WORKTREE) + expect(selectCount(store.getState())).toBe(1) + + store.getState().setVisibleWorkspaceHostIds(['local']) + expect(selectCount(store.getState())).toBe(1) + }) + }) +}) diff --git a/src/renderer/src/lib/unread-badge-count-selector.ts b/src/renderer/src/lib/unread-badge-count-selector.ts index a7e0bb81999..862387e4196 100644 --- a/src/renderer/src/lib/unread-badge-count-selector.ts +++ b/src/renderer/src/lib/unread-badge-count-selector.ts @@ -1,19 +1,86 @@ +import { getVisibleWorkspaceHostIdSet } from '@/components/sidebar/visible-worktree-host-scope' +import { getPairedDeviceIdsByEnvironment } from '@/components/sidebar/workspace-creator-visibility' +import { getRepoMapFromState } from '@/store/selectors' +import type { AppState } from '@/store/types' +import { getSettingsFocusedExecutionHostId } from '../../../shared/execution-host' +import type { Worktree } from '../../../shared/worktree/types' import { sameBucketRecords } from './bucket-record-equality' -import { - type UnreadBadgeCountSources, - type UnreadBadgeTab, - type UnreadBadgeWorktree, - getUnreadBadgeCount -} from './unread-badge-count' +import { getUnreadBadgeCount, hasUnreadFolderTab } from './unread-badge-count' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' -const EMPTY_BUCKETS = Object.freeze({}) +type UnreadBadgeCountState = Pick< + AppState, + | 'worktreesByRepo' + | 'folderWorkspaces' + | 'projectGroups' + | 'repos' + | 'settings' + | 'workspaceHostScope' + | 'visibleWorkspaceHostIds' + | 'hideWorkspacesFromOtherDevices' + | 'runtimeEnvironments' + | 'runtimeStatusByEnvironmentId' + | 'tabsByWorktree' + | 'unifiedTabsByWorktree' + | 'unreadTerminalTabs' +> -function sameBadgeWorktree(previous: UnreadBadgeWorktree, next: UnreadBadgeWorktree): boolean { - return previous.id === next.id && previous.isUnread === next.isUnread +/** The worktree fields the count reads (`id` embeds `repoId`), so equality over them is a sound cache key. */ +function sameBadgeWorktree(previous: Worktree, next: Worktree): boolean { + const previousCreator = previous.creatorProvenance + const nextCreator = next.creatorProvenance + return ( + previous.runtimeOwnerEnvironmentId === next.runtimeOwnerEnvironmentId && + previousCreator?.kind === nextCreator?.kind && + (previousCreator?.kind !== 'paired-device' || + (nextCreator?.kind === 'paired-device' && + previousCreator.deviceId === nextCreator.deviceId)) && + previous.id === next.id && + previous.hostId === next.hostId && + previous.isUnread === next.isUnread && + previous.isArchived === next.isArchived + ) } -function sameBadgeTab(previous: UnreadBadgeTab, next: UnreadBadgeTab): boolean { - return previous.id === next.id +function sameFolderAttention( + previous: UnreadBadgeCountState, + next: UnreadBadgeCountState +): boolean { + if ( + previous.tabsByWorktree === next.tabsByWorktree && + previous.unifiedTabsByWorktree === next.unifiedTabsByWorktree && + previous.unreadTerminalTabs === next.unreadTerminalTabs + ) { + return true + } + for (const folder of next.folderWorkspaces) { + if (!folder.isUnread) { + continue + } + const key = folderWorkspaceKey(folder.id) + if (hasUnreadFolderTab(previous, key) !== hasUnreadFolderTab(next, key)) { + return false + } + } + return true +} + +function sameCountInputs(previous: UnreadBadgeCountState, next: UnreadBadgeCountState): boolean { + return ( + previous.folderWorkspaces === next.folderWorkspaces && + previous.projectGroups === next.projectGroups && + previous.repos === next.repos && + previous.workspaceHostScope === next.workspaceHostScope && + previous.visibleWorkspaceHostIds === next.visibleWorkspaceHostIds && + previous.settings?.activeRuntimeEnvironmentId === next.settings?.activeRuntimeEnvironmentId && + previous.hideWorkspacesFromOtherDevices === next.hideWorkspacesFromOtherDevices && + // Why gated: runtime status reallocates on remote activity and only this filter reads it. + (!next.hideWorkspacesFromOtherDevices || + (previous.runtimeEnvironments === next.runtimeEnvironments && + previous.runtimeStatusByEnvironmentId === next.runtimeStatusByEnvironmentId)) && + sameBucketRecords(previous.worktreesByRepo, next.worktreesByRepo, sameBadgeWorktree) && + sameFolderAttention(previous, next) + ) } /** @@ -21,30 +88,35 @@ function sameBadgeTab(previous: UnreadBadgeTab, next: UnreadBadgeTab): boolean { * the whole shell on every agent title frame; selecting the count instead means the subscription * only notifies when the badge value can actually have moved. * - * Why chaining against the immediately preceding state is enough: equality over the count's read set - * — worktree `id`/`isUnread`, tab `id`, and the unread map identity — is transitive, so a run of - * unchanged states is equivalent to comparing against the state that produced the cached count. + * Why chaining against the immediately preceding state is enough: equality over the count's read + * set is transitive, so a run of unchanged states is equivalent to comparing against the state + * that produced the cached count. */ -export function createUnreadBadgeCountSelector(): (state: UnreadBadgeCountSources) => number { - let previousWorktreesByRepo: UnreadBadgeCountSources['worktreesByRepo'] = EMPTY_BUCKETS - let previousTabsByWorktree: UnreadBadgeCountSources['tabsByWorktree'] = EMPTY_BUCKETS - let previousUnreadTerminalTabs: UnreadBadgeCountSources['unreadTerminalTabs'] | undefined +export function createUnreadBadgeCountSelector(): (state: UnreadBadgeCountState) => number { + let previousState: UnreadBadgeCountState | undefined let unreadCount = 0 - let counted = false return (state) => { - const unchanged = - counted && - previousUnreadTerminalTabs === state.unreadTerminalTabs && - sameBucketRecords(previousWorktreesByRepo, state.worktreesByRepo, sameBadgeWorktree) && - sameBucketRecords(previousTabsByWorktree, state.tabsByWorktree, sameBadgeTab) - if (!unchanged) { - unreadCount = getUnreadBadgeCount(state) - previousUnreadTerminalTabs = state.unreadTerminalTabs - counted = true + if (!previousState || !sameCountInputs(previousState, state)) { + unreadCount = getUnreadBadgeCount({ + worktreesByRepo: state.worktreesByRepo, + folderWorkspaces: state.folderWorkspaces, + tabsByWorktree: state.tabsByWorktree, + unifiedTabsByWorktree: state.unifiedTabsByWorktree, + unreadTerminalTabs: state.unreadTerminalTabs, + projectGroups: state.projectGroups, + repoMap: getRepoMapFromState(state), + visibleHostIds: getVisibleWorkspaceHostIdSet(state), + defaultHostId: getSettingsFocusedExecutionHostId(state.settings), + hiddenOtherDevicePairings: state.hideWorkspacesFromOtherDevices + ? getPairedDeviceIdsByEnvironment( + state.runtimeEnvironments, + state.runtimeStatusByEnvironmentId + ) + : null + }) } - previousWorktreesByRepo = state.worktreesByRepo - previousTabsByWorktree = state.tabsByWorktree + previousState = state return unreadCount } } diff --git a/src/renderer/src/lib/unread-badge-count.test.ts b/src/renderer/src/lib/unread-badge-count.test.ts index 8d031579522..87a9980497e 100644 --- a/src/renderer/src/lib/unread-badge-count.test.ts +++ b/src/renderer/src/lib/unread-badge-count.test.ts @@ -1,44 +1,169 @@ import { describe, expect, it } from 'vitest' -import type { TerminalTab } from '../../../shared/terminal-tab-types' +import { makeTab, makeWorktree, TEST_REPO } from '@/store/slices/store-test-helpers' +import { makeFolderWorkspace } from '@/store/slices/worktrees-slice-test-fixtures' +import type { ProjectGroup } from '../../../shared/project-group-types' import type { Worktree } from '../../../shared/worktree/types' -import { getUnreadBadgeCount } from './unread-badge-count' +import { getUnreadBadgeCount, type UnreadBadgeCountSources } from './unread-badge-count' -function worktree(id: string, isUnread: boolean): Worktree { - return { id, isUnread } as Worktree +function worktree(id: string, overrides: Partial = {}): Worktree { + return makeWorktree({ id, repoId: TEST_REPO.id, isUnread: true, ...overrides }) } -function tab(id: string): TerminalTab { - return { id } as TerminalTab +function projectGroup(overrides: Partial = {}): ProjectGroup { + return { + id: 'group-1', + name: 'platform', + parentPath: '/work', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 0, + updatedAt: 0, + ...overrides + } +} + +function count(overrides: Partial): number { + return getUnreadBadgeCount({ + worktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [projectGroup()], + repoMap: new Map([[TEST_REPO.id, TEST_REPO]]), + visibleHostIds: null, + defaultHostId: 'local', + hiddenOtherDevicePairings: null, + ...overrides, + // These visibility fixtures represent folder bells with live tab owners. + tabsByWorktree: + overrides.tabsByWorktree ?? + Object.fromEntries( + (overrides.folderWorkspaces ?? []).map((folder) => [ + `folder:${folder.id}`, + [makeTab({ id: `bell:${folder.id}`, worktreeId: `folder:${folder.id}` })] + ]) + ), + unreadTerminalTabs: + overrides.unreadTerminalTabs ?? + Object.fromEntries( + (overrides.folderWorkspaces ?? []).map((folder) => [ + `bell:${folder.id}`, + 'terminal-bell' as const + ]) + ) + }) } describe('getUnreadBadgeCount', () => { it('counts unread worktrees', () => { expect( - getUnreadBadgeCount({ - worktreesByRepo: { repo: [worktree('wt-1', true), worktree('wt-2', false)] }, - tabsByWorktree: {}, - unreadTerminalTabs: {} + count({ + worktreesByRepo: { repo1: [worktree('wt-1'), worktree('wt-2', { isUnread: false })] } }) ).toBe(1) }) - it('dedupes unread terminal tabs against their worktree', () => { + it('skips archived worktrees, which the sidebar never shows', () => { + expect(count({ worktreesByRepo: { repo1: [worktree('wt-1', { isArchived: true })] } })).toBe(0) + }) + + it('preserves id-only deduplication across execution hosts', () => { + const rows = [worktree('wt-1', { hostId: 'local' }), worktree('wt-1', { hostId: 'ssh:remote' })] + expect(count({ worktreesByRepo: { repo1: rows } })).toBe(1) + expect(count({ worktreesByRepo: { repo1: rows }, visibleHostIds: new Set(['local']) })).toBe(1) + }) + + it('counts a row repeated across repo buckets once', () => { expect( - getUnreadBadgeCount({ - worktreesByRepo: { repo: [worktree('wt-1', true)] }, - tabsByWorktree: { 'wt-1': [tab('tab-1'), tab('tab-2')] }, - unreadTerminalTabs: { 'tab-1': true, 'tab-2': true } + count({ + worktreesByRepo: { + 'repo-a': [worktree('wt-1', { hostId: 'local' })], + 'repo-b': [worktree('wt-1', { hostId: 'local' })] + } }) ).toBe(1) }) - it('counts tab-only unread activity by owning worktree', () => { + it('counts unread folder workspaces alongside worktrees', () => { expect( - getUnreadBadgeCount({ - worktreesByRepo: { repo: [worktree('wt-1', false), worktree('wt-2', false)] }, - tabsByWorktree: { 'wt-1': [tab('tab-1')], 'wt-2': [tab('tab-2')] }, - unreadTerminalTabs: { 'tab-1': true, 'tab-2': true } + count({ + worktreesByRepo: { repo1: [worktree('wt-1')] }, + folderWorkspaces: [ + makeFolderWorkspace({ id: 'folder-1', isUnread: true }), + makeFolderWorkspace({ id: 'folder-2' }) + ] }) ).toBe(2) }) + + it('uses the same other-device policy for git worktrees as for folder rows', () => { + const foreign = worktree('foreign', { + creatorProvenance: { kind: 'paired-device', deviceId: 'phone' } + }) + expect(count({ worktreesByRepo: { repo1: [foreign] } })).toBe(1) + expect( + count({ worktreesByRepo: { repo1: [foreign] }, hiddenOtherDevicePairings: new Map() }) + ).toBe(0) + const runtimeOwned = { ...foreign, runtimeOwnerEnvironmentId: 'env' } + expect( + count({ + worktreesByRepo: { repo1: [runtimeOwned] }, + hiddenOtherDevicePairings: new Map([['env', 'phone']]) + }) + ).toBe(1) + expect( + count({ + worktreesByRepo: { repo1: [runtimeOwned] }, + hiddenOtherDevicePairings: new Map([['env', 'other']]) + }) + ).toBe(0) + }) + + it('adds no flag-only folder or orphan-marker counts', () => { + const folderWorkspaces = [makeFolderWorkspace({ isUnread: true })] + expect(count({ folderWorkspaces, unreadTerminalTabs: {} })).toBe(0) + expect( + count({ + folderWorkspaces, + tabsByWorktree: {}, + unreadTerminalTabs: { orphan: 'terminal-bell' } + }) + ).toBe(0) + }) + + it('skips an unread folder workspace the sidebar has no row for', () => { + const localOnly = { visibleHostIds: new Set(['local'] as const) } + const remoteFolderInLocalGroup = { + folderWorkspaces: [makeFolderWorkspace({ isUnread: true, executionHostId: 'ssh:ssh-1' })] + } + const localFolderInRemoteGroup = { + folderWorkspaces: [makeFolderWorkspace({ isUnread: true, executionHostId: 'local' })], + projectGroups: [projectGroup({ connectionId: 'ssh-1' })] + } + + expect(count(remoteFolderInLocalGroup)).toBe(1) + expect(count({ ...remoteFolderInLocalGroup, ...localOnly })).toBe(0) + expect(count(localFolderInRemoteGroup)).toBe(1) + expect(count({ ...localFolderInRemoteGroup, ...localOnly })).toBe(0) + // A group with no folder on disk renders no rows. + expect( + count({ + folderWorkspaces: [makeFolderWorkspace({ isUnread: true })], + projectGroups: [projectGroup({ parentPath: null })] + }) + ).toBe(0) + }) + + it('skips a folder workspace from another device while the sidebar hides those', () => { + const fromOtherDevice = makeFolderWorkspace({ + isUnread: true, + creatorProvenance: { kind: 'paired-device', deviceId: 'phone' } + }) + + expect(count({ folderWorkspaces: [fromOtherDevice] })).toBe(1) + expect( + count({ folderWorkspaces: [fromOtherDevice], hiddenOtherDevicePairings: new Map() }) + ).toBe(0) + }) }) diff --git a/src/renderer/src/lib/unread-badge-count.ts b/src/renderer/src/lib/unread-badge-count.ts index 50e04f61eee..3ffda1db770 100644 --- a/src/renderer/src/lib/unread-badge-count.ts +++ b/src/renderer/src/lib/unread-badge-count.ts @@ -1,47 +1,95 @@ +import { + filterFolderWorkspacesForVisibleHosts, + filterProjectGroupsForVisibleHosts +} from '@/components/sidebar/worktree-list/listing/host-filtering' +import { getRenderableFolderWorkspaces } from '@/components/sidebar/worktree-list/grouping/folder-workspace-lanes' +import { worktreeMatchesVisibleHost } from '@/components/sidebar/visible-worktree-host-scope' +import { + filterFolderWorkspacesFromOtherDevices, + isWorkspaceFromOtherDevice +} from '@/components/sidebar/workspace-creator-visibility' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { FolderWorkspace } from '../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../shared/project-group-types' +import type { Repo } from '../../../shared/repo-types' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' import type { StoredAgentAttentionUnread } from '@/attention/agent-attention-contract' import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { Tab } from '../../../shared/tab-types' import type { Worktree } from '../../../shared/worktree/types' -/** The only fields the count reads, so a projection over them is a sound cache key. */ -export type UnreadBadgeWorktree = Pick -export type UnreadBadgeTab = Pick - export type UnreadBadgeCountSources = { - worktreesByRepo: Readonly> - tabsByWorktree: Readonly> - unreadTerminalTabs: Readonly> + worktreesByRepo: Readonly> + folderWorkspaces: readonly FolderWorkspace[] + projectGroups: readonly ProjectGroup[] + repoMap: Map + /** null when the sidebar shows every host. */ + visibleHostIds: ReadonlySet | null + defaultHostId: ExecutionHostId + /** null unless the sidebar hides workspaces created from other devices. */ + hiddenOtherDevicePairings: ReadonlyMap | null + tabsByWorktree?: Readonly[]>> + unifiedTabsByWorktree?: Readonly[]>> + unreadTerminalTabs?: Readonly> } -export function getUnreadBadgeCount({ - worktreesByRepo, - tabsByWorktree, - unreadTerminalTabs -}: UnreadBadgeCountSources): number { - const unreadWorktreeIds = new Set() +export function hasUnreadFolderTab( + sources: Pick< + UnreadBadgeCountSources, + 'tabsByWorktree' | 'unifiedTabsByWorktree' | 'unreadTerminalTabs' + >, + key: string +): boolean { + return Boolean( + sources.tabsByWorktree?.[key]?.some((tab) => sources.unreadTerminalTabs?.[tab.id]) || + sources.unifiedTabsByWorktree?.[key]?.some( + (tab) => tab.contentType === 'agent-session' && sources.unreadTerminalTabs?.[tab.id] + ) + ) +} - for (const worktrees of Object.values(worktreesByRepo)) { +/** Workspace flags clear on a visit; tab markers can outlive that visit or their owner. */ +export function getUnreadBadgeCount(sources: UnreadBadgeCountSources): number { + const { visibleHostIds, defaultHostId } = sources + // Preserve the existing id-only count while narrowing it to visible hosts. + const unreadWorktrees = new Set() + for (const worktrees of Object.values(sources.worktreesByRepo)) { for (const worktree of worktrees) { - if (worktree.isUnread) { - unreadWorktreeIds.add(worktree.id) + // Why: the sidebar never renders an archived worktree, nor one on a host it is not showing. + if ( + worktree.isUnread && + !worktree.isArchived && + (!sources.hiddenOtherDevicePairings || + !isWorkspaceFromOtherDevice(worktree, sources.hiddenOtherDevicePairings)) && + worktreeMatchesVisibleHost(worktree, visibleHostIds, sources.repoMap, defaultHostId) + ) { + unreadWorktrees.add(worktree.id) } } } + return unreadWorktrees.size + countUnreadFolderRows(sources) +} - const unreadTabIds = new Set(Object.keys(unreadTerminalTabs)) - if (unreadTabIds.size === 0) { - return unreadWorktreeIds.size +/** Folder workspaces through the same membership steps the sidebar runs before building rows. */ +function countUnreadFolderRows(sources: UnreadBadgeCountSources): number { + const { projectGroups, visibleHostIds, defaultHostId, hiddenOtherDevicePairings } = sources + const unread = sources.folderWorkspaces.filter( + (folder) => folder.isUnread && hasUnreadFolderTab(sources, folderWorkspaceKey(folder.id)) + ) + if (unread.length === 0) { + return 0 } - - for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { - for (const tab of tabs) { - if (!unreadTabIds.delete(tab.id)) { - continue - } - unreadWorktreeIds.add(worktreeId) - } - } - - // Why: tab unread state should normally map to a live worktree, but counting - // unmatched entries keeps the Dock badge honest during hydration races. - return unreadWorktreeIds.size + unreadTabIds.size + const onVisibleHosts = filterFolderWorkspacesForVisibleHosts( + unread, + projectGroups, + visibleHostIds, + defaultHostId + ) + const rows = getRenderableFolderWorkspaces( + hiddenOtherDevicePairings + ? filterFolderWorkspacesFromOtherDevices(onVisibleHosts, hiddenOtherDevicePairings) + : onVisibleHosts, + filterProjectGroupsForVisibleHosts(projectGroups, visibleHostIds, defaultHostId) + ) + return new Set(rows.map(({ folderWorkspace }) => folderWorkspace.id)).size } From 059e81a106f675aa445bdce4a4e8a9ed56cfa9f1 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:19:43 -0700 Subject: [PATCH 08/48] =?UTF-8?q?chore(i18n):=20use=20=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93=20for=20Chinese=20Agent=20copy=20(#25767)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplified Chinese rendered the Agent concept as 代理, which collides with 代理 = proxy. Standardize on 智能体 for Agent (and 子智能体 for subagent), while keeping 代理 for proxy senses: HTTP/network proxy, SSH Proxy Command, reverse proxy, and browser user agent. - Converted 131 zh catalog values (incl. 子代理 -> 子智能体); 26 proxy / user-agent values left as 代理. - locale-phrase-fixes.mjs: 客服人员/代理商/座席 -> 智能体, 代理 -> 智能体 when the English names an agent (guard excludes "user agent"); removed the old 智能体 -> 代理 rule so the pipeline no longer reverts it. - Updated value/key/search/macos-tcc overrides to 智能体; proxy keyword and proxy override entries unchanged. - Updated the two policy tests that pinned the old 代理 output. Gates: catalog verify, coverage --check, extraction, runtime-catalog, and the locale vitest suites (296 tests) pass. --- config/scripts/locale-key-overrides.mjs | 8 +- .../locale-macos-tcc-key-overrides.mjs | 4 +- config/scripts/locale-phrase-fixes.mjs | 15 +- .../locale-search-keyword-overrides.mjs | 4 +- .../locale-translation-policy.test.mjs | 4 +- ...cale-translation-policy.zh-round5.test.mjs | 4 +- config/scripts/locale-value-overrides.mjs | 26 +- config/scripts/locale-zh-value-overrides.mjs | 8 +- config/update-translation.txt | 14 + src/renderer/src/i18n/locales/zh.json | 262 +++++++++--------- 10 files changed, 184 insertions(+), 165 deletions(-) diff --git a/config/scripts/locale-key-overrides.mjs b/config/scripts/locale-key-overrides.mjs index 749ed846343..175961b88cf 100644 --- a/config/scripts/locale-key-overrides.mjs +++ b/config/scripts/locale-key-overrides.mjs @@ -173,7 +173,7 @@ const BASE_LOCALE_KEY_OVERRIDES = { }, 'auto.components.tab.bar.TabBarCreateEntry.b27864279e': { ko: '에이전트 실행', - zh: '启动代理', + zh: '启动智能体', ja: 'Agent を起動' }, 'auto.components.sidebar.SidebarNav.c39ab10000': { @@ -432,12 +432,12 @@ const BASE_LOCALE_KEY_OVERRIDES = { }, 'auto.components.settings.AgentsPane.9bccf48906': { ko: '에이전트 위치', - zh: '代理位置', + zh: '智能体位置', ja: 'Agent の場所' }, 'auto.components.sidebar.SidebarNav.e518f544b1': { ko: '감지된 에이전트 없음', - zh: '未检测到代理', + zh: '未检测到智能体', ja: 'Agent が検出されません' }, 'auto.components.onboarding.OnboardingFlow.04ae28d8ca': { @@ -472,7 +472,7 @@ const BASE_LOCALE_KEY_OVERRIDES = { }, 'auto.components.settings.ExperimentalPane.0277901cf7': { ko: '완료된 에이전트, 차단 질문, 읽지 않은 상태 및 작업 트리 생성 이벤트에 대한 스레드 작업 트리 피드가 있는 에이전트 항목을 왼쪽 사이드바에 추가합니다. 실험적 — 이벤트 모델과 UI가 변경될 수 있습니다.', - zh: '将代理条目添加到左侧边栏,其中包含已完成代理、阻塞待办、未读状态和工作树创建事件的线程工作树提要。实验性——事件模型和 UI 可能会改变。', + zh: '将智能体条目添加到左侧边栏,其中包含已完成智能体、阻塞待办、未读状态和工作树创建事件的线程工作树提要。实验性——事件模型和 UI 可能会改变。', ja: '完了した Agent、ブロック中の質問、未読状態、ワークツリー作成イベントのスレッドワークツリーフィード付き Agent 項目を左サイドバーに追加します。実験的 — イベントモデルと UI は変更される場合があります。' }, 'auto.lib.fix.checks.agent.launch.9f00d7df0c': { diff --git a/config/scripts/locale-macos-tcc-key-overrides.mjs b/config/scripts/locale-macos-tcc-key-overrides.mjs index 81b3d39db93..d094fb6c249 100644 --- a/config/scripts/locale-macos-tcc-key-overrides.mjs +++ b/config/scripts/locale-macos-tcc-key-overrides.mjs @@ -9,13 +9,13 @@ export const MACOS_TCC_KEY_OVERRIDES = { es: 'Los mensajes de permisos de macOS pueden aparecer cuando un agente o una herramienta de terminal que se ejecuta en Orca intenta acceder a archivos protegidos. Concede acceso total al disco en Ajustes para reducir estos avisos.', ja: 'Orca で実行中のエージェントやターミナルツールが保護されたファイルにアクセスしようとすると、macOS の権限メッセージが表示されることがあります。これらの確認を減らすには、設定でフルディスクアクセスを許可してください。', ko: 'Orca에서 실행 중인 에이전트나 터미널 도구가 보호된 파일에 접근하려고 하면 macOS 권한 메시지가 표시될 수 있습니다. 이러한 요청을 줄이려면 설정에서 전체 디스크 접근 권한을 허용하세요.', - zh: '当 Orca 中运行的代理或终端工具尝试访问受保护的文件时,macOS 可能会显示权限信息。请在“设置”中授予“完全磁盘访问权限”,以减少此类提示。' + zh: '当 Orca 中运行的智能体或终端工具尝试访问受保护的文件时,macOS 可能会显示权限信息。请在“设置”中授予“完全磁盘访问权限”,以减少此类提示。' }, 'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8': { es: 'Cuando los agentes que ejecuta Orca leen datos de otras apps, macOS muestra el nombre de Orca porque es el proceso responsable de los comandos de terminal. Concede este permiso a Orca para reducir esos avisos. Después, cierra y vuelve a abrir Orca.', ja: 'Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca にこの権限を許可してください。その後、Orca を終了して再度開いてください。', ko: 'Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca에 이 권한을 허용하세요. 그런 다음 Orca를 종료했다가 다시 여세요.', - zh: '当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 授予此权限,以减少此类提示。然后退出并重新打开 Orca。' + zh: '当 Orca 运行的智能体读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 授予此权限,以减少此类提示。然后退出并重新打开 Orca。' }, 'auto.components.settings.DeveloperPermissionsPane.c566bca278': { ko: '전체 디스크 접근 권한', diff --git a/config/scripts/locale-phrase-fixes.mjs b/config/scripts/locale-phrase-fixes.mjs index b3c8026cea5..064116cc8fa 100644 --- a/config/scripts/locale-phrase-fixes.mjs +++ b/config/scripts/locale-phrase-fixes.mjs @@ -131,15 +131,20 @@ export const LOCALE_PHRASE_FIXES = { ...KO_PHRASE_FIXES_ROUND4 ], zh: [ - { pattern: /客服人员/g, replacement: '代理', whenEnIncludes: 'agent' }, + { pattern: /客服人员/g, replacement: '智能体', whenEnIncludes: 'agent' }, { pattern: /会议/g, replacement: '会话', whenEnIncludes: 'session' }, { pattern: /港口/g, replacement: '端口', whenEnIncludes: 'ort' }, { pattern: /公关/g, replacement: 'PR', whenEnIncludes: 'PR' }, { pattern: /虎鲸:\/\//g, replacement: 'orca://', whenEnIncludes: 'orca://' }, - { pattern: /代理商/g, replacement: '代理', whenEnIncludes: 'agent' }, - { pattern: /智能体/g, replacement: '代理', whenEnIncludes: 'agent' }, + { pattern: /代理商/g, replacement: '智能体', whenEnIncludes: 'agent' }, + { + pattern: /代理/g, + replacement: '智能体', + // Why: Orca names the Agent concept 智能体; 代理 stays for proxy and user-agent copy. + whenEnMatches: /(? { localeValue: '未已检测代理', locale: 'zh' }) - ).toBe('未检测到代理') + ).toBe('未检测到智能体') expect( repairTranslatedValue({ key: 'auto.components.skills.SkillsPage.38e0951c3a', @@ -275,7 +275,7 @@ describe('locale-translation-policy', () => { localeValue: '代理技巧', locale: 'zh' }) - ).toBe('代理技能') + ).toBe('智能体技能') expect( repairTranslatedValue({ key: 'auto.components.settings.appearance.search.9ae151b26b', diff --git a/config/scripts/locale-translation-policy.zh-round5.test.mjs b/config/scripts/locale-translation-policy.zh-round5.test.mjs index 622b3c5c31c..2ab2d69b6e0 100644 --- a/config/scripts/locale-translation-policy.zh-round5.test.mjs +++ b/config/scripts/locale-translation-policy.zh-round5.test.mjs @@ -140,7 +140,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '代理', locale: 'zh' }) - ).toBe('代理') + ).toBe('智能体') expect( repairTranslatedValue({ key: 'auto.components.GitHubItemDialog.28986b3747', @@ -148,7 +148,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '已启动 AI 代理处理失败的检查。', locale: 'zh' }) - ).toBe('已启动 AI 代理处理失败的检查。') + ).toBe('已启动 AI 智能体处理失败的检查。') expect( repairTranslatedValue({ key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef', diff --git a/config/scripts/locale-value-overrides.mjs b/config/scripts/locale-value-overrides.mjs index 0887cf2e434..2e8547f057c 100644 --- a/config/scripts/locale-value-overrides.mjs +++ b/config/scripts/locale-value-overrides.mjs @@ -186,8 +186,8 @@ export const LOCALE_VALUE_OVERRIDES = { Back: '返回', Reopen: '重新打开', Closed: '已关闭', - Agents: '代理', - agents: '代理', + Agents: '智能体', + agents: '智能体', orchestration: '编排', conflict: '冲突', Disconnect: '断开连接', @@ -205,7 +205,7 @@ export const LOCALE_VALUE_OVERRIDES = { Optional: '可选', Ports: '端口', Active: '当前', - 'Dismiss agent': '关闭代理', + 'Dismiss agent': '关闭智能体', 'Codex Usage': 'Codex 使用情况', 'Claude Usage': 'Claude 使用情况', 'Gemini Usage': 'Gemini 使用情况', @@ -216,7 +216,7 @@ export const LOCALE_VALUE_OVERRIDES = { 'Grok Usage': 'Grok 使用情况', 'Grok (xAI) Usage': 'Grok (xAI) 使用情况', 'Force Delete Branch': '强制删除分支', - 'Time agents worked': '代理工作时间', + 'Time agents worked': '智能体工作时间', PR: 'PR', linear: 'Linear', jira: 'Jira', @@ -285,7 +285,7 @@ export const LOCALE_VALUE_OVERRIDES = { 'Open workspace': '打开工作区', 'Search Linear issues...': '搜索 Linear 议题...', 'Search Linear projects...': '搜索 Linear 项目...', - 'Launch agent': '启动代理', + 'Launch agent': '启动智能体', 'Git AI Author': 'Git AI Author', 'Copy reference ID': '复制参考 ID', 'Try Again': '重试', @@ -323,29 +323,29 @@ export const LOCALE_VALUE_OVERRIDES = { // so a single value-wide mapping is wrong for half the call sites. Per-key catalog values decide. 'Join Discord': '加入 Discord', 'Pull request merged': '拉取请求已合并', - 'Agent Skills': '代理技能', - 'Agent skills': '代理技能', - 'Agent skill': '代理技能', + 'Agent Skills': '智能体技能', + 'Agent skills': '智能体技能', + 'Agent skill': '智能体技能', 'Browser Use skill': '浏览器使用技能', 'Install Browser Use Skill': '安装浏览器使用技能', 'Scanning skills': '正在扫描技能', - 'Agent location': '代理位置', + 'Agent location': '智能体位置', Location: '位置', 'Account Location': '账户位置', 'Run location': '运行位置', 'more locations': '更多位置', - 'No agents detected': '未检测到代理', + 'No agents detected': '未检测到智能体', 'No external ports detected': '未检测到外部端口', 'No workspace ports detected': '未检测到工作区端口', 'No local ports detected': '未检测到本地端口', 'No ports detected': '未检测到端口', 'No speech detected.': '未检测到语音。', 'No enabled AI agent was detected on this workspace host.': - '此工作区主机上未检测到已启用的 AI 代理。', + '此工作区主机上未检测到已启用的 AI 智能体。', 'The selected agent was not detected on this workspace host.': - '此工作区主机上未检测到所选代理。', + '此工作区主机上未检测到所选智能体。', 'No agents detected on your PATH. Pick one to install later, or continue with a blank terminal.': - 'PATH 中未检测到代理。可选择一个稍后安装,或使用空白终端继续。', + 'PATH 中未检测到智能体。可选择一个稍后安装,或使用空白终端继续。', 'Jira issue': 'Jira 议题', 'New Jira issue': '新建 Jira 议题', 'Open in Jira': '在 Jira 中打开', diff --git a/config/scripts/locale-zh-value-overrides.mjs b/config/scripts/locale-zh-value-overrides.mjs index 5055ba00341..6c78399a73f 100644 --- a/config/scripts/locale-zh-value-overrides.mjs +++ b/config/scripts/locale-zh-value-overrides.mjs @@ -125,7 +125,7 @@ export const ZH_VALUE_OVERRIDES = { 'Use review template when available': '可用时使用评审模板', 'Create hosted reviews as drafts unless changed in the composer.': '除非在编辑器中更改,否则将托管评审创建为草稿。', - 'Start an agent from failed hosted-review checks.': '从失败的托管评审检查中启动代理。', + 'Start an agent from failed hosted-review checks.': '从失败的托管评审检查中启动智能体。', 'Checks require a Git branch and hosted review context': '检查需要 Git 分支和托管评审上下文', 'Resolve Review Conflicts With AI': '使用 AI 解决评审冲突', 'Hosted review operation in progress…': '托管评审操作进行中…', @@ -168,7 +168,7 @@ export const ZH_VALUE_OVERRIDES = { 'Show local markdown review note controls in rich editor mode.': '在富文本编辑器模式下显示本地 Markdown 评审笔记控件。', 'Start an agent for local or hosted-review merge conflicts.': - '启动用于解决本地或托管评审合并冲突的代理。', + '启动用于解决本地或托管评审合并冲突的智能体。', 'changed since you last approved. Re-review before it runs': '自您上次批准以来已发生变化。运行前请重新评审', 'Run the weekly dependency audit and summarize risky changes.': @@ -185,7 +185,7 @@ export const ZH_VALUE_OVERRIDES = { '留空以使用系统代理设置和继承的代理环境变量。', 'Proxy Command': '代理命令', "Give agents direct access to Orca's browser so they can test pages, capture screenshots, and act on what they see.": - '让代理直接访问 Orca 的浏览器,以便测试页面、捕获屏幕截图并根据所见内容执行操作。', + '让智能体直接访问 Orca 的浏览器,以便测试页面、捕获屏幕截图并根据所见内容执行操作。', 'X finishes, send it the review task.”': 'X 完成后,把评审任务发给它。”', 'Branch naming, base refs, and Git AI Author.': '分支命名、基础引用和 Git AI Author。', 'You have unsaved Git AI Author changes. Leaving will discard them.': @@ -216,7 +216,7 @@ export const ZH_VALUE_OVERRIDES = { 'Optional account switching for Claude while preserving shared chat context.': 'Claude 的可选账户切换,同时保留共享聊天上下文。', 'Countdown timer showing time until prompt cache expires (Claude agents).': - '显示提示词缓存到期倒计时的计时器(Claude 代理)。', + '显示提示词缓存到期倒计时的计时器(Claude 智能体)。', 'Claude caches your conversation to reduce costs. When idle too long the cache expires and the next message resends full context at higher cost. This shows a countdown so you know when to resume.': 'Claude 会缓存对话以降低成本。空闲过久后缓存会过期,下一条消息将以更高成本重新发送完整上下文。此倒计时可帮助您了解何时继续。', 'from Orca. It is still on your disk.': '来自 Orca。它仍保留在您的磁盘上。', diff --git a/config/update-translation.txt b/config/update-translation.txt index 566189544c4..11097556bf0 100644 --- a/config/update-translation.txt +++ b/config/update-translation.txt @@ -107,3 +107,17 @@ Method (reproducible): R3 sign-off clean on all five locales (0 errors, 0 warns; placeholders byte-identical, 0 stale changed keys, 0 generic-term regressions). +## Terminology change 2026-10-06 — zh Agent is 智能体, not 代理 + +- Standardized Simplified Chinese on 智能体 for the Agent concept. Converted 131 zh values + (incl. 子代理 -> 子智能体) and updated the pipeline so it is not reverted: + `locale-phrase-fixes.mjs` now maps 客服人员/代理商/座席 -> 智能体 and replaces 代理 -> 智能体 + when the English names an agent (guarded to exclude "user agent"); the old + 智能体 -> 代理 rule was removed. +- 代理 stays for proxy senses: HTTP/network proxy, SSH Proxy Command, reverse proxy, and + browser "user agent" (26 zh values left untouched). Overrides updated to match + (locale-value-overrides, locale-key-overrides, locale-macos-tcc-key-overrides, + locale-zh-value-overrides, locale-search-keyword-overrides agent/agents -> 智能体, proxy -> 代理). +- Gates: catalog verify, coverage --check, extraction, runtime-catalog pass; 296 locale + tests pass (policy, zh-round5, generic-terms, etc.). + diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index fbcaa33d409..97e98cbf58d 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -102,11 +102,11 @@ "agents": { "codexTerminalServerIsolation": { "title": "在自己的服务器上运行每个 Codex 终端", - "description": "保持 Orca 的状态和关闭选项卡正常工作。关闭以使用 Codex 的共享服务器及其代理概述。适用于新终端。" + "description": "保持 Orca 的状态和关闭选项卡正常工作。关闭以使用 Codex 的共享服务器及其智能体概述。适用于新终端。" }, "codexSharedServerWarning": { "title": "当 Codex 选项卡共享服务器时发出警告", - "description": "当您自己启动的 Codex 与其他选项卡共享服务器时,在您自己启动的 Codex 上显示一条通知,因为它的代理状态可能是错误的。" + "description": "当您自己启动的 Codex 与其他选项卡共享服务器时,在您自己启动的 Codex 上显示一条通知,因为它的智能体状态可能是错误的。" } } }, @@ -652,11 +652,11 @@ "bddc4c09b8": "运行分阶段工作流程", "ab0e9803b7": "移交给另一个工作树", "5e0d489fe1": "移交活动任务", - "handoffSummary": "将所有权移交给另一个有足够上下文的代理以继续。", - "worktreeHandoffSummary": "将工作移至已在不同分支中运行的代理。", - "childSequenceSummary": "当每个阶段依赖于最后一个阶段时,依次使用子代理。", - "childParallelSummary": "在子代理之间拆分非重叠的调查或实施任务。", - "prSplitSummary": "为每个子代理提供自己的工作树,以便并行实施保持可审查。" + "handoffSummary": "将所有权移交给另一个有足够上下文的智能体以继续。", + "worktreeHandoffSummary": "将工作移至已在不同分支中运行的智能体。", + "childSequenceSummary": "当每个阶段依赖于最后一个阶段时,依次使用子智能体。", + "childParallelSummary": "在子智能体之间拆分非重叠的调查或实施任务。", + "prSplitSummary": "为每个子智能体提供自己的工作树,以便并行实施保持可审查。" } } }, @@ -900,7 +900,7 @@ } }, "activateAiVaultStructuredSession": { - "unavailable": "结构化代理会话尚不可用。稍后重试。", + "unavailable": "结构化智能体会话尚不可用。稍后重试。", "gone": "此聊天不再在此主机上,因此无法在此处重新打开。", "hostCannotOpen": "在 Orca 更新之前,无法重新打开此聊天。" }, @@ -931,8 +931,8 @@ "storageNotPrivate": "Orca 用于拖放文件的存储位置可被其他用户读取,因此未复制任何内容。", "timedOut": "复制耗时过长。请重新拖放。", "busy": "仍有太多拖放正在复制。请稍候再试。", - "tooLarge": "文件太大,无法复制,因此 Orca 无法将其交给代理。", - "storageFull": "Orca 用于拖放文件的存储空间已满,因此 Orca 无法将其交给代理。", + "tooLarge": "文件太大,无法复制,因此 Orca 无法将其交给智能体。", + "storageFull": "Orca 用于拖放文件的存储空间已满,因此 Orca 无法将其交给智能体。", "generic": "请重新拖放。" }, "reveal": { @@ -1084,7 +1084,7 @@ "artifactsTitle": "工件", "artifactsDescription": "与您的团队共享 HTML 和 Markdown 文件并管理他们的公共链接。", "automationsTitle": "自动化", - "automationsDescription": "安排代理工作并选择自动化是否显示在侧栏中。", + "automationsDescription": "安排智能体工作并选择自动化是否显示在侧栏中。", "shareSkillsTitle": "分享技能", "shareSkillsDescription": "通过未列出的链接分享您的技能。任何拥有它的人都可以安装它们。", "floatingWorkspaceWebDescription": "全局终端和 Markdown 选项卡。" @@ -1097,7 +1097,7 @@ }, "useMacosTccPromptNotice": { "title": "看到“Orca 想要访问…”提示?", - "description": "当 Orca 中运行的代理或终端工具尝试访问受保护的文件时,macOS 可能会显示权限信息。请在“设置”中授予“完全磁盘访问权限”,以减少此类提示。", + "description": "当 Orca 中运行的智能体或终端工具尝试访问受保护的文件时,macOS 可能会显示权限信息。请在“设置”中授予“完全磁盘访问权限”,以减少此类提示。", "openSettings": "打开设置", "dismiss": "不再显示" }, @@ -3516,7 +3516,7 @@ "splitTerminal": "拆分终端" }, "TerminalTabLeadingIcon": { - "7ab2964bea": "未读的代理完成" + "7ab2964bea": "未读的智能体完成" }, "TabBarQuickCommandAddActions": { "45a2f36d51": "命令", @@ -4418,7 +4418,7 @@ "aa59462502": "存储库", "571c5818c1": "主目录", "0bc1379f4c": "所有来源", - "38e0951c3a": "代理技能", + "38e0951c3a": "智能体技能", "fb6bf60b52": "Claude", "426be2aac6": "Codex", "39b6998ddb": "所有提供商", @@ -4903,14 +4903,14 @@ "bundleSkillsMissing": "此版本不包含任何已安装的内置技能。", "reconnectBeforeVersionChange": "在更改版本之前重新连接您的 Orca 帐户。", "removeFailed": "Orca 无法安全地移除该技能。", - "agentsLabel": "代理", + "agentsLabel": "智能体", "agentsCanonical": "始终为 {{value0}} 安装,读取为 {{value1}}。", - "agentsNoneChosen": "无需额外代理", + "agentsNoneChosen": "无需额外智能体", "agentsSummary": "另:{{value0}}", "agentNotInstalled": "未安装", "bundleDestinationSummary": "{{value0}} 新的 · {{value1}} 已安装 · {{value2}} 需要决定", "trustNote": "技能是来自其作者的指令和代码。安装您信任的内容。", - "agentsNone": "没有选择代理", + "agentsNone": "没有选择智能体", "agentsSelected": "安装:{{value0}}", "agentAlways": "总是", "agentsCanonicalNote": "{{value0}} 读取 {{value1}},每个安装都会写入。", @@ -4926,17 +4926,17 @@ "reviewSupportingFiles": "包括支持文件", "reviewInstructions": "关于这个技能", "supportingFileWarning": "所选技能包括 SKILL.md 之外的 {{fileCount}}。", - "agentAccessWarning": "技能包含您的代理可能遵循的说明。仅当您信任此共享链接的来源时才继续。", + "agentAccessWarning": "技能包含您的智能体可能遵循的说明。仅当您信任此共享链接的来源时才继续。", "fileBinary": "二进制", "fileRunnable": "可运行", "agentsCountBadge": "已选择 {{count}}", "agentsCountLabel": "{{count}} {{label}}", - "targetAgentsHeader": "目标代理", + "targetAgentsHeader": "目标智能体", "deselectAll": "取消选择可选", "selectAll": "选择全部", - "canonicalHeader": "标准代理(始终包含)", - "canonicalExplanation": "这些代理本机读取 {{root}},Orca 默认安装到该位置:", - "additionalAgentsHeader": "其他代理目录" + "canonicalHeader": "标准智能体(始终包含)", + "canonicalExplanation": "这些智能体本机读取 {{root}},Orca 默认安装到该位置:", + "additionalAgentsHeader": "其他智能体目录" }, "SkillSharedLinkRow": { "contentsUnavailable": "无法加载此链接包含的内容。", @@ -4983,7 +4983,7 @@ "bundleDescription": "涵盖每个警告级别的预览包。", "instructionsOnlyDescription": "说明完全包含在 SKILL.md 中。", "supportingFilesDescription": "包括主要说明之外的可读参考文件。", - "runnableFilesDescription": "包括代理可以使用您的访问权限运行的脚本。", + "runnableFilesDescription": "包括智能体可以使用您的访问权限运行的脚本。", "binaryFilesDescription": "包括不透明资产和可执行二进制文件。" } }, @@ -5827,8 +5827,8 @@ "lockedReason": "此工作区被 Git 锁定。Git 报告:{{value0}}。请在其仓库中运行 git worktree unlock ,然后重试删除。", "unstoppedPty": "Orca 无法确认此工作区中的每个终端都已退出,因此它在删除任何文件之前停止。无论如何,请使用强制删除将其删除。", "unstoppedPtyLive": "该工作区仍然有正在运行的终端,因此 Orca 在删除任何文件之前停止了。强制删除将杀死它们并丢弃它们持有的任何未提交的工作。", - "runningAgentSession": "Orca 无法确认此工作区中的每个代理会话都已关闭,因此它在删除任何文件之前停止。无论如何,请使用强制删除将其删除。", - "runningAgentSessionLive": "此工作区仍然有 Orca 无法关闭的正在运行的代理会话,因此它在删除任何文件之前停止了。强制删除将丢弃他们持有的任何工作。" + "runningAgentSession": "Orca 无法确认此工作区中的每个智能体会话都已关闭,因此它在删除任何文件之前停止。无论如何,请使用强制删除将其删除。", + "runningAgentSessionLive": "此工作区仍然有 Orca 无法关闭的正在运行的智能体会话,因此它在删除任何文件之前停止了。强制删除将丢弃他们持有的任何工作。" }, "failure": { "archive": { @@ -6384,14 +6384,14 @@ "d9657ac204": "终端会话关闭请求已发送。", "e8f25bd903": "无法完成终端清理。", "a702d4196e": "这将关闭所有工作区中的每个终端标签页,并请求关闭其当前终端会话。任何未保存的终端工作将丢失。守护进程本身将继续运行,可以立即打开新终端。此操作不可撤销。", - "01d6b7c64e": "打开终端,代理将重新启动。远程主机上的终端不受影响。", + "01d6b7c64e": "打开终端,智能体将重新启动。远程主机上的终端不受影响。", "922548bc66": "重启终端服务?" }, "MacFolderAccessFixDialog": { "stepAllow": "在文件和文件夹下允许 Orca", "openSystemSettings": "打开系统设置", "stepRestart": "重启 Orca 的终端服务", - "restartConsequence": "打开终端,代理将重新启动。", + "restartConsequence": "打开终端,智能体将重新启动。", "restarting": "正在重新启动...", "restart": "重新启动", "restartFailed": "重启失败。从设置 → 终端 → 管理会话重试。", @@ -6688,7 +6688,7 @@ "remoteDetectionFailed": "无法检测已安装的智能体。请检查主机连接后重试。", "retryDetection": "Retry", "storedDefaultUndetected": "已保存为默认值,但目前未检测到", - "noAgentsDetected": "未检测到代理。如果安装了探测器,探测器可能会超时。" + "noAgentsDetected": "未检测到智能体。如果安装了探测器,探测器可能会超时。" }, "AppIconSelector": { "d5a112dc9b": "下一个图标", @@ -7045,7 +7045,7 @@ "e7bb06007c": "局域网", "4a73f5217a": "用于控制其他本地应用程序的脚本的 Apple 事件。", "e119f0d66b": "自动化", - "7ca17b62c8": "当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 授予此权限,以减少此类提示。然后退出并重新打开 Orca。", + "7ca17b62c8": "当 Orca 运行的智能体读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 授予此权限,以减少此类提示。然后退出并重新打开 Orca。", "c566bca278": "完全磁盘访问权限", "9f35980756": "击键注入、窗口控制和 UI 自动化工具。", "5b2f22ca2d": "无障碍", @@ -7137,7 +7137,7 @@ "shellEnvCopy": "Codex 和 Claude 聊天启动时会带上登录 shell 导出的所有变量,与终端一致。关闭后可选择传递哪些变量。", "shellEnvToggleLabel": "切换使用 shell 环境", "structuredTitle": "使用更新的结构化本机聊天", - "structuredCopy": "将新的 Codex 和 Claude 代理作为结构化聊天打开。关闭时会在基于终端的聊天中打开。已有的聊天保持不变。", + "structuredCopy": "将新的 Codex 和 Claude 智能体作为结构化聊天打开。关闭时会在基于终端的聊天中打开。已有的聊天保持不变。", "structuredScope": "在本机和运行支持该功能版本的已配对 Orca 服务器上运行;较旧的服务器保留终端聊天。WSL 和 SSH 主机继续使用终端聊天,Windows 会回退到它,除非 Orca 可以读取进程启动时间。", "structuredToggleLabel": "切换更新的结构化本机聊天", "resumeTitle": "重启后自动恢复工作聊天", @@ -7182,7 +7182,7 @@ "80c454e8a6": "将此与您的提供商的缓存 TTL 相匹配。" }, "GeneralEditorSettingsSection": { - "f80603d293": "在所有 Markdown 视图中显示 Markdown 笔记控件,并在代理交接操作中包含这些笔记。", + "f80603d293": "在所有 Markdown 视图中显示 Markdown 笔记控件,并在智能体交接操作中包含这些笔记。", "4edc104f0f": "Markdown 评审笔记", "5f02e6fb21": "在 Markdown 文件中显示本地 Markdown 评审笔记控件。", "51161d1647": "编辑文件时显示小地图概述。", @@ -7240,7 +7240,7 @@ "editor_preview_tabs": "浏览文件时重复使用预览选项卡", "editor_preview_tabs_description": "单击资源管理器中的文件,或点击 Markdown 源中的链接,可以重复使用每组一个斜体预览选项卡,而不是打开一个新选项卡。编辑、双击或固定可以使该选项卡保持打开状态。关闭此选项可为每个文件提供自己的选项卡。", "confirm_running_terminal_close": "关闭运行终端前确认", - "confirm_running_terminal_close_description": "关闭终端时,在停止正在运行的代理或命令之前询问。" + "confirm_running_terminal_close_description": "关闭终端时,在停止正在运行的智能体或命令之前询问。" }, "GeneralSupportSection": { "af7d9f4396": "感谢您的支持!", @@ -7701,7 +7701,7 @@ "2aacdb0517": "跨切换、工作树切换和子智能体工作协调编码智能体。", "191ac34567": "智能体编排", "nestedWorkerDepthTitle": "嵌套工人深度", - "nestedWorkerDepthDescription": "多少代的派遣工可能会产生自己的工人。 1 保持代理树平坦:协调者调度工人,而那些工人不调度。" + "nestedWorkerDepthDescription": "多少代的派遣工可能会产生自己的工人。 1 保持智能体树平坦:协调者调度工人,而那些工人不调度。" }, "OrchestrationSetupCard": { "e7d2a5146c": "使智能体能够通过 Orca 传递上下文并协调工作。", @@ -7711,8 +7711,8 @@ "6dec5ce2d2": "智能体覆盖范围", "ffe13e36fb": "缺失", "1e8f8d8fae": "就绪", - "checking": "检查已安装的代理和技能路径...", - "noAgents": "在 PATH 上未检测到代理 CLI。在“设置”→“代理”中安装代理,然后重新检查。", + "checking": "检查已安装的智能体和技能路径...", + "noAgents": "在 PATH 上未检测到智能体 CLI。在“设置”→“智能体”中安装智能体,然后重新检查。", "fullCoverage_one": "所有 1 名已检测的智能体都拥有该技能。", "fullCoverage_other": "所有 {{value0}} 已检测的智能体都拥有该技能。", "noCoverage": "安装上面的技能,然后重新检查。", @@ -9078,8 +9078,8 @@ "agentRuntime": "智能体运行时", "agentRuntimeDescription": "选择默认在 Windows 还是 WSL 中检测并启动智能体。", "runtime": "运行时", - "agentLocation": "代理位置", - "installedAgentsWsl": "在 wsl 中安装代理", + "agentLocation": "智能体位置", + "installedAgentsWsl": "在 wsl 中安装智能体", "permission": "允许", "permissions": "权限", "yolo": "约洛", @@ -9090,7 +9090,7 @@ "ff457e1e7b": "守护进程", "9f3becc4e8": "共享", "34337ed5c7": "隔离", - "9a84e65118": "代理概览" + "9a84e65118": "智能体概览" } }, "appearance": { @@ -9597,14 +9597,14 @@ "openclaude": "开放克劳德", "omp": "奥普", "terminal": "终端", - "agent": "代理", + "agent": "智能体", "queue": "队列" }, "agentDashboard": { "title": "智能体仪表盘", "description": "用于监控跨工作树的智能体的看板,支持窗口内或弹出窗口显示。", "dashboard": "仪表盘", - "agent": "代理", + "agent": "智能体", "kanban": "看板", "popout": "弹出", "board": "木板", @@ -9813,7 +9813,7 @@ "afa37a34e1": "关闭", "running_terminal": "跑步", "terminal": "终端", - "agent": "代理", + "agent": "智能体", "command": "命令", "confirm": "确认", "close": "关闭", @@ -10344,7 +10344,7 @@ "copy": "复制", "clone": "克隆", "apfs": "apfs", - "waitForSetupBeforeAgent": "启动代理之前等待设置", + "waitForSetupBeforeAgent": "启动智能体之前等待设置", "external": "外部的", "visibility": "能见度", "sidebar": "侧边栏", @@ -10360,7 +10360,7 @@ "windowsHost": "视窗主机", "wsl": "wsl", "distro": "发行版", - "agentRuntime": "代理运行时", + "agentRuntime": "智能体运行时", "skillRuntime": "技能运行时间" } }, @@ -10681,7 +10681,7 @@ "close_terminal": "关闭", "running": "跑步", "command": "命令", - "agent": "代理", + "agent": "智能体", "process": "过程", "prompt": "提示词", "stop": "停止", @@ -10809,9 +10809,9 @@ "95d3031db2": "在智能体工作时保持此电脑和显示器唤醒。合盖行为遵循此设备的电源设置。", "a42f6fbdd8": "在智能体工作时保持此电脑和显示器唤醒。Orca 还会根据电源策略,在合盖时请求此设备保持唤醒。", "modeTitle": "防止电脑休眠", - "modeDescriptionWindows": "选择“打开”、“代理”或“关闭”。代理工作时代理模式保持唤醒状态;盖子关闭行为遵循该设备的电源设置。", - "modeDescriptionMac": "选择“打开”、“代理”或“关闭”。代理模式会在代理工作时防止空闲休眠,因此开盖即可完成长时间任务。合上盖子仍会使此 Mac 进入睡眠状态。", - "modeDescriptionDefault": "选择“打开”、“代理”或“关闭”。当代理工作时,代理模式保持唤醒状态。 Orca 还要求该设备在盖子关闭时保持唤醒状态,具体取决于其电源策略。", + "modeDescriptionWindows": "选择“打开”、“智能体”或“关闭”。智能体工作时智能体模式保持唤醒状态;盖子关闭行为遵循该设备的电源设置。", + "modeDescriptionMac": "选择“打开”、“智能体”或“关闭”。智能体模式会在智能体工作时防止空闲休眠,因此开盖即可完成长时间任务。合上盖子仍会使此 Mac 进入睡眠状态。", + "modeDescriptionDefault": "选择“打开”、“智能体”或“关闭”。当智能体工作时,智能体模式保持唤醒状态。 Orca 还要求该设备在盖子关闭时保持唤醒状态,具体取决于其电源策略。", "lidNoteMac": "开盖时防止空闲休眠;合上盖子仍会使此 Mac 进入睡眠状态。", "lidNoteWindows": "合盖行为遵循该设备的电源设置。", "lidNoteDefault": "Orca 还要求该设备在盖子关闭时保持唤醒状态,具体取决于其电源策略。" @@ -11963,7 +11963,7 @@ "howToTitle": "如何使用神器", "howToDescription": "将 HTML 或 Markdown 文件发布为公共链接,然后与您的团队共享。", "shareStepTitle": "选择要共享的文件", - "shareStepDescription": "打开 HTML 或 Markdown 文件并选择共享为工件,或要求代理共享它。", + "shareStepDescription": "打开 HTML 或 Markdown 文件并选择共享为工件,或要求智能体共享它。", "linkStepTitle": "复制公共链接", "linkStepDescription": "发布后,复制链接并将其发送给您的团队。", "manageStepTitle": "在 Orca 中管理它", @@ -12025,17 +12025,17 @@ "showButton": "显示自动化按钮", "showButtonDescription": "在侧边栏中显示自动化快捷方式。", "howItWorksTitle": "自动化如何工作", - "howItWorksDescription": "安排代理工作一次,然后让 Orca 创建每次运行并将其结果保存在一起。", + "howItWorksDescription": "安排智能体工作一次,然后让 Orca 创建每次运行并将其结果保存在一起。", "defineStepTitle": "描述工作", - "defineStepDescription": "选择项目、代理、提示和时间表。", + "defineStepDescription": "选择项目、智能体、提示和时间表。", "runStepTitle": "Orca 开始每次跑步", - "runStepDescription": "当时间表到期时,选定的代理将获得一个新的工作区。", + "runStepDescription": "当时间表到期时,选定的智能体将获得一个新的工作区。", "reviewStepTitle": "查看结果", "reviewStepDescription": "检查最近的运行并在需要时继续工作。", "openAutomations": "开放式自动化", "openAutomationsDescription": "创建时间表并检查最近的运行。", "title": "自动化", - "description": "安排代理工作并选择自动化是否显示在侧栏中。", + "description": "安排智能体工作并选择自动化是否显示在侧栏中。", "keywordAutomations": "自动化", "keywordSchedule": "日程", "keywordAgent": "智能体", @@ -12044,7 +12044,7 @@ "shareSkills": { "title": "分享技能", "description": "通过未列出的链接分享您的技能。任何拥有它的人都可以安装它们。", - "allowAgentPublishing": "允许代理和 Orca CLI 发布技能链接", + "allowAgentPublishing": "允许智能体和 Orca CLI 发布技能链接", "allowAgentPublishingDescription": "让命令发布明确命名的已安装技能。技能文件夹可以包含脚本、配置或机密,因此默认情况下此功能处于关闭状态。", "allowAgentPublishingWebDescription": "仅限桌面。在主机设备上打开“设置”→“共享技能”以更改此设置。", "selectTitle": "选择一项或多项技能", @@ -12145,7 +12145,7 @@ "confirm": "停止清理" }, "NativeChatSupportedAgents": { - "label": "支持代理:" + "label": "支持智能体:" }, "contrast": { "title": "色彩对比", @@ -12710,7 +12710,7 @@ "c06193ef57": "无法复制{{value0}}", "d172a4f068": "提交哈希", "e283b50179": "提交信息", - "f394c6128a": "没有可用于解释此提交的代理", + "f394c6128a": "没有可用于解释此提交的智能体", "04a5d7239b": "此仓库没有受支持的网页远程库", "15b6e834ac": "无法在浏览器中打开提交", "d37e68f61d": "正在准备分支以供评审…", @@ -13347,7 +13347,7 @@ "4cbd0cd9d2": "正在进行中……", "e7876a2bde": "暂存至少一个文件以生成消息。", "0904be2505": "清除消息以重新生成。", - "1ea9ba37aa": "在“设置”->“Git”->“源代码管理 AI”中选择一个代理。" + "1ea9ba37aa": "在“设置”->“Git”->“源代码管理 AI”中选择一个智能体。" } } } @@ -13817,7 +13817,7 @@ "69af7e9c1c": "尚未加入 PATH。Orca 会将其设为默认,你可随时安装。", "1eee1c7bd8": "PATH 中未检测到智能体。可选择一个稍后安装,或使用空白终端继续。", "hideAgents": "隐藏智能体", - "showMoreAgents": "显示另外 {{value0}} 个代理 →", + "showMoreAgents": "显示另外 {{value0}} 个智能体 →", "yoloModeLabel": "Yolo 模式", "yoloPermissionsDescription": "智能体会在不询问的情况下运行命令和编辑文件,部分智能体还会绕过沙箱。仅在你信任的项目中使用。" }, @@ -13866,7 +13866,7 @@ "ac80d97e02": "更改自定义文件", "56b836215c": "正在检查通知权限…", "fd84d3e9b8": "通知已启用", - "4f7bce5644": "当代理完成或终端需要处理时,macOS 会提醒你。", + "4f7bce5644": "当智能体完成或终端需要处理时,macOS 会提醒你。", "95d99b52fa": "允许 Orca 发送通知", "94562ba367": "macOS 正在请求权限。在对话框中点按允许,此步骤会自动更新。", "4f6a1da718": "打开系统设置", @@ -14607,17 +14607,17 @@ "copy": { "62bac8f43c": "同时在 2 个不同的工作树中工作。每个项目都是独立的(即使在同一个项目中)。非常适合同时处理 2 个功能。", "908898c3ee": "使用 Orca 浏览器", - "43781563c3": "无需离开 Orca 即可浏览您的网络应用程序。抓取任何元素,然后一键将其确切的来源和样式发送给代理。", + "43781563c3": "无需离开 Orca 即可浏览您的网络应用程序。抓取任何元素,然后一键将其确切的来源和样式发送给智能体。", "29aa2c2077": "打开通知", - "71bd9a8c95": "了解代理完成、需要关注或被阻止的时刻。", - "46db810da8": "选择您的默认代理", - "b8e5bae17f": "通过已选择的首选代理,更快地开始新工作。", + "71bd9a8c95": "了解智能体完成、需要关注或被阻止的时刻。", + "46db810da8": "选择您的默认智能体", + "b8e5bae17f": "通过已选择的首选智能体,更快地开始新工作。", "ad342dd4c6": "连接集成", - "06fe30fdb0": "一键从任务启动代理并查看 PR 状态。", + "06fe30fdb0": "一键从任务启动智能体并查看 PR 状态。", "eddc532e58": "自动化工作区设置", - "56049b74c2": "自动运行安装和设置命令,以便每个新工作树都为代理做好准备。", + "56049b74c2": "自动运行安装和设置命令,以便每个新工作树都为智能体做好准备。", "2cf795433b": "在多个存储库中开始工作", - "42525ba8a4": "将您的关键存储库带入 Orca,这样您就可以开始代理工作,而无需寻找文件夹。", + "42525ba8a4": "将您的关键存储库带入 Orca,这样您就可以开始智能体工作,而无需寻找文件夹。", "workOnTwoTasks": "同时处理两项任务", "agentSkillsName": "为智能体提供 Orca 技能", "agentSkillsDescription": "安装智能体用于操作 Orca 浏览器、控制你的电脑以及协调多步骤工作的技能。" @@ -15789,7 +15789,7 @@ "automations": { "intro": { "title": "什么是自动化?", - "body": "自动化按计划运行代理工作。单击此按钮添加自动化。" + "body": "自动化按计划运行智能体工作。单击此按钮添加自动化。" }, "results": { "title": "查找结果", @@ -16219,7 +16219,7 @@ "0a75e5e2fa": "创建 Hermes 自动化", "03142e7721": "编辑 Hermes 自动化", "17086b48ee": "编辑自动化", - "4c8e1a72b9": "重复代理任务" + "4c8e1a72b9": "重复智能体任务" }, "AutomationMissedRunGraceField": { "0f4459e91d": "48小时", @@ -16724,8 +16724,8 @@ "hiddenCount": "{{value0}} 隐藏", "hideAutomationCreated": "隐藏自动化创建的工作区", "hideCliCreated": "隐藏 CLI 创建的工作区", - "hideOtherClientsAria": "隐藏共享远程服务器上由其他 Orca 客户端创建的工作区中的代理", - "hideOtherClients": "隐藏其他客户端的代理" + "hideOtherClientsAria": "隐藏共享远程服务器上由其他 Orca 客户端创建的工作区中的智能体", + "hideOtherClients": "隐藏其他客户端的智能体" }, "ActivityThreadRow": { "clearNotification": "清除通知" @@ -16737,7 +16737,7 @@ }, "clearCompleted": { "clearedOne": "清除 1 个已完成的智能体", - "clearedMany": "已清除 {{count}} 已完成的代理", + "clearedMany": "已清除 {{count}} 已完成的智能体", "undo": "撤消" }, "standaloneWorktree": { @@ -16759,16 +16759,16 @@ "markUnread": "标记为未读", "goToWorkspace": "转到工作区", "clear": "从列表中清除", - "agentSection": "代理", - "markManyRead_one": "将 {{count}} 个代理标记为已读", - "markManyRead_other": "将 {{count}} 个代理标记为已读", - "markManyUnread_one": "将 {{count}} 个代理标记为未读", - "markManyUnread_other": "将 {{count}} 个代理标记为未读", - "clearMany_one": "从列表中清除 {{count}} 个代理", - "clearMany_other": "从列表中清除 {{count}} 个代理", - "markManyRead": "将 {{count}} 个代理标记为已读", - "markManyUnread": "将 {{count}} 个代理标记为未读", - "clearMany": "从列表中清除 {{count}} 个代理" + "agentSection": "智能体", + "markManyRead_one": "将 {{count}} 个智能体标记为已读", + "markManyRead_other": "将 {{count}} 个智能体标记为已读", + "markManyUnread_one": "将 {{count}} 个智能体标记为未读", + "markManyUnread_other": "将 {{count}} 个智能体标记为未读", + "clearMany_one": "从列表中清除 {{count}} 个智能体", + "clearMany_other": "从列表中清除 {{count}} 个智能体", + "markManyRead": "将 {{count}} 个智能体标记为已读", + "markManyUnread": "将 {{count}} 个智能体标记为未读", + "clearMany": "从列表中清除 {{count}} 个智能体" } }, "confirmation": { @@ -17200,14 +17200,14 @@ "signInCopy": "登录以查看和管理通过您的帐户共享的工件。", "signIn": "登录 Orca", "empty": "没有共享工件", - "emptyCopy": "打开 HTML 或 Markdown 文件并选择共享为工件,或要求您的代理共享它。", + "emptyCopy": "打开 HTML 或 Markdown 文件并选择共享为工件,或要求您的智能体共享它。", "openArtifact": "打开神器", "deleteArtifact": "删除工件", "moreAvailable": "更多工件可用", "moreAvailableCopy": "加载下一页以继续。", "loadMoreFailed": "无法加载更多工件。", "publishingOff": "发布已关闭", - "publishingOffCopy": "此设备上的任何内容都无法创建公共工件链接。允许在“设置”→“工件”中进行发布,然后从打开的 HTML 或 Markdown 文件进行共享或询问您的代理。", + "publishingOffCopy": "此设备上的任何内容都无法创建公共工件链接。允许在“设置”→“工件”中进行发布,然后从打开的 HTML 或 Markdown 文件进行共享或询问您的智能体。", "openArtifactsSettings": "打开设置 → 工件", "retry": "重试", "reconnectHeading": "再次登录 Orca", @@ -17313,8 +17313,8 @@ }, "NativeChatResumeOnRestartModal": { "title": "恢复中断的聊天?", - "body": "当 Orca 关闭时,这些聊天正在工作。恢复会将每个聊天还原到停止的位置及其完整上下文,并要求代理在继续之前检查它正在做的事情。您自己的提示不会重新发送。", - "updateBody": "当 Orca 安装更新时,这些聊天正在工作。恢复会将每个聊天还原到停止的位置及其完整上下文,并要求代理在继续之前检查它正在做的事情。您自己的提示不会重新发送。", + "body": "当 Orca 关闭时,这些聊天正在工作。恢复会将每个聊天还原到停止的位置及其完整上下文,并要求智能体在继续之前检查它正在做的事情。您自己的提示不会重新发送。", + "updateBody": "当 Orca 安装更新时,这些聊天正在工作。恢复会将每个聊天还原到停止的位置及其完整上下文,并要求智能体在继续之前检查它正在做的事情。您自己的提示不会重新发送。", "resuming": "正在恢复…", "untitled": "无标题聊天", "listLabel": "将恢复的聊天", @@ -17342,8 +17342,8 @@ "activityApproval": "等待您的批准:{{value0}}", "activityQuestion": "等待您的回答:{{value0}}", "activityMidReply": "回复进行中", - "activitySubagentOne": "子代理运行中:{{value0}}", - "activitySubagentMany": "{{value0}} 个子代理运行中", + "activitySubagentOne": "子智能体运行中:{{value0}}", + "activitySubagentMany": "{{value0}} 个子智能体运行中", "activityMonitorOne": "正在监控:{{value0}}", "activityMonitorMany": "正在监控 {{value0}} 个后台任务" }, @@ -17364,7 +17364,7 @@ "53e1559f99": "需要手动安装", "a7ac6ec78b": "Orca 下载了系统包。从终端完成更新之前退出 Orca。", "82c6dbea00": "复制命令,退出 Orca,然后在安装 Orca 的计算机上的系统终端中运行它。完成后重新打开 Orca。", - "53c4b8e148": "没有可用的身份验证代理响应特权安装请求。", + "53c4b8e148": "没有可用的身份验证智能体响应特权安装请求。", "c732bcbf8f": "检查包裹...", "aa57fa4f80": "命令已复制。退出 Orca,在系统终端中运行它以安装 {{value0}},然后重新打开 Orca。", "b7e7c5bc95": "Orca 在构建此命令时根据发布元数据检查下载的文件。系统包本身没有经过签名检查,之后 Orca 无法为该文件提供担保。" @@ -17436,7 +17436,7 @@ "conflict": "另一个 Orca 窗口或终端仍持有此会话。请将其关闭,然后重试。", "ownershipUnknown": "Orca 仍在确认哪个进程持有此会话。请稍候,然后重试。", "notAttached": "聊天未能重新连接。请重试,或将其打开以开始新的轮次。", - "dispatchRejected": "代理拒绝了“continue”消息。请打开聊天并自行发送一条。", + "dispatchRejected": "智能体拒绝了“continue”消息。请打开聊天并自行发送一条。", "unsupported": "此聊天无法由 Orca 恢复。请将其打开以查看停止位置。", "fallback": "Orca 无法恢复此聊天。请将其打开以手动继续。" }, @@ -17596,7 +17596,7 @@ "restartFailed": "智能体无法重新启动。", "capacity": "Orca 在过去一天内收到的请求过多。", "outcomeUnknown": "Orca 无法确认发生了什么。请查看聊天。", - "sendOutcomeLost": "Orca 无法确认你的消息已送达代理。请查看聊天,如有需要请重新发送。", + "sendOutcomeLost": "Orca 无法确认你的消息已送达智能体。请查看聊天,如有需要请重新发送。", "questionChanged": "此问题已被回答或已更改。", "historyUnreadable": "Orca 无法读取此聊天的已保存历史记录。", "historyUnusable": "无法加载此聊天。", @@ -17751,7 +17751,7 @@ "runWebSearchMany": "网络搜索 {{value0}} 次", "runIntegrationOne": "使用 1 集成", "runIntegrationMany": "使用 {{value0}} 集成", - "runAgentOne": "然 1 代理", + "runAgentOne": "然 1 智能体", "runAgentMany": "然 {{value0}} 智能体", "runPlanOne": "更新了计划", "runPlanMany": "更新计划 {{value0}} 次", @@ -17773,8 +17773,8 @@ "runLiveWebSearchMany": "搜索网络 {{value0}} 次", "runLiveIntegrationOne": "使用 1 集成", "runLiveIntegrationMany": "使用 {{value0}} 集成", - "runLiveAgentOne": "运行 1 个代理", - "runLiveAgentMany": "运行 {{value0}} 代理", + "runLiveAgentOne": "运行 1 个智能体", + "runLiveAgentMany": "运行 {{value0}} 智能体", "runLivePlanOne": "更新计划", "runLivePlanMany": "更新计划 {{value0}} 次", "runLiveToolOne": "使用 1 个工具", @@ -17901,7 +17901,7 @@ "outputFile": "输出:{{value0}}", "detailsUnavailable": "此会话的任务详细信息不可用。", "countAgentsOne": "1 个智能体", - "countAgentsMany": "{{value0}} 代理", + "countAgentsMany": "{{value0}} 智能体", "countShellOne": "1 个外壳", "countShellMany": "{{value0}} 贝壳", "countShellCommandOne": "1 个 shell 命令", @@ -17922,7 +17922,7 @@ "stateUnverifiableCount": "{{value0}} 暂无近期更新", "reasonWaiting": "需要批准", "reasonBlocked": "失败的", - "groupAgents": "代理", + "groupAgents": "智能体", "groupShell": "壳", "groupMonitors": "显示器", "groupWorkflows": "工作流程", @@ -17951,19 +17951,19 @@ "stoppedCount": "{{value0}} 已停止", "unverifiableCount": "{{value0}} 暂无近期更新" }, - "startedOne": "踢掉 1 个子代理", - "startedN": "启动 {{value0}} 子代理", - "ranOne": "Ran 1 子代理", - "ranN": "Ran {{value0}} 子代理", + "startedOne": "踢掉 1 个子智能体", + "startedN": "启动 {{value0}} 子智能体", + "ranOne": "Ran 1 子智能体", + "ranN": "Ran {{value0}} 子智能体", "tokens": "{{value0}} 代币", - "unnamed": "子代理" + "unnamed": "子智能体" }, "conversationCommand": { "pendingWork": "在使用此命令之前,请等待挂起的工作和消息完成。" }, "drop": { "title": "拖放以附加到此聊天", - "subtitle": "文件将作为代理可以读取的路径添加到您的消息中。" + "subtitle": "文件将作为智能体可以读取的路径添加到您的消息中。" }, "copyCode": "复制代码", "context": { @@ -18083,7 +18083,7 @@ "selectableOnly": "仅可删除", "kind": { "status": "状态", - "agent": "代理", + "agent": "智能体", "git": "Git", "review": "审查", "reviewState": "审查状态", @@ -18102,7 +18102,7 @@ "comment": "评论", "prunable": "可修剪", "locked": "已锁定", - "retainedAgents": "已完成的代理" + "retainedAgents": "已完成的智能体" } }, "noSizes": "尚未测量工作区大小。", @@ -18168,7 +18168,7 @@ "pinned": "已固定", "unread": "未读", "comment": "有评论", - "retainedDoneAgents": "代理成绩单完成", + "retainedDoneAgents": "智能体成绩单完成", "contextPresence": "打开选项卡、终端、评论", "completelyEmpty": "没有什么可失去的", "noWorkspaces": "未找到工作区。", @@ -18338,7 +18338,7 @@ "onboarding": { "flow": { "stepTooltip": { - "agent": "默认代理", + "agent": "默认智能体", "theme": "外观", "windowsTerminal": "Windows 终端", "notifications": "通知", @@ -18393,7 +18393,7 @@ "settings": { "TerminalInteraction": { "copyTrimsGutter": "修剪副本上的装订线", - "copyTrimsGutterDescription": "将左侧装订线代理输出绘制在后面,因此复制的文本不会缩进。仅删除每个选定行共享的缩进。" + "copyTrimsGutterDescription": "将左侧装订线智能体输出绘制在后面,因此复制的文本不会缩进。仅删除每个选定行共享的缩进。" }, "terminal": { "clipboard": { @@ -18682,9 +18682,9 @@ "voice": { "demoPrompt": "查看此差异以了解边缘情况,并为您发现的任何内容添加测试。", "startDictation": "开始听写", - "agentPromptTitle": "代理提示", + "agentPromptTitle": "智能体提示", "listening": "听...", - "focusPaneInstruction": "聚焦终端、编辑器或代理提示符,然后按", + "focusPaneInstruction": "聚焦终端、编辑器或智能体提示符,然后按", "startInstruction": "开始语音听写。按", "stopInstruction": "再次停止。", "unassignedInstruction": "在焦点窗格中开始语音听写之前指定听写快捷方式。", @@ -18695,7 +18695,7 @@ "ageHours": "2小时", "ageMinutes": "12分钟", "ageYesterday": "1天", - "agentsInstruction": "您的代理也可以搜索。让它“找到我们修复登录超时的那次会话”。", + "agentsInstruction": "您的智能体也可以搜索。让它“找到我们修复登录超时的那次会话”。", "consentInstruction": "随时可在以下位置更改或关闭", "continueInBackground": "在后台继续", "demoHit1Age": "3天", @@ -18721,10 +18721,10 @@ "demoRoleAssistant": "助手", "demoRoleUser": "用户", "enableFailed": "无法开启会话搜索。请重试。", - "indexingDescription": "Orca 正在读取您过去的代理会话记录,以便您可以搜索它们。这可能需要几分钟。", - "indexingTitle": "正在索引您的代理会话", + "indexingDescription": "Orca 正在读取您过去的智能体会话记录,以便您可以搜索它们。这可能需要几分钟。", + "indexingTitle": "正在索引您的智能体会话", "progressStarting": "正在查找您的会话…", - "readyDescription": "现在可以通过会话内容找到此电脑上的每个代理会话。", + "readyDescription": "现在可以通过会话内容找到此电脑上的每个智能体会话。", "readyTitle": "会话搜索已就绪", "readyToast": "会话搜索已就绪", "readyToastOpen": "打开", @@ -18734,7 +18734,7 @@ "recent2Title": "更新 lockfile", "recent3Text": "这是按领域分组的记录初稿。", "recent3Title": "发布说明草稿", - "settingsLink": "设置 → 代理会话搜索", + "settingsLink": "设置 → 智能体会话搜索", "startSearching": "开始搜索" } }, @@ -18771,7 +18771,7 @@ "tryAgain": "重试", "launchFailedMessage": "Orca 无法启动绘制其窗口的进程。", "launchFailedDetail": "Orca 重试了 {{recoveryCount}} 次,但未成功。", - "processLimitDetail": "由于你的用户帐户已达到进程数上限,系统拒绝创建新进程。这通常由失控的终端、开发服务器或代理导致。请关闭其中一些,然后点击“重试”。", + "processLimitDetail": "由于你的用户帐户已达到进程数上限,系统拒绝创建新进程。这通常由失控的终端、开发服务器或智能体导致。请关闭其中一些,然后点击“重试”。", "launchFailedGenericDetail": "系统拒绝启动它。请释放内存或关闭其他应用,然后点击“重试”。如果问题持续出现,请重新安装 Orca。", "lowCommitMessage": "Windows 内存不足。", "lowCommitDetail": "Windows 只剩下 {{availableMB}} MB 内存供应用程序使用,因此窗口在重新加载后再次耗尽内存。", @@ -18788,7 +18788,7 @@ "sortRelevance": "最相关的", "sortNewest": "最新", "sortResultsAriaLabel": "排序结果:{{value0}}", - "consent": "启用全文搜索吗? Orca 根据本地代理记录在此计算机上构建索引,包括完整对话和每个工具输出最多 3,072 个字符。内容未经过编辑。经过身份验证的配对客户端可以搜索它。", + "consent": "启用全文搜索吗? Orca 根据本地智能体记录在此计算机上构建索引,包括完整对话和每个工具输出最多 3,072 个字符。内容未经过编辑。经过身份验证的配对客户端可以搜索它。", "remoteDisabled": "此计算机上已禁用搜索。在该计算机上启用转录索引以搜索其会话。", "notReady": "搜索索引尚未准备好。稍后再试。", "noService": "在此计算机上无法进行搜索。它可能需要 Orca 更新或具有搜索支持的运行时。", @@ -18805,7 +18805,7 @@ "sessionsOfLoaded": "{{value0}} 次(共 {{value1}} 次)", "showMoreSessions": "显示更多会话", "loadingMoreSessions": "正在加载更多会话...", - "unsupportedAgent": "此计算机不支持所选代理的历史搜索。更新该计算机上的 Orca 或选择其他代理。" + "unsupportedAgent": "此计算机不支持所选智能体的历史搜索。更新该计算机上的 Orca 或选择其他智能体。" } }, "sessionHistory": { @@ -18825,17 +18825,17 @@ "advanced": "高级", "deleteIndexCopy": "清除搜索数据", "deleteTitle": "清除这台计算机上的搜索数据吗?", - "deleteEnabled": "关闭搜索并从此计算机中删除可搜索副本。您的代理会话不受影响。", - "deleteDisabled": "从此计算机中删除可搜索副本。您的代理会话不受影响。", + "deleteEnabled": "关闭搜索并从此计算机中删除可搜索副本。您的智能体会话不受影响。", + "deleteDisabled": "从此计算机中删除可搜索副本。您的智能体会话不受影响。", "delete": "清除", "cleared": "搜索数据已清除。", "clearError": "无法清除搜索数据。再试一次。", "webUnsupported": "从该计算机上的 Orca 桌面应用程序打开会话搜索。", - "title": "代理会话搜索", - "description": "在此计算机和任何配对的 Orca 服务器上搜索您的代理所说和所做的一切。", + "title": "智能体会话搜索", + "description": "在此计算机和任何配对的 Orca 服务器上搜索您的智能体所说和所做的一切。", "searchDescription": "打开此计算机和配对服务器的会话搜索,或清除搜索数据。", "indexComputers": "在会话内搜索", - "computersConsent": "每台计算机都保留自己的代理对话和工具输出的可搜索副本。没有任何东西离开那台计算机。", + "computersConsent": "每台计算机都保留自己的智能体对话和工具输出的可搜索副本。没有任何东西离开那台计算机。", "serverVersion": "Orca v{{version}}", "rowSwitchLabel": "搜索 {{host}} 上的会话", "serverOffline": "离线", @@ -18848,16 +18848,16 @@ "showMore": "显示 {{count}} 更多", "showFewer": "显示较少", "openInSidebar": "在侧边栏中打开", - "openInSidebarCopy": "输入您记得的内容,或询问代理:“找到我们修复登录超时的会话。”", + "openInSidebarCopy": "输入您记得的内容,或询问智能体:“找到我们修复登录超时的会话。”", "open": "打开", "enableOnAll": "在所有计算机上启用" } }, "aiVault": { "subagents": { - "loading": "正在加载子代理...", - "loadError": "无法加载所有子代理。", - "empty": "未找到子代理。" + "loading": "正在加载子智能体...", + "loadError": "无法加载所有子智能体。", + "empty": "未找到子智能体。" }, "antigravityReferenceDescription": "使用此记录作为参考继续新的Antigravity CLI 对话。", "antigravityIdeToCli": "IDE → CLI", @@ -18928,7 +18928,7 @@ }, "accounts": { "managedData": { - "description": "在 Orca 主机的终端中运行此命令以添加账户。选择适用于该主机上新启动的代理;直接 SSH 连接和 Windows 托管的 WSL 使用各自的凭据。", + "description": "在 Orca 主机的终端中运行此命令以添加账户。选择适用于该主机上新启动的智能体;直接 SSH 连接和 Windows 托管的 WSL 使用各自的凭据。", "copied": "已复制", "add": "复制添加账户命令", "refresh": "刷新账户", @@ -18938,7 +18938,7 @@ "select": "选择", "remove": "移除", "removeTitle": "移除托管账户?", - "removeDescription": "请先停止使用此配置的代理。移除将删除其保存的凭据和对话数据。系统登录不受影响。", + "removeDescription": "请先停止使用此配置的智能体。移除将删除其保存的凭据和对话数据。系统登录不受影响。", "cancel": "取消" }, "antigravity": { @@ -18982,7 +18982,7 @@ }, "codexTerminalServerIsolationNotice": { "title": "Orca 现在无需共享服务器即可运行 Codex", - "description": "这使得代理状态更加可靠。您可以在“设置”中将其重新打开。", + "description": "这使得智能体状态更加可靠。您可以在“设置”中将其重新打开。", "openSettings": "打开设置" }, "codexSharedSettingsNotice": { @@ -18991,17 +18991,17 @@ }, "codexSharedServerBanner": { "title": "此 Codex 与您的其他 Codex 选项卡共享服务器", - "body": "会话可能会意外结束,并且代理状态可能会错误。", + "body": "会话可能会意外结束,并且智能体状态可能会错误。", "openedBeforeUpdateBody": "这个终端是在 Orca 开始为每个 Codex 提供自己的服务器之前打开的。", "oldTerminalDialogTitle": "为什么此 Codex 共享服务器", - "oldTerminalDialogRisk": "共享服务器的 Codex 会话可能会一起结束,并且代理状态可能会错误。", + "oldTerminalDialogRisk": "共享服务器的 Codex 会话可能会一起结束,并且智能体状态可能会错误。", "oldTerminalDialogCause": "较旧的终端仍然共享 Codex 服务器。打开一个新终端以在单独的服务器上运行 Codex。", "fix": "使固定", "openNewTerminal": "打开新终端", "dontShowAgain": "不再显示", "dismiss": "关闭", "dialogTitle": "为每个 Codex 选项卡提供自己的服务器", - "dialogDescription": "Codex 会话直接在共享一台后台服务器的终端中启动。 Orca 将其启动的 Codex 会话分开保存。当会话共享一台服务器时,关闭一个服务器可能会结束其他服务器,并且代理状态可能会错误。", + "dialogDescription": "Codex 会话直接在共享一台后台服务器的终端中启动。 Orca 将其启动的 Codex 会话分开保存。当会话共享一台服务器时,关闭一个服务器可能会结束其他服务器,并且智能体状态可能会错误。", "copy": "复制", "copied": "已复制", "done": "完成", @@ -19066,7 +19066,7 @@ "refreshFailed": "无法刷新工作区列表", "review": "连同嵌套工作树一起删除…", "title": "删除工作区和嵌套工作树?", - "description": "这将永久删除“{{name}}”、下面列出的每个嵌套工作树及其中的所有文件(包括未提交的更改)。它们的终端和代理将被停止。", + "description": "这将永久删除“{{name}}”、下面列出的每个嵌套工作树及其中的所有文件(包括未提交的更改)。它们的终端和智能体将被停止。", "detached": "分离 HEAD", "loading": "正在检查嵌套工作树…", "order": "嵌套工作树会先被删除。如果其中一个失败,删除将停止并保留父工作树。分支遵循您现有的删除设置。", From ccc0bf70e46ea33b1f9d735942de9d2a71664cd2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:49:20 -0700 Subject: [PATCH 09/48] Pause Pullfrog reviews while the CI runner queue recovers (#25760) --- .github/workflows/pullfrog.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml index 2fc81c887bb..1520d890920 100644 --- a/.github/workflows/pullfrog.yml +++ b/.github/workflows/pullfrog.yml @@ -28,6 +28,8 @@ concurrency: jobs: review_scope: + # Paused while we measure CI queue recovery. + if: vars.PULLFROG_PAUSED != 'true' runs-on: ubuntu-slim permissions: contents: read From 7f2541373a7966fb8b600cc03b97121fd94e67df Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:53:07 -0700 Subject: [PATCH 10/48] Stage long agent launch lines where they are typed, so they arrive whole (step 2 of 7) (#23962) * feat(agent-launch): host-side prompt delivery for agent.launch The host's agent.launch typed any launch prompt into the shell as part of the launch command. A long or multi-line prompt then ran line by line in the shell, and an agent that never showed readiness or crashed at startup had nothing guarding where its text went. agent.launch now carries a prompt on the typed line only when the line stays one line, control-free and at most 512 bytes; otherwise the agent starts clean and the host pastes the prompt once the agent's own ready signal fires (bracketed paste plus its composer marker or a quiet render, read only after the shell's last hand-off, never while the pane's own shell is proven in front), with main's draft-paste bytes and an Enter 50 ms later. Orchestration worker starts wait on tui-idle as before. A replay-safe launch admits and claims its ledger row in one write, Qwen Code gets a second Enter, the desktop and phone share one launch-refusal classifier, and hosts advertise agent.launch.prompt-carry.v1. Split out of #23748, which moves the desktop source-control buttons onto this path. * fix(agent-launch): keep a short-lined multi-line prompt on a local zsh launch line, as main did #24257 moved every multi-line or over-512-byte prompt off the typed launch line and pasted it after readiness. The phone's AI buttons and review notes, whose multi-line prompts main typed whole into zsh, then reached Claude 0.5-3 s later and their RPC reply waited for the paste. The host now names the shell a local macOS or Linux line is typed into, the way the spawn picks it, and a multi-line prompt rides a zsh line when every line is at most 512 bytes and the whole line at most 8 KB. A real-zsh test types such a line through Orca's own ready barrier and startup write, including when a slow user config makes the write land early. Elsewhere the measured unsafe cases keep the paste: bash 3.2 runs multi-line lines piecemeal, fish drops an early multi-line write, and any shell loses a line over 1 KB written early. * test(agent-launch): keep the real-zsh launch-line test out of the Windows lane's gate scan The Windows lane registration check read `const ZSH_PATH = process.platform === 'win32'` (the head of a multi-line ternary) as a Windows-true flag, so `describe.skipIf(!ZSH_PATH)` looked like a Windows-only suite. The file is POSIX-only; the zsh lookup is now a function. * refactor(protocol): move the agent.launch capabilities into their own module Main's protocol-version.ts sits at the 300-line cap, so the prompt-carry capability pushed it over. The four agent.launch capabilities and their doc move to agent-launch-runtime-capability.ts, re-exported by name and spread into RUNTIME_CAPABILITIES at the same position; the advertised lists and every export are unchanged. * refactor(protocol): import the agent.launch capabilities from their own module `export *` from protocol-version left the four names undefined under the mobile recording loader, which resolves a relative import through a Proxy with no own keys, so 37 phone recordings lost agent.launchReplay. Importers now name agent-launch-runtime-capability directly; protocol-version only spreads its list. * refactor(agent-launch): drop the unshipped viewMode field and trusted local caller id Both were inert in step 1 and existed only for step 2. agent.launch will become a public plugin API, so every wire field is permanent once shipped; a top-level viewMode reads as "choose terminal vs chat", which the host decides. Step 2 introduces placement and view intent under a placement object instead. * fix(agent-launch): read Codex's provisional startup from the rule files' hold anchor Main (#24375) moved Codex's provisional-header check into codex.json's provisional_startup hold anchor and deleted codex-terminal-readiness.ts, so the launch readiness hold now asks showsHoldAnchor, as main's own settled check does. * fix(agent-launch): hold rule-file name titles to quiet for a launch, and census the zsh fixture Main (#24375) answers a name-only title from each agent's rule file ahead of the sustained-title lane, so gemini.json's name_title settled a launch readiness wait on the shell's auto-title while Gemini was still booting. A launch now asks quiet of every weak idle verdict, as that lane did. Main's readiness census requires a recorder for every runtime fixture; the zsh prompt recording is a non-agent control. Gemini's synthetic baseline is regenerated for this PR's stated change: a bare gemini title is no longer its rest mark, so name-only rows settle weak, and a fresh working or blocked status is no longer overridden. * fix(launch): stage long or multi-line launch lines so they arrive whole A launch line that is long or has several lines could lose lines or wedge a shell that is still starting, because it was typed into the terminal before the shell could read it whole. The terminal host (local daemon, and the SSH relay) now writes such a line to a script file and types one short line that runs it, for every POSIX shell; a shell that cannot read Orca's quoting runs Orca's own agent lines through /bin/sh. If the script cannot be written, the terminal says so. The SSH background launch path no longer types the line from the renderer; the relay stages it like any other terminal. Bumps the terminal daemon protocol to 40 (main took 39); a v39 daemon keeps its sessions and gets no staged line. Restacked onto #24257 from the version reviewed on top of #23748; carries none of #23748's changes. * fix(launch): run a staged launch line as its own job in fish and ksh fish and ksh run the commands of a sourced file in the shell's own process group, so a staged agent was not a job: Ctrl-Z was dropped (a typed line stops), and the shell-in-front check saw the shell's group in the foreground while the agent ran. fish now runs the script with `eval (string collect < '')`, which runs it as if typed and leaves the shell's job-control mode alone. ksh and mksh run a long Orca-built line through `/bin/sh ''`, a real job; a long command the user wrote is typed as before. The real-shell test now asserts the agent's process group is its own and is the terminal's foreground group. The staging rule also reuses hasControlByte and the 512-byte budget from startup-line-prompt-carry so the carry and staging limits cannot drift. * fix(agent-launch): paste a launch prompt only when the launched agent is proven in front A launch pasted its prompt unless a shell was proven in the terminal's foreground, so any read that could not prove one let the prompt through. After an agent exited at startup, its shell turned bracketed paste on at the next prompt, readiness fired on it, and the prompt was typed into the shell: - macOS: a pane runs its shell under login, so the process-group fence's root was never the shell's group and never proved it; the cached foreground name could also still name the exited process. - Windows Git Bash and WSL: the shell-alone-in-its-job check never answers. Now one fresh read of the terminal's foreground decides: agent, shell or unknown. Only 'agent' lets a write through (paste, Enter, second Enter, reused panes too); 'shell' still drops a ready signal. A Windows host never proves the agent, so there the launch line carries the prompt at any size, as on main. * test(agent-launch): cover the Windows QA stub, a grok override that exits at once * fix(agent-launch): keep the local socket alive while a prompted launch waits for its agent A launch with a prompt now waits up to 60 s for the terminal agent to be ready before it writes the prompt, and reports not-delivered when the agent never is. The local runtime socket closes a connection idle for 30 s unless the request is a long poll, so a launch whose agent exited at startup lost its reply and the caller saw 'runtime closed the connection' instead of not-delivered. Classify a prompted agent.launch and agent.launchReplay as a long poll, as orchestration.workerStart already is for the same wait. * refactor(agent-launch): narrow the launch params by 'in' instead of a cast * fix(agent-launch): find a launched agent behind a wrapper that leads its process group A tcsh or nu launch line runs the agent from /bin/sh '