From d67ede15947e59127e061c603ecb2c09651a6ea3 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:32:36 -0700 Subject: [PATCH 01/45] Implement confirm-only PR panel composer with classified error blocking (#9428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Clarify PR panel guidance: classify errors and confirm-only composer Replace the ambiguous GitHub hosted-review boolean with a four-state evidence model (found/positive_unresolved/not_found/unknown) so "No PR found" never appears without an accepted lookup result. Classify GitHub refresh failures into types (rate_limited, auth, network, permission, repo_unavailable, gh_unavailable, unknown) for stable, honest copy. Confirmed-only composer: preserve drafts across transient failures; hide Create during hard errors and positive-unresolved evidence. Hard errors clear only when an eligibility request starts after the error and returns an accepted outcome. Propagate error types and unified retry schedule through the store. Sync mobile parity with shouldOpenChecksPanelCreateComposer gating. Localize all new copy. * Clarify PR panel guidance: classify errors and confirm-only composer Add reviewLookupOutcome to hosted-review eligibility and thread it through the panel so it never claims "No PR found" without accepted evidence. A failed lookup is unavailable, not a settled no-PR. Fail closed on positive unresolved evidence, hard refresh errors, and unavailable lookups. Add structured GitHub refresh-error classification with Retry-After parsing. Implement confirmed-only composer gating based on fresh, matching-context eligibility with hard-error clearing. Mobile gates on reviewLookupOutcome to prevent false Create claims. Surface throwOnFailure variants for each provider so transport failures cross the RPC boundary instead of collapsing to null. (Design success criteria 1–4; invariant 8.) * Add exec-error helpers for subprocess error classification Extracts stderr/stdout parsing and Retry-After detection into a lightweight module that can be imported without pulling in the heavier runner machinery. Supports PR-refresh error classification and proper rate-limit handling for gh commands. * test(mobile): include reviewLookupOutcome in create eligibility fixtures Create / Push & Create now fails closed unless the lookup is not_found. Update mobile test fixtures so accepted-no-PR cases can still proceed. * Add OrThrow mock variants to forge-provider test mocks forge-provider resolves branch reviews via the OrThrow variant so lookup failures surface as unavailable instead of "no PR found". --- ...hosted-review-create-intent-runner.test.ts | 2 + .../mobile-hosted-review-service.ts | 25 +- .../source-control/mobile-pr-create.test.ts | 146 +++++- mobile/src/source-control/mobile-pr-create.ts | 10 +- .../azure-devops/azure-devops-api-request.ts | 19 +- src/main/azure-devops/client.test.ts | 32 ++ src/main/azure-devops/client.ts | 32 +- src/main/bitbucket/client.test.ts | 19 +- src/main/bitbucket/client.ts | 50 +- src/main/git/exec-error.ts | 111 ++++ src/main/git/runner.ts | 108 +--- src/main/gitea/client.test.ts | 14 + src/main/gitea/client.ts | 67 ++- src/main/github/client.test.ts | 28 + src/main/github/client.ts | 67 +-- src/main/github/gh-utils.ts | 7 +- src/main/github/pr-refresh-coordinator.ts | 163 +++++- .../pr-refresh-error-classification.test.ts | 144 +++++ .../github/pr-refresh-error-classification.ts | 144 +++++ src/main/gitlab/client-mr.test.ts | 14 + src/main/gitlab/client.ts | 26 +- src/main/runtime/orca-runtime.test.ts | 18 +- src/main/runtime/orca-runtime.ts | 12 +- .../runtime/rpc/methods/hosted-review.test.ts | 1 + src/main/source-control/forge-provider.ts | 25 +- ...hosted-review-creation-eligibility.test.ts | 92 +++- .../source-control/hosted-review-creation.ts | 53 +- src/main/source-control/hosted-review.test.ts | 12 + .../components/right-sidebar/ChecksPanel.tsx | 437 +++++++++++++++- .../right-sidebar/CommitArea.test.tsx | 6 +- .../right-sidebar/SourceControl.tsx | 6 +- ...ecks-panel-ambiguous-github-review.test.ts | 44 -- .../checks-panel-ambiguous-github-review.ts | 22 - .../checks-panel-blocker-copy.ts | 220 ++++++++ .../checks-panel-empty-state.test.ts | 495 ++++++++---------- .../right-sidebar/checks-panel-empty-state.ts | 335 +++++++----- .../checks-panel-git-status-snapshot.ts | 5 + .../right-sidebar/checks-panel-review-copy.ts | 288 ++++++++++ .../checks-panel-review-creation.test.ts | 397 +++++++++++--- .../checks-panel-review-creation.ts | 168 +++++- ...ecks-panel-review-lookup-authority.test.ts | 130 +++++ .../checks-panel-review-lookup-authority.ts | 120 +++++ .../checks-panel-review-state-model.ts | 165 ++++++ ...urce-control-create-pr-intent-flow.test.ts | 18 +- ...ntrol-create-review-blocked-action.test.ts | 1 + .../source-control-dropdown-items.test.ts | 21 +- ...view-creation-eligibility-snapshot.test.ts | 13 +- ...ed-review-creation-eligibility-snapshot.ts | 12 +- ...ol-primary-action.create-pr-intent.test.ts | 60 ++- .../source-control-primary-action.test.ts | 15 +- .../useCreatePullRequestDialogFields.test.ts | 1 + .../useCreatePullRequestDialogFields.ts | 25 +- src/renderer/src/i18n/locales/en.json | 141 ++++- src/renderer/src/i18n/locales/es.json | 141 ++++- src/renderer/src/i18n/locales/ja.json | 141 ++++- src/renderer/src/i18n/locales/ko.json | 141 ++++- src/renderer/src/i18n/locales/zh.json | 141 ++++- src/renderer/src/store/slices/github.ts | 73 ++- src/shared/hosted-review.ts | 9 + .../source-control-create-review-intent.ts | 5 + ...ce-control-primary-action-decision.test.ts | 6 +- src/shared/types.ts | 21 +- 62 files changed, 4359 insertions(+), 905 deletions(-) create mode 100644 src/main/git/exec-error.ts create mode 100644 src/main/github/pr-refresh-error-classification.test.ts create mode 100644 src/main/github/pr-refresh-error-classification.ts delete mode 100644 src/renderer/src/components/right-sidebar/checks-panel-ambiguous-github-review.test.ts delete mode 100644 src/renderer/src/components/right-sidebar/checks-panel-ambiguous-github-review.ts create mode 100644 src/renderer/src/components/right-sidebar/checks-panel-blocker-copy.ts create mode 100644 src/renderer/src/components/right-sidebar/checks-panel-review-copy.ts create mode 100644 src/renderer/src/components/right-sidebar/checks-panel-review-lookup-authority.test.ts create mode 100644 src/renderer/src/components/right-sidebar/checks-panel-review-lookup-authority.ts create mode 100644 src/renderer/src/components/right-sidebar/checks-panel-review-state-model.ts diff --git a/mobile/src/source-control/mobile-hosted-review-create-intent-runner.test.ts b/mobile/src/source-control/mobile-hosted-review-create-intent-runner.test.ts index c96edaded01..b64bb888798 100644 --- a/mobile/src/source-control/mobile-hosted-review-create-intent-runner.test.ts +++ b/mobile/src/source-control/mobile-hosted-review-create-intent-runner.test.ts @@ -35,6 +35,8 @@ function eligibility(overrides: Record) { defaultBaseRef: 'main', title: 'Ship mobile PR create', body: 'Generated body', + // Accepted no-review lookup so Create / Push & Create is allowed unless overridden. + reviewLookupOutcome: 'not_found', ...overrides } } diff --git a/mobile/src/source-control/mobile-hosted-review-service.ts b/mobile/src/source-control/mobile-hosted-review-service.ts index c219586edc7..c040b7a2c19 100644 --- a/mobile/src/source-control/mobile-hosted-review-service.ts +++ b/mobile/src/source-control/mobile-hosted-review-service.ts @@ -3,6 +3,7 @@ import type { HostedReviewCreationBlockedReason, HostedReviewCreationEligibility, HostedReviewCreationNextAction, + HostedReviewLookupOutcome, HostedReviewProvider } from '../../../src/shared/hosted-review' import type { RpcClient } from '../transport/rpc-client' @@ -62,6 +63,10 @@ export type MobileHostedReviewPrefill = { canCreate?: boolean blockedReason?: HostedReviewCreationBlockedReason nextAction?: HostedReviewCreationNextAction + // Why: mobile lacks the desktop refresh/review-lookup signals, so it fails + // closed on ambiguity. When the host could not prove the branch has no review + // (`unavailable`), create — including the Push & Create path — stays blocked. + reviewLookupOutcome?: HostedReviewLookupOutcome } // Resolve the mobile compose prefill from the same hosted-review eligibility @@ -97,7 +102,14 @@ export async function resolveMobileHostedReviewPrefill( behind: args.behind }) if (!eligibility) { - return { ...fallback, canCreate: false, blockedReason: null, nextAction: null } + // Eligibility itself could not be resolved: the review lookup is unproven. + return { + ...fallback, + canCreate: false, + blockedReason: null, + nextAction: null, + reviewLookupOutcome: 'unavailable' + } } return { provider: eligibility.provider, @@ -106,10 +118,17 @@ export async function resolveMobileHostedReviewPrefill( body: eligibility.body || '', canCreate: eligibility.canCreate, blockedReason: eligibility.blockedReason, - nextAction: eligibility.nextAction + nextAction: eligibility.nextAction, + reviewLookupOutcome: eligibility.reviewLookupOutcome } } catch { - return { ...fallback, canCreate: false, blockedReason: null, nextAction: null } + return { + ...fallback, + canCreate: false, + blockedReason: null, + nextAction: null, + reviewLookupOutcome: 'unavailable' + } } } diff --git a/mobile/src/source-control/mobile-pr-create.test.ts b/mobile/src/source-control/mobile-pr-create.test.ts index 28f2e4b691f..deb36b9e9f0 100644 --- a/mobile/src/source-control/mobile-pr-create.test.ts +++ b/mobile/src/source-control/mobile-pr-create.test.ts @@ -40,6 +40,7 @@ function eligibility( canCreate: true, blockedReason: null, nextAction: null, + reviewLookupOutcome: 'not_found', defaultBaseRef: 'main', title: 'Add feature', body: '', @@ -118,12 +119,115 @@ describe('mobile create form gating parity', () => { body: desktopEligibility.body ?? '', canCreate: desktopEligibility.canCreate, blockedReason: desktopEligibility.blockedReason, - nextAction: desktopEligibility.nextAction + nextAction: desktopEligibility.nextAction, + // Mobile receives the lookup outcome from eligibility; thread it so the + // gate reflects real prefills (current hosts always populate it). + reviewLookupOutcome: desktopEligibility.reviewLookupOutcome }) === null expect(mobileAllowsComposer).toBe(desktopAllowsComposer) }) + it('fails closed when the review-lookup outcome is missing (older host)', () => { + // A host that predates `reviewLookupOutcome` leaves review existence unproven. + // Mobile must not open Create / Push & Create on that ambiguity. + expect( + getMobilePrCreateBlockMessage({ + provider: 'github', + base: 'main', + title: 'Add feature', + body: '', + canCreate: true, + blockedReason: null + }) + ).toBe( + 'Orca could not confirm whether this branch already has a pull request. Try again in a moment.' + ) + expect( + getMobilePrCreateBlockMessage({ + provider: 'github', + base: 'main', + title: 'Add feature', + body: '', + canCreate: false, + blockedReason: 'needs_push' + }) + ).toBe( + 'Orca could not confirm whether this branch already has a pull request. Try again in a moment.' + ) + }) + + it('desktop gate hard-blocks on positive unresolved review evidence', () => { + // Mobile lacks review-lookup signals, so it fails closed on ambiguity: the + // shared desktop gate must return false even when eligibility looks ready. + expect( + shouldOpenChecksPanelCreateComposer({ + activeReview: null, + isFolder: false, + branch: 'feature/x', + hostedReviewCreation: eligibility({ canCreate: true }), + reviewLookup: 'positive_unresolved' + }) + ).toBe(false) + }) + + it('desktop gate hard-blocks during a hard refresh error', () => { + expect( + shouldOpenChecksPanelCreateComposer({ + activeReview: null, + isFolder: false, + branch: 'feature/x', + hostedReviewCreation: eligibility({ canCreate: true }), + hasHardRefreshError: true + }) + ).toBe(false) + }) + + it('fails closed on an unavailable review lookup even when eligibility looks ready', () => { + // The existing-review lookup could not prove there is no PR; mobile has no + // review-lookup signal of its own, so create must be blocked. + expect( + getMobilePrCreateBlockMessage({ + provider: 'github', + base: 'main', + title: 'Add feature', + body: '', + canCreate: true, + blockedReason: null, + reviewLookupOutcome: 'unavailable' + }) + ).toBe( + 'Orca could not confirm whether this branch already has a pull request. Try again in a moment.' + ) + }) + + it('fails closed on unavailable even on the needs_push Push & Create path', () => { + // needs_push would normally be allowed (Push & Create); an unavailable lookup + // must still block it — this is the fail-open gap the parity gate closes. + const mobileBlocked = + getMobilePrCreateBlockMessage({ + provider: 'github', + base: 'main', + title: 'Add feature', + body: '', + canCreate: false, + blockedReason: 'needs_push', + reviewLookupOutcome: 'unavailable' + }) !== null + const desktopAllowsComposer = shouldOpenChecksPanelCreateComposer({ + activeReview: null, + isFolder: false, + branch: 'feature/x', + hostedReviewCreation: eligibility({ + canCreate: false, + blockedReason: 'needs_push', + reviewLookupOutcome: 'unavailable' + }) + }) + expect(mobileBlocked).toBe(true) + expect(desktopAllowsComposer).toBe(false) + }) + it('stays safely blocked for a reason added by a newer desktop contract', () => { expect( getMobilePrCreateBlockMessage({ @@ -158,7 +262,8 @@ describe('resolveMobilePrPrefill', () => { nextAction: null, defaultBaseRef: 'develop', title: 'Add feature', - body: 'Body' + body: 'Body', + reviewLookupOutcome: 'not_found' }) ]) await expect(resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)).resolves.toEqual({ @@ -168,7 +273,8 @@ describe('resolveMobilePrPrefill', () => { body: 'Body', canCreate: true, blockedReason: null, - nextAction: null + nextAction: null, + reviewLookupOutcome: 'not_found' }) }) @@ -182,7 +288,8 @@ describe('resolveMobilePrPrefill', () => { nextAction: 'push', defaultBaseRef: 'main', title: 'Add feature', - body: '' + body: '', + reviewLookupOutcome: 'not_found' }) ]) const prefill = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs) @@ -198,7 +305,8 @@ describe('resolveMobilePrPrefill', () => { review: null, blockedReason: 'dirty', nextAction: 'commit', - defaultBaseRef: 'main' + defaultBaseRef: 'main', + reviewLookupOutcome: 'not_found' }) ]) const prefill = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs) @@ -209,15 +317,39 @@ describe('resolveMobilePrPrefill', () => { it('returns a blocked fallback when eligibility is unavailable', async () => { const client = clientWith([fail('nope')]) - await expect(resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs)).resolves.toEqual({ + const prefill = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs) + expect(prefill).toEqual({ provider: 'github', base: 'main', title: 'feature/x', body: '', canCreate: false, blockedReason: null, - nextAction: null + nextAction: null, + // Eligibility could not be resolved, so the review lookup is unproven. + reviewLookupOutcome: 'unavailable' }) + // A prefill Orca could not resolve must not offer create. + expect(getMobilePrCreateBlockMessage(prefill)).not.toBeNull() + }) + + it('threads reviewLookupOutcome from eligibility into the prefill and blocks needs_push', async () => { + const client = clientWith([ + ok({ + provider: 'github', + canCreate: false, + review: null, + blockedReason: 'needs_push', + nextAction: 'push', + defaultBaseRef: 'main', + title: 'Add feature', + body: '', + reviewLookupOutcome: 'unavailable' + }) + ]) + const prefill = await resolveMobilePrPrefill(client, 'repo-1::/tmp/wt', baseArgs) + expect(prefill.reviewLookupOutcome).toBe('unavailable') + expect(getMobilePrCreateBlockMessage(prefill)).not.toBeNull() }) it('blocks without calling the RPC when there is no branch', async () => { diff --git a/mobile/src/source-control/mobile-pr-create.ts b/mobile/src/source-control/mobile-pr-create.ts index b584b86e202..20f908d3df2 100644 --- a/mobile/src/source-control/mobile-pr-create.ts +++ b/mobile/src/source-control/mobile-pr-create.ts @@ -43,10 +43,18 @@ export function getMobilePrCreateSuccessWarning( } export function getMobilePrCreateBlockMessage(prefill: MobilePrPrefill): string | null { + const copy = hostedReviewCopy(prefill.provider) if (prefill.canCreate !== false || shouldPushBeforeMobileHostedReviewCreate(prefill)) { + // Fail closed: only an accepted no-review lookup (`not_found`) may open + // Create / Push & Create. `unavailable`, `found`, or a missing outcome (an + // older host that predates the field) all leave review existence unproven — + // mobile has no refresh/review-lookup signal of its own, so it must not + // offer create, or the needs_push Push & Create path would slip through. + if (prefill.reviewLookupOutcome !== 'not_found') { + return `Orca could not confirm whether this branch already has a ${copy.reviewLabel}. Try again in a moment.` + } return null } - const copy = hostedReviewCopy(prefill.provider) switch (prefill.blockedReason) { case 'dirty': return `Commit changes before creating a ${copy.reviewLabel}.` diff --git a/src/main/azure-devops/azure-devops-api-request.ts b/src/main/azure-devops/azure-devops-api-request.ts index 089975cf5a2..5fc8f256793 100644 --- a/src/main/azure-devops/azure-devops-api-request.ts +++ b/src/main/azure-devops/azure-devops-api-request.ts @@ -73,7 +73,11 @@ function apiUrl( export async function requestAzureDevOpsJsonAtBase( baseUrl: string, path: string, - options: AzureDevOpsRequestOptions = {} + options: AzureDevOpsRequestOptions = {}, + // Why: the existing-review lookup behind Create must distinguish a real + // transport/auth failure from an accepted "no PR". When true, a failed request + // throws instead of collapsing to null so callers never report false not_found. + throwOnFailure = false ): Promise { const config = getAzureDevOpsAuthConfig() try { @@ -86,10 +90,16 @@ export async function requestAzureDevOpsJsonAtBase( }) if (!response.ok) { await cancelUnreadResponseBody(response) + if (throwOnFailure) { + throw new Error(`Azure DevOps request failed: HTTP ${response.status}`) + } return null } return (await response.json()) as T - } catch { + } catch (error) { + if (throwOnFailure) { + throw error + } return null } } @@ -97,7 +107,8 @@ export async function requestAzureDevOpsJsonAtBase( export function requestAzureDevOpsJson( repo: AzureDevOpsRepoRef, path: string, - options: AzureDevOpsRequestOptions = {} + options: AzureDevOpsRequestOptions = {}, + throwOnFailure = false ): Promise { - return requestAzureDevOpsJsonAtBase(configuredApiBaseUrl(repo), path, options) + return requestAzureDevOpsJsonAtBase(configuredApiBaseUrl(repo), path, options, throwOnFailure) } diff --git a/src/main/azure-devops/client.test.ts b/src/main/azure-devops/client.test.ts index d394962fcff..8c993a9db0d 100644 --- a/src/main/azure-devops/client.test.ts +++ b/src/main/azure-devops/client.test.ts @@ -3,6 +3,7 @@ import { cancelTrackingResponse } from '../lib/unread-response-body.test-fixture import { getAzureDevOpsAuthStatus, getAzureDevOpsPullRequestForBranch, + getAzureDevOpsPullRequestForBranchOrThrow, normalizeAzureDevOpsApiBaseUrl } from './client' import { _resetAzureDevOpsRepoRefCache } from './repository-ref' @@ -122,6 +123,37 @@ describe('Azure DevOps client', () => { ]) }) + it('getAzureDevOpsPullRequestForBranchOrThrow surfaces a list failure instead of null (finding 4)', async () => { + gitExecFileAsyncMock.mockResolvedValue({ + stdout: 'https://dev.azure.com/acme/Project/_git/repo\n' + }) + globalThis.fetch = vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)) + if (url.pathname === '/acme/Project/_apis/git/repositories/repo') { + return new Response( + JSON.stringify({ + id: 'repo-guid', + webUrl: 'https://dev.azure.com/acme/Project/_git/repo' + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + } + // The pull-request list lookup fails (auth/transport). + return new Response(JSON.stringify({ message: 'forbidden' }), { status: 403 }) + }) as never + + // The swallowing variant collapses the failure into a false "no PR". + await expect( + getAzureDevOpsPullRequestForBranch('/repo', 'refs/heads/feature/azure') + ).resolves.toBeNull() + _resetAzureDevOpsRepoRefCache() + // The throwing variant makes the failure visible so eligibility records + // `unavailable` rather than a false "No pull request found". + await expect( + getAzureDevOpsPullRequestForBranchOrThrow('/repo', 'refs/heads/feature/azure') + ).rejects.toThrow(/Azure DevOps request failed/) + }) + it('uses the most recent branch PR instead of preferring stale active PRs', async () => { gitExecFileAsyncMock.mockResolvedValue({ stdout: 'https://dev.azure.com/acme/Project/_git/repo\n' diff --git a/src/main/azure-devops/client.ts b/src/main/azure-devops/client.ts index 2bed23c2cd4..25390ba4bfa 100644 --- a/src/main/azure-devops/client.ts +++ b/src/main/azure-devops/client.ts @@ -197,7 +197,8 @@ export async function getAzureDevOpsPullRequestForBranch( branch: string, linkedPRNumber?: number | null, connectionId?: string | null, - options: HostedReviewExecutionOptions = {} + options: HostedReviewExecutionOptions = {}, + throwOnFailure = false ): Promise { const branchName = branch.replace(/^refs\/heads\//, '') if (!branchName && linkedPRNumber == null) { @@ -224,7 +225,8 @@ export async function getAzureDevOpsPullRequestForBranch( 'searchCriteria.status': 'all', $top: 10 } - } + }, + throwOnFailure ) const raw = (list?.value ?? []).sort(sortPullRequestsForBranch)[0] if (raw) { @@ -239,11 +241,35 @@ export async function getAzureDevOpsPullRequestForBranch( repo, `/_apis/git/repositories/${encodePathSegment(repository.idOrName)}/pullRequests/${encodePathSegment( String(linkedPRNumber) - )}` + )}`, + {}, + throwOnFailure ) return raw ? normalizePullRequest(repo, repository.idOrName, repository.webBaseUrl, raw) : null } +/** + * Existing-review lookup that surfaces transport/auth failures instead of + * collapsing them to null, so a failed lookup becomes + * `reviewLookupOutcome: 'unavailable'` rather than a false "No pull request found". + */ +export function getAzureDevOpsPullRequestForBranchOrThrow( + repoPath: string, + branch: string, + linkedPRNumber?: number | null, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + return getAzureDevOpsPullRequestForBranch( + repoPath, + branch, + linkedPRNumber, + connectionId, + options, + true + ) +} + export async function getAzureDevOpsRepoSlug( repoPath: string, connectionId?: string | null, diff --git a/src/main/bitbucket/client.test.ts b/src/main/bitbucket/client.test.ts index 4e552ab59cd..7ecfeb64151 100644 --- a/src/main/bitbucket/client.test.ts +++ b/src/main/bitbucket/client.test.ts @@ -9,7 +9,11 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) -import { getBitbucketAuthStatus, getBitbucketPullRequestForBranch } from './client' +import { + getBitbucketAuthStatus, + getBitbucketPullRequestForBranch, + getBitbucketPullRequestForBranchOrThrow +} from './client' import { _resetBitbucketRepoRefCache } from './repository-ref' const OLD_ENV = process.env @@ -93,6 +97,19 @@ describe('Bitbucket client', () => { ) }) + it('getBitbucketPullRequestForBranchOrThrow surfaces a failure instead of null (finding 4)', async () => { + const fetchMock = vi.fn(async () => Response.json({ error: 'forbidden' }, { status: 403 })) + vi.stubGlobal('fetch', fetchMock) + + // The swallowing variant collapses a real failure into a false "no PR". + await expect(getBitbucketPullRequestForBranch('/repo', 'feature/bitbucket')).resolves.toBeNull() + // The throwing variant makes the failure visible so eligibility records + // `unavailable` rather than a false "No pull request found". + await expect( + getBitbucketPullRequestForBranchOrThrow('/repo', 'feature/bitbucket') + ).rejects.toThrow(/Bitbucket request failed/) + }) + it('falls back to a linked PR number when branch lookup misses', async () => { const fetchMock = vi.fn(async (url: string, _init?: RequestInit) => { if (url.includes('/statuses/build')) { diff --git a/src/main/bitbucket/client.ts b/src/main/bitbucket/client.ts index 6dbc11f65da..ae4158fa180 100644 --- a/src/main/bitbucket/client.ts +++ b/src/main/bitbucket/client.ts @@ -87,7 +87,14 @@ function apiUrl(path: string, searchParams?: RequestOptions['searchParams']): st return url.toString() } -async function requestJson(path: string, options: RequestOptions = {}): Promise { +async function requestJson( + path: string, + options: RequestOptions = {}, + // Why: the existing-review lookup behind Create must distinguish a real + // transport/auth failure from an accepted "no PR". When true, a failed request + // throws instead of collapsing to null so callers never report false not_found. + throwOnFailure = false +): Promise { const config = getAuthConfig() try { const response = await fetch(apiUrl(path, options.searchParams), { @@ -99,10 +106,16 @@ async function requestJson(path: string, options: RequestOptions = {}): Promi }) if (!response.ok) { await cancelUnreadResponseBody(response) + if (throwOnFailure) { + throw new Error(`Bitbucket request failed: HTTP ${response.status}`) + } return null } return (await response.json()) as T - } catch { + } catch (error) { + if (throwOnFailure) { + throw error + } return null } } @@ -184,7 +197,8 @@ export async function getBitbucketPullRequestForBranch( branch: string, linkedPRNumber?: number | null, connectionId?: string | null, - options: HostedReviewExecutionOptions = {} + options: HostedReviewExecutionOptions = {}, + throwOnFailure = false ): Promise { const branchName = branch.replace(/^refs\/heads\//, '') if (!branchName && linkedPRNumber == null) { @@ -214,7 +228,8 @@ export async function getBitbucketPullRequestForBranch( q: query, state: ALL_PULL_REQUEST_STATES } - } + }, + throwOnFailure ) const raw = list?.values?.[0] if (raw) { @@ -226,11 +241,36 @@ export async function getBitbucketPullRequestForBranch( return null } const raw = await requestJson( - `/repositories/${encodedRepoPath(repo)}/pullrequests/${encodeURIComponent(String(linkedPRNumber))}` + `/repositories/${encodedRepoPath(repo)}/pullrequests/${encodeURIComponent(String(linkedPRNumber))}`, + {}, + throwOnFailure ) return raw ? normalizePullRequest(repo, raw) : null } +/** + * Existing-review lookup that surfaces transport/auth failures instead of + * collapsing them to null. The hosted-review creation preflight uses this so a + * failed lookup becomes `reviewLookupOutcome: 'unavailable'`, never a false + * "No pull request found". + */ +export function getBitbucketPullRequestForBranchOrThrow( + repoPath: string, + branch: string, + linkedPRNumber?: number | null, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + return getBitbucketPullRequestForBranch( + repoPath, + branch, + linkedPRNumber, + connectionId, + options, + true + ) +} + export async function getBitbucketRepoSlug( repoPath: string, connectionId?: string | null, diff --git a/src/main/git/exec-error.ts b/src/main/git/exec-error.ts new file mode 100644 index 00000000000..fb44e54917a --- /dev/null +++ b/src/main/git/exec-error.ts @@ -0,0 +1,111 @@ +/** + * Pure helpers for reading diagnostics out of a subprocess (git/gh/glab) + * rejection. Kept in a standalone module (not `runner.ts`) so lightweight + * consumers — e.g. PR-refresh error classification — can import them without + * pulling in the runner's heavy execution machinery, and so tests that mock + * `./runner` still get the real implementations. + */ + +/** + * Extract stderr/stdout from an execFile rejection. + * + * Why: Node's execFile rejects with an Error that has `.stdout` and `.stderr` + * fields populated separately from `.message`. Reading `err.message` alone is + * unreliable — it can truncate stderr or omit it entirely depending on Node + * version and maxBuffer behavior. We prefer the explicit fields and fall + * back to `.message` only when neither is present. + */ +export function extractExecError(err: unknown): { stderr: string; stdout: string } { + if (err && typeof err === 'object') { + const e = err as { stderr?: unknown; stdout?: unknown; message?: unknown } + const stderr = + typeof e.stderr === 'string' + ? e.stderr + : Buffer.isBuffer(e.stderr) + ? e.stderr.toString('utf-8') + : '' + const stdout = + typeof e.stdout === 'string' + ? e.stdout + : Buffer.isBuffer(e.stdout) + ? e.stdout.toString('utf-8') + : '' + if (stderr || stdout) { + return { stderr, stdout } + } + if (typeof e.message === 'string') { + return { stderr: e.message, stdout: '' } + } + } + return { stderr: String(err), stdout: '' } +} + +/** + * Detect a Retry-After hint in gh stderr and return the suggested delay in ms, + * or null when the response includes no Retry-After. + * + * Why: gh forwards response headers when verbose, and prints "Retry-After: + * " in error output for primary rate-limit 429s. When present, the + * caller is better served by propagating the error so the UI can surface the + * real wait time — retrying on our own 250ms cadence just earns another 429 + * and burns the retry budget. Also supports HTTP-date Retry-After values. + */ +export function parseRetryAfterMs(stderr: string): number | null { + const raw = findRetryAfterHeaderValue(stderr) + if (raw === null) { + return null + } + if (/^\d+$/.test(raw)) { + const seconds = Number(raw) + return Number.isFinite(seconds) ? seconds * 1000 : null + } + const ts = Date.parse(raw) + if (Number.isNaN(ts)) { + return null + } + return Math.max(0, ts - Date.now()) +} + +function findRetryAfterHeaderValue(stderr: string): string | null { + const headerIndex = indexOfAsciiIgnoreCase(stderr, 'retry-after:', 0) + if (headerIndex === -1) { + return null + } + let valueStart = headerIndex + 'retry-after:'.length + while (valueStart < stderr.length) { + const code = stderr.charCodeAt(valueStart) + if (code !== 9 && code !== 32) { + break + } + valueStart++ + } + let valueEnd = valueStart + while (valueEnd < stderr.length) { + const code = stderr.charCodeAt(valueEnd) + if (code === 10 || code === 13) { + break + } + valueEnd++ + } + const value = stderr.slice(valueStart, valueEnd).trim() + return value.length > 0 ? value : null +} + +function indexOfAsciiIgnoreCase(value: string, search: string, fromIndex: number): number { + const lastStart = value.length - search.length + for (let index = Math.max(0, fromIndex); index <= lastStart; index++) { + let matches = true + for (let offset = 0; offset < search.length; offset++) { + const code = value.charCodeAt(index + offset) + const normalizedCode = code >= 65 && code <= 90 ? code + 32 : code + if (normalizedCode !== search.charCodeAt(offset)) { + matches = false + break + } + } + if (matches) { + return index + } + } + return -1 +} diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 27d1e5837b9..8c70421e3ef 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -42,6 +42,10 @@ import { } from '../../shared/wsl-login-shell-command' import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../../shared/git-output-locale' import { endSubprocessStdin } from '../../shared/subprocess-stdin-write' +// Re-exported for existing importers; lightweight consumers should import these +// from './exec-error' directly so they do not depend on this heavy module. +import { extractExecError, parseRetryAfterMs } from './exec-error' +export { extractExecError, parseRetryAfterMs } // ─── Core resolution ──────────────────────────────────────────────── @@ -1233,110 +1237,6 @@ function argsLookIdempotent(args: string[]): boolean { return true } -/** - * Extract stderr from an execFile rejection. - * - * Why: Node's execFile rejects with an Error that has `.stdout` and `.stderr` - * fields populated separately from `.message`. Reading `err.message` alone is - * unreliable — it can truncate stderr or omit it entirely depending on Node - * version and maxBuffer behavior. We prefer the explicit fields and fall - * back to `.message` only when neither is present. - */ -export function extractExecError(err: unknown): { stderr: string; stdout: string } { - if (err && typeof err === 'object') { - const e = err as { stderr?: unknown; stdout?: unknown; message?: unknown } - const stderr = - typeof e.stderr === 'string' - ? e.stderr - : Buffer.isBuffer(e.stderr) - ? e.stderr.toString('utf-8') - : '' - const stdout = - typeof e.stdout === 'string' - ? e.stdout - : Buffer.isBuffer(e.stdout) - ? e.stdout.toString('utf-8') - : '' - if (stderr || stdout) { - return { stderr, stdout } - } - if (typeof e.message === 'string') { - return { stderr: e.message, stdout: '' } - } - } - return { stderr: String(err), stdout: '' } -} - -/** - * Detect a Retry-After hint in gh stderr and return the suggested delay in ms, - * or null when the response includes no Retry-After. - * - * Why: gh forwards response headers when verbose, and prints "Retry-After: - * " in error output for primary rate-limit 429s. When present, the - * caller is better served by propagating the error so the UI can surface the - * real wait time — retrying on our own 250ms cadence just earns another 429 - * and burns the retry budget. Also supports HTTP-date Retry-After values. - */ -export function parseRetryAfterMs(stderr: string): number | null { - const raw = findRetryAfterHeaderValue(stderr) - if (raw === null) { - return null - } - if (/^\d+$/.test(raw)) { - const seconds = Number(raw) - return Number.isFinite(seconds) ? seconds * 1000 : null - } - const ts = Date.parse(raw) - if (Number.isNaN(ts)) { - return null - } - return Math.max(0, ts - Date.now()) -} - -function findRetryAfterHeaderValue(stderr: string): string | null { - const headerIndex = indexOfAsciiIgnoreCase(stderr, 'retry-after:', 0) - if (headerIndex === -1) { - return null - } - let valueStart = headerIndex + 'retry-after:'.length - while (valueStart < stderr.length) { - const code = stderr.charCodeAt(valueStart) - if (code !== 9 && code !== 32) { - break - } - valueStart++ - } - let valueEnd = valueStart - while (valueEnd < stderr.length) { - const code = stderr.charCodeAt(valueEnd) - if (code === 10 || code === 13) { - break - } - valueEnd++ - } - const value = stderr.slice(valueStart, valueEnd).trim() - return value.length > 0 ? value : null -} - -function indexOfAsciiIgnoreCase(value: string, search: string, fromIndex: number): number { - const lastStart = value.length - search.length - for (let index = Math.max(0, fromIndex); index <= lastStart; index++) { - let matches = true - for (let offset = 0; offset < search.length; offset++) { - const code = value.charCodeAt(index + offset) - const normalizedCode = code >= 65 && code <= 90 ? code + 32 : code - if (normalizedCode !== search.charCodeAt(offset)) { - matches = false - break - } - } - if (matches) { - return index - } - } - return -1 -} - /** * Classify whether a gh execFile rejection is worth retrying. * diff --git a/src/main/gitea/client.test.ts b/src/main/gitea/client.test.ts index a0ae0b3ce4f..60f3441263c 100644 --- a/src/main/gitea/client.test.ts +++ b/src/main/gitea/client.test.ts @@ -12,6 +12,7 @@ vi.mock('../git/runner', () => ({ import { getGiteaAuthStatus, getGiteaPullRequestForBranch, + getGiteaPullRequestForBranchOrThrow, normalizeGiteaApiBaseUrl } from './client' import { _resetGiteaRepoRefCache } from './repository-ref' @@ -172,6 +173,19 @@ describe('Gitea client', () => { } }) + it('surfaces a lookup failure via getGiteaPullRequestForBranchOrThrow instead of null (finding 4)', async () => { + const fetchMock = vi.fn(async () => Response.json({ message: 'unauthorized' }, { status: 403 })) + vi.stubGlobal('fetch', fetchMock) + + // The swallowing variant returns null — indistinguishable from "no PR". + await expect(getGiteaPullRequestForBranch('/repo', 'feature/gitea')).resolves.toBeNull() + // The throwing variant makes the failure visible so eligibility records + // `unavailable` rather than a false "No pull request found". + await expect(getGiteaPullRequestForBranchOrThrow('/repo', 'feature/gitea')).rejects.toThrow( + /Gitea request failed/ + ) + }) + it('expires successful scans and bounds retained repository listings', async () => { vi.useFakeTimers() try { diff --git a/src/main/gitea/client.ts b/src/main/gitea/client.ts index 9889598b612..da6c498d8b7 100644 --- a/src/main/gitea/client.ts +++ b/src/main/gitea/client.ts @@ -78,7 +78,11 @@ function apiUrl(baseUrl: string, path: string, searchParams?: RequestOptions['se async function requestJsonAtBase( baseUrl: string, path: string, - options: RequestOptions = {} + options: RequestOptions = {}, + // Why: the existing-review lookup behind Create must distinguish a real + // transport/auth failure from an accepted "no PR". When true, a failed request + // throws instead of collapsing to null so callers never report false not_found. + throwOnFailure = false ): Promise { const config = getAuthConfig() try { @@ -91,10 +95,16 @@ async function requestJsonAtBase( }) if (!response.ok) { await cancelUnreadResponseBody(response) + if (throwOnFailure) { + throw new Error(`Gitea request failed: HTTP ${response.status}`) + } return null } return (await response.json()) as T - } catch { + } catch (error) { + if (throwOnFailure) { + throw error + } return null } } @@ -102,9 +112,10 @@ async function requestJsonAtBase( function requestJson( repo: GiteaRepoRef, path: string, - options: RequestOptions = {} + options: RequestOptions = {}, + throwOnFailure = false ): Promise { - return requestJsonAtBase(configuredApiBaseUrl(repo), path, options) + return requestJsonAtBase(configuredApiBaseUrl(repo), path, options, throwOnFailure) } function encodedRepoPath(repo: GiteaRepoRef): string { @@ -227,7 +238,8 @@ export async function getGiteaPullRequestForBranch( branch: string, linkedPRNumber?: number | null, connectionId?: string | null, - options: HostedReviewExecutionOptions = {} + options: HostedReviewExecutionOptions = {}, + throwOnFailure = false ): Promise { const branchName = branch.replace(/^refs\/heads\//, '') if (!branchName && linkedPRNumber == null) { @@ -245,17 +257,25 @@ export async function getGiteaPullRequestForBranch( if (branchName) { const pullRequests = await scanGiteaPullRequests( - giteaPullRequestScanKey(repo), + // Why: throwing scans keep a separate cache namespace so they never reuse a + // prior swallowing scan's cached-empty from a failed page fetch, which would + // otherwise let a real lookup failure masquerade as "no PR". + throwOnFailure ? `${giteaPullRequestScanKey(repo)}::strict` : giteaPullRequestScanKey(repo), (page) => - requestJson(repo, `/repos/${encodedRepoPath(repo)}/pulls`, { - searchParams: { - state: 'all', - sort: 'recentupdate', - page, - limit: PULL_REQUEST_PAGE_LIMIT + requestJson( + repo, + `/repos/${encodedRepoPath(repo)}/pulls`, + { + searchParams: { + state: 'all', + sort: 'recentupdate', + page, + limit: PULL_REQUEST_PAGE_LIMIT + }, + timeoutMs: PULL_REQUEST_LIST_TIMEOUT_MS }, - timeoutMs: PULL_REQUEST_LIST_TIMEOUT_MS - }), + throwOnFailure + ), PULL_REQUEST_PAGE_LIMIT, MAX_PULL_REQUEST_PAGES ) @@ -270,11 +290,28 @@ export async function getGiteaPullRequestForBranch( } const raw = await requestJson( repo, - `/repos/${encodedRepoPath(repo)}/pulls/${encodeURIComponent(String(linkedPRNumber))}` + `/repos/${encodedRepoPath(repo)}/pulls/${encodeURIComponent(String(linkedPRNumber))}`, + {}, + throwOnFailure ) return raw ? normalizePullRequest(repo, raw) : null } +/** + * Existing-review lookup that surfaces transport/auth failures instead of + * collapsing them to null, so a failed lookup becomes + * `reviewLookupOutcome: 'unavailable'` rather than a false "No pull request found". + */ +export function getGiteaPullRequestForBranchOrThrow( + repoPath: string, + branch: string, + linkedPRNumber?: number | null, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + return getGiteaPullRequestForBranch(repoPath, branch, linkedPRNumber, connectionId, options, true) +} + export async function getGiteaRepoSlug( repoPath: string, connectionId?: string | null, diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index d403d582fcd..07a007f7f1e 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -1268,6 +1268,34 @@ describe('getPRForBranch', () => { ) }) + it('propagates a Retry-After cooldown into the rate-limited retry schedule', async () => { + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [{ owner: 'stablyai', repo: 'orca' }], + headRepo: null + }) + // gh puts the diagnostic on `.stderr`; a secondary limit carries Retry-After. + ghExecFileAsyncMock + .mockRejectedValueOnce( + Object.assign(new Error('gh exited with 1.'), { + stderr: 'HTTP 403: You have exceeded a secondary rate limit\nRetry-After: 120' + }) + ) + .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) + + const before = Date.now() + const outcome = await getPRForBranchOutcome('/repo-root', 'feature/test') + expect(outcome.kind).toBe('upstream-error') + if (outcome.kind !== 'upstream-error') { + throw new Error('expected upstream-error') + } + expect(outcome.errorType).toBe('rate_limited') + // ~120s cooldown surfaced as both the manual gate and the auto-retry time. + expect(outcome.retryDisabledUntil).toBeDefined() + expect(outcome.nextAutoRetryAt).toBe(outcome.retryDisabledUntil) + expect(outcome.retryDisabledUntil ?? 0).toBeGreaterThanOrEqual(before + 119_000) + expect(outcome.retryDisabledUntil ?? 0).toBeLessThanOrEqual(Date.now() + 121_000) + }) + it('reports no PR when fallback branch discovery cleanly misses', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [{ owner: 'stablyai', repo: 'orca' }], diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 67d34bef4ba..d9d41f4f653 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -39,6 +39,10 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { sliceCheckLogTail } from './check-job-log-tail-slice' +import { + classifyPRRefreshError, + safePRRefreshErrorMessage +} from './pr-refresh-error-classification' import { getPRConflictSummary } from './conflict-summary' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' @@ -47,7 +51,6 @@ import { execFileAsync, ghExecFileAsync, gitExecFileAsync, - extractExecError, acquire, release, getOwnerRepo, @@ -63,6 +66,9 @@ import { type LocalGitExecOptions, type OwnerRepo } from './gh-utils' +// Import the pure error-parsing helpers from the lightweight module, not +// `./gh-utils`, so tests that mock gh-utils still resolve the real functions. +import { extractExecError, parseRetryAfterMs } from '../git/exec-error' import { isCommitPartOfMergedPR, type MergedPRCommitMembership @@ -181,61 +187,28 @@ async function assertRateLimitBudget(bucket: RateLimitBucketKind): Promise } } -function classifyPRRefreshError( - err: unknown -): Extract['errorType'] { - const message = err instanceof Error ? err.message : String(err) - // Why: GitHub-unreachable buckets (5xx outage / network / rate limit) share - // one detector with the Tasks work-item path so both surfaces attribute an - // outage to GitHub identically. Rate-limit responses also carry "HTTP 403", - // so this must run before the permission check below. - const unavailable = classifyGitHubUnavailable(message) - if (unavailable) { - return unavailable - } - const lower = message.toLowerCase() - if (lower.includes('http 403') || lower.includes('resource not accessible')) { - return 'permission' - } - if (lower.includes('http 404') || lower.includes('could not resolve to a repository')) { - return 'repo_unavailable' - } - return /auth|login|credential/i.test(message) ? 'auth' : 'unknown' -} - -function safePRRefreshErrorMessage( - errorType: Extract['errorType'] -): string { - switch (errorType) { - case 'rate_limited': - return 'GitHub rate limit is low. Try again after the limit resets.' - case 'auth': - return 'GitHub authentication is unavailable. Check your gh login.' - case 'network': - return 'GitHub is unreachable right now. Check your network and try again.' - case 'server_error': - return "GitHub's API is temporarily unavailable (server error). This is a GitHub-side issue." - case 'permission': - return 'GitHub did not allow access to this pull request.' - case 'repo_unavailable': - return 'The GitHub repository is unavailable or cannot be resolved.' - case 'gh_unavailable': - return 'GitHub CLI is unavailable.' - case 'unknown': - return 'GitHub pull request refresh failed.' - } -} - function prRefreshUpstreamError( err: unknown ): Extract { const errorType = classifyPRRefreshError(err) - return { + const outcome: Extract = { kind: 'upstream-error', errorType, message: safePRRefreshErrorMessage(errorType), fetchedAt: Date.now() } + // Why: a rate limit that carries a Retry-After is a real cooldown — surface it + // as the retry schedule so the renderer disables manual Retry until the reset + // and shows a truthful auto-retry time, instead of retrying into another 429. + if (errorType === 'rate_limited') { + const retryAfterMs = parseRetryAfterMs(extractExecError(err).stderr) + if (retryAfterMs !== null && retryAfterMs > 0) { + const retryAt = Date.now() + retryAfterMs + outcome.nextAutoRetryAt = retryAt + outcome.retryDisabledUntil = retryAt + } + } + return outcome } function isNoPullRequestError(err: unknown): boolean { diff --git a/src/main/github/gh-utils.ts b/src/main/github/gh-utils.ts index 38b3d23cf30..b294e90ac1a 100644 --- a/src/main/github/gh-utils.ts +++ b/src/main/github/gh-utils.ts @@ -1,11 +1,14 @@ import { execFile } from 'node:child_process' import { promisify } from 'node:util' -import { gitExecFileAsync, ghExecFileAsync, extractExecError } from '../git/runner' +import { gitExecFileAsync, ghExecFileAsync } from '../git/runner' +// Pure error-parsing helpers come from the lightweight module (not `runner`) so +// tests that mock `../git/runner` still resolve the real implementations. +import { extractExecError, parseRetryAfterMs } from '../git/exec-error' // Why: legacy generic execFile wrapper - only used by callers that don't need // WSL-aware routing. Repo-scoped callers should use the runner exports below. export const execFileAsync = promisify(execFile) -export { ghExecFileAsync, gitExecFileAsync, extractExecError } +export { ghExecFileAsync, gitExecFileAsync, extractExecError, parseRetryAfterMs } export { classifyGhError, classifyListIssuesError } from './gh-error-classification' export { _getOwnerRepoCacheSize, diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index bd59fc77cf2..700cc32ecd2 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -83,7 +83,30 @@ const queue = new Map() const backgroundStarts: number[] = [] const activeStartsByScope = new Map() const errorBackoff = new Map() +// Per-key manual-retry cooldown from a real secondary rate limit (Retry-After). +// refreshPRNow refuses a manual retry until this time so a stale renderer cannot +// bypass the gate the client advertised via `retryDisabledUntil`. +const manualRetryGates = new Map() let lastBackgroundStartAt = 0 + +/** + * Track (or clear) the manual-retry cooldown for a key from a broadcast outcome. + * Only a rate-limit outcome carrying `retryDisabledUntil` sets a gate; any other + * settled outcome clears it. + */ +function noteManualRetryGate(key: string, outcome: PRRefreshOutcome): void { + if (outcome.kind === 'upstream-error' && outcome.retryDisabledUntil !== undefined) { + manualRetryGates.set(key, outcome.retryDisabledUntil) + } else { + manualRetryGates.delete(key) + } +} + +/** Reset all per-key retry state (backoff + manual cooldown) when a key resets. */ +function resetKeyRetryState(key: string): void { + errorBackoff.delete(key) + manualRetryGates.delete(key) +} const visibleByWindow = new Map }>() let outcomeObserver: PRRefreshOutcomeObserver | null = null const diagnosticsCounters = { @@ -101,7 +124,7 @@ function removeInvisibleVisibleRefreshes(): void { for (const [key, entry] of queue) { if (entry.reason === 'visible' && !isVisibleKey(key)) { queue.delete(key) - errorBackoff.delete(key) + resetKeyRetryState(key) broadcast({ aliases: Array.from(entry.aliases.values()), reason: 'visible', @@ -177,7 +200,7 @@ export function pruneWorktreePRRefreshAliases(worktreeId: string): void { // No aliases left means no worktree still cares about this refresh. if (entry.aliases.size === 0) { queue.delete(key) - errorBackoff.delete(key) + resetKeyRetryState(key) continue } // Keep the entry alive but ensure its representative candidate is not a @@ -385,7 +408,7 @@ function removeQueuedAliasForInvalidCandidate(key: string, alias: GitHubPRRefres const replacementAlias = existing.aliases.values().next().value if (!replacementAlias) { queue.delete(key) - errorBackoff.delete(key) + resetKeyRetryState(key) return } @@ -405,6 +428,44 @@ function removeQueuedAliasForInvalidCandidate(key: string, alias: GitHubPRRefres } } +/** + * Advances the visible-key error backoff and returns the earliest retry time. + * The renderer surfaces this as `nextAutoRetryAt`; only visible keys auto-retry, + * so callers must gate on `isVisibleKey` before computing a schedule. + */ +function nextVisibleErrorRetryAt(key: string): number { + const failures = (errorBackoff.get(key)?.failures ?? 0) + 1 + const retryAt = + Date.now() + Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.min(failures - 1, 4)) + errorBackoff.set(key, { failures, retryAt }) + return retryAt +} + +/** + * Stamps the auto-retry time onto an error outcome before broadcast so every + * alias receives identical timing. + * + * Why: this only sets `nextAutoRetryAt` (the "Orca will retry at {time}" hint). + * It must NOT set `retryDisabledUntil` — that field disables *manual* Retry and + * is reserved for a real rate-limit gate (the paused/breaker path in + * `refreshPRNow`, which maps `pausedUntil` into it). Deriving it from ordinary + * exponential backoff would disable a manual Retry that `refreshPRNow` would in + * fact accept. + */ +function withErrorSchedule(outcome: PRRefreshOutcome, retryAt: number): PRRefreshOutcome { + if (outcome.kind !== 'upstream-error') { + return outcome + } + // Why: preserve a real Retry-After cooldown the client already stamped on the + // outcome (`retryDisabledUntil`); auto-retry no earlier than both the backoff + // and that cooldown so we do not retry into an active secondary rate limit. + const cooldownUntil = outcome.retryDisabledUntil + return { + ...outcome, + nextAutoRetryAt: cooldownUntil !== undefined ? Math.max(retryAt, cooldownUntil) : retryAt + } +} + function scheduleVisibleFollowUp( key: string, candidate: GitHubPRRefreshCandidate, @@ -412,19 +473,18 @@ function scheduleVisibleFollowUp( priority: number, aliases: GitHubPRRefreshAlias[], windowId?: number, - options?: { pendingMergeabilityDelayMs?: number } + options?: { pendingMergeabilityDelayMs?: number; plannedRetryAt?: number } ): void { if (!isVisibleKey(key)) { // Why: manual/active refreshes can remove the queued visible retry after // its owner window is gone, leaving the retry backoff without an owner. - errorBackoff.delete(key) + resetKeyRetryState(key) return } if (outcome.kind === 'upstream-error') { - const failures = (errorBackoff.get(key)?.failures ?? 0) + 1 - const retryAt = - Date.now() + Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.min(failures - 1, 4)) - errorBackoff.set(key, { failures, retryAt }) + // Why: reuse the retry time already computed for the broadcast schedule so + // the same failure is not counted twice against the backoff. + const retryAt = options?.plannedRetryAt ?? nextVisibleErrorRetryAt(key) setVisibleFollowUp({ key, candidate, @@ -440,7 +500,7 @@ function scheduleVisibleFollowUp( scheduleDrain(retryAt - Date.now()) return } - errorBackoff.delete(key) + resetKeyRetryState(key) const followUpCandidate = visibleCandidateAfterOutcome(candidate, outcome) const regularDueAt = freshRetryAt(followUpCandidate) ?? Date.now() const pendingMergeabilityDueAt = @@ -693,7 +753,7 @@ async function drainQueue(): Promise { continue } if (next.reason === 'visible' && !isVisibleKey(next.key)) { - errorBackoff.delete(next.key) + resetKeyRetryState(next.key) broadcast({ aliases, reason: next.reason, status: 'skipped', skippedReason: 'fresh' }) continue } @@ -748,15 +808,30 @@ async function drainQueue(): Promise { next.candidate.linkedPRNumber == null ? (next.candidate.fallbackPRNumber ?? null) : null, ...hostedReviewOptionArgs(next.candidate) ) + // Why: compute the retry schedule before broadcasting so the outcome and + // its follow-up retry share one timing; only visible keys auto-retry. + let plannedRetryAt: number | undefined + let broadcastOutcome = outcome + if (outcome.kind === 'upstream-error' && isVisibleKey(next.key)) { + plannedRetryAt = nextVisibleErrorRetryAt(next.key) + broadcastOutcome = withErrorSchedule(outcome, plannedRetryAt) + } outcomeObserver?.(next.candidate, outcome) - broadcast({ aliases, reason: next.reason, outcome, requestStartedAt }, requestSequence) + noteManualRetryGate(next.key, broadcastOutcome) + broadcast( + { aliases, reason: next.reason, outcome: broadcastOutcome, requestStartedAt }, + requestSequence + ) scheduleVisibleFollowUp( next.key, next.candidate, outcome, next.priority, aliases, - next.windowId + next.windowId, + { + plannedRetryAt + } ) } } finally { @@ -893,6 +968,53 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise return outcome } + // Why: enforce the same rate-limit gate main advertises via `retryDisabledUntil` + // so a stale renderer cannot bypass it. A blocked primary bucket means gh would + // 403 anyway; a recorded secondary Retry-After cooldown means gh would 429 + // again. Refuse without spending quota until the later of the two and report + // the pause honestly. + const manualBlockedGuard = backgroundRefreshBuckets() + .map((bucket) => rateLimitGuard(bucket)) + .find((guard) => guard.blocked) + const secondaryGateUntil = manualRetryGates.get(key) + const gateUntil = Math.max( + manualBlockedGuard?.blocked ? manualBlockedGuard.resetAt * 1000 : 0, + secondaryGateUntil !== undefined ? secondaryGateUntil : 0 + ) + if (gateUntil > Date.now()) { + const retryAt = gateUntil + // Why: the paused broadcast maps `pausedUntil` into the renderer's + // `nextAutoRetryAt` ("Orca will retry at {time}"), so that auto-retry must be + // real. Requeue this candidate at the reset time and wake the drain then — + // mirroring the background paused path — instead of advertising an automatic + // retry that was never scheduled (finding 12). + queue.set(key, { + key, + candidate, + aliases: aliasMap, + reason: 'manual', + priority: 40, + dueAt: retryAt, + queuedAt: nextQueueOrder() + }) + broadcast({ + aliases, + reason: 'manual', + status: 'paused', + pausedUntil: retryAt, + skippedReason: 'rate-limit' + }) + scheduleDrain(Math.max(1_000, retryAt - Date.now())) + return { + kind: 'upstream-error', + errorType: 'rate_limited', + message: 'GitHub is temporarily limiting requests. Try again after the limit resets.', + fetchedAt: Date.now(), + nextAutoRetryAt: retryAt, + retryDisabledUntil: retryAt + } + } + queue.delete(key) const requestSequence = nextSequence() const requestStartedAt = Date.now() @@ -905,12 +1027,23 @@ export async function refreshPRNow(candidate: GitHubPRRefreshCandidate): Promise candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null, ...hostedReviewOptionArgs(candidate) ) + let plannedRetryAt: number | undefined + let broadcastOutcome = outcome + if (outcome.kind === 'upstream-error' && isVisibleKey(key)) { + plannedRetryAt = nextVisibleErrorRetryAt(key) + broadcastOutcome = withErrorSchedule(outcome, plannedRetryAt) + } outcomeObserver?.(candidate, outcome) - broadcast({ aliases, reason: 'manual', outcome, requestStartedAt }, requestSequence) + noteManualRetryGate(key, broadcastOutcome) + broadcast( + { aliases, reason: 'manual', outcome: broadcastOutcome, requestStartedAt }, + requestSequence + ) scheduleVisibleFollowUp(key, candidate, outcome, 40, aliases, undefined, { + plannedRetryAt, // Why: GitHub often reports UNKNOWN immediately after `gh pr reopen`; // do one prompt visible retry so conflicts replace the transient label. pendingMergeabilityDelayMs: MANUAL_MERGEABILITY_PENDING_REFRESH_MS }) - return outcome + return broadcastOutcome } diff --git a/src/main/github/pr-refresh-error-classification.test.ts b/src/main/github/pr-refresh-error-classification.test.ts new file mode 100644 index 00000000000..549fc10fdc8 --- /dev/null +++ b/src/main/github/pr-refresh-error-classification.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' +import { + classifyPRRefreshError, + safePRRefreshErrorMessage +} from './pr-refresh-error-classification' + +describe('classifyPRRefreshError', () => { + it('classifies an HTTP 429 as rate_limited even without a rate-limit body', () => { + expect(classifyPRRefreshError(new Error('HTTP 429 Too Many Requests'))).toBe('rate_limited') + }) + + it('classifies secondary rate limit markers as rate_limited, not permission', () => { + for (const message of [ + 'You have exceeded a secondary rate limit', + 'abuse detection mechanism triggered', + 'abuse-rate-limits', + 'you have triggered an abuse detection mechanism' + ]) { + expect(classifyPRRefreshError(new Error(message))).toBe('rate_limited') + } + }) + + it('classifies a 403 carrying Retry-After as rate_limited, not permission', () => { + expect(classifyPRRefreshError(new Error('HTTP 403 Forbidden; Retry-After: 60'))).toBe( + 'rate_limited' + ) + }) + + it('classifies the primary breaker language as rate_limited', () => { + expect(classifyPRRefreshError(new Error('API rate limit exceeded for user'))).toBe( + 'rate_limited' + ) + }) + + it('classifies a plain 403 resource denial as permission', () => { + expect( + classifyPRRefreshError(new Error('HTTP 403: Resource not accessible by integration')) + ).toBe('permission') + }) + + it('classifies network failures', () => { + for (const message of ['ETIMEDOUT', 'could not resolve host github.com', 'network is down']) { + expect(classifyPRRefreshError(new Error(message))).toBe('network') + } + }) + + it('classifies 404 / could not resolve repository as repo_unavailable', () => { + expect(classifyPRRefreshError(new Error('HTTP 404 Not Found'))).toBe('repo_unavailable') + expect( + classifyPRRefreshError(new Error('Could not resolve to a Repository with the name')) + ).toBe('repo_unavailable') + }) + + it('classifies an ENOENT spawn failure as gh_unavailable', () => { + const err = Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }) + expect(classifyPRRefreshError(err)).toBe('gh_unavailable') + expect(classifyPRRefreshError(new Error("'gh' is not recognized as an internal command"))).toBe( + 'gh_unavailable' + ) + }) + + it('classifies auth failures after rate-limit and permission checks', () => { + expect(classifyPRRefreshError(new Error('authentication failed: bad credentials'))).toBe('auth') + }) + + it('falls back to unknown', () => { + expect(classifyPRRefreshError(new Error('something unexpected happened'))).toBe('unknown') + }) + + it('does not classify a message that merely contains "author" as auth', () => { + for (const message of [ + 'PR author octocat has no write access to the fork', + 'authored 3 commits, none pushed', + 'unexpected failure from author service' + ]) { + expect(classifyPRRefreshError(new Error(message))).toBe('unknown') + } + }) + + it('does not classify a repository name containing "network" as a network failure', () => { + // A repo/branch called "network-*" is not a connectivity failure; without a + // structured code or a real connectivity phrase this must stay unknown. + expect(classifyPRRefreshError(new Error('operation failed for repo acme/network-tools'))).toBe( + 'unknown' + ) + }) + + it('classifies a 404 repository error even when the message mentions network', () => { + // 404 ranks before the network branch so the repo error is not misread. + expect( + classifyPRRefreshError( + new Error('Could not resolve to a Repository named acme/network-proxy (HTTP 404)') + ) + ).toBe('repo_unavailable') + }) + + it('classifies a 401 as auth', () => { + expect(classifyPRRefreshError(new Error('HTTP 401 Unauthorized'))).toBe('auth') + }) + + it('classifies from the real runner error shape where the signal is on .stderr', () => { + // Why: the runner rejects with a generic message and puts the gh diagnostic + // on `.stderr` (string or Buffer). Reading `.message` alone would misclassify + // these hard errors as `unknown` and treat them as transient. + const permission = Object.assign(new Error('gh exited with 1.'), { + stderr: 'HTTP 403: Resource not accessible by integration' + }) + expect(classifyPRRefreshError(permission)).toBe('permission') + + const repo = Object.assign(new Error('gh exited with 1.'), { + stderr: Buffer.from('HTTP 404: Not Found') + }) + expect(classifyPRRefreshError(repo)).toBe('repo_unavailable') + + const secondary = Object.assign(new Error('gh exited with 1.'), { + stderr: 'You have exceeded a secondary rate limit' + }) + expect(classifyPRRefreshError(secondary)).toBe('rate_limited') + + const auth = Object.assign(new Error('gh exited with 1.'), { + stdout: '', + stderr: 'gh auth login required: bad credentials' + }) + expect(classifyPRRefreshError(auth)).toBe('auth') + }) +}) + +describe('safePRRefreshErrorMessage', () => { + it('returns non-empty copy for every classified type without leaking raw errors', () => { + for (const type of [ + 'rate_limited', + 'auth', + 'network', + 'permission', + 'repo_unavailable', + 'gh_unavailable', + 'unknown' + ] as const) { + const message = safePRRefreshErrorMessage(type) + expect(message.length).toBeGreaterThan(0) + expect(message).not.toContain('ENOENT') + } + }) +}) diff --git a/src/main/github/pr-refresh-error-classification.ts b/src/main/github/pr-refresh-error-classification.ts new file mode 100644 index 00000000000..aa9e226939d --- /dev/null +++ b/src/main/github/pr-refresh-error-classification.ts @@ -0,0 +1,144 @@ +import type { PRRefreshErrorType } from '../../shared/types' +import { classifyGitHubUnavailable } from '../../shared/github-api-availability' +import { extractExecError } from '../git/exec-error' + +/** + * Sanitization boundary for GitHub PR-refresh failures. Maps a raw runner/CLI + * error to a stable {@link PRRefreshErrorType}; the renderer turns that into + * classified copy so raw stderr / env values never reach the UI. + * + * Classification order (see docs/reference/pr-panel-refresh-guidance.md): + * HTTP 429 / secondary rate limit → primary rate limit → repo_unavailable (404) → + * network → permission (403) → gh_unavailable → auth → unknown. GitHub returns + * 403 OR 429 for both primary and secondary limits, so the http-403 permission + * branch must run only after the rate-limit checks. 404 is matched before the + * network branch so a repository error whose message happens to contain a + * substring like "network" is not misread as a connectivity failure. Auth and + * network detection avoid broad substrings (e.g. "auth" inside "author", or a + * repo name containing "network") in favor of structured codes and full phrases. + */ +export function classifyPRRefreshError(err: unknown): PRRefreshErrorType { + // Why: the git/gh runner keeps diagnostics on `.stderr`/`.stdout` separate + // from `.message` (which can be just "gh exited with 1."). Reading message + // alone would misclassify a real "HTTP 403" / "HTTP 404" as `unknown`, so a + // hard error would be treated as transient. Combine all three for substring + // detection while still preferring the structured spawn `code`. + const { stderr, stdout } = extractExecError(err) + const message = err instanceof Error ? err.message : String(err) + const lower = `${message}\n${stderr}\n${stdout}`.toLowerCase() + const code = + err && typeof err === 'object' && 'code' in err + ? String((err as { code?: unknown }).code ?? '').toLowerCase() + : '' + + // Rate limits first: a 429 is always a rate-limit signal; secondary limits also + // arrive as abuse-mechanism phrasing or a 403/429 carrying Retry-After, none of + // which contain "rate limit". + const isHttp429 = lower.includes('http 429') || lower.includes('429 too many requests') + const isHttp403 = lower.includes('http 403') + const hasRetryAfter = lower.includes('retry-after') + if ( + isHttp429 || + lower.includes('secondary rate limit') || + lower.includes('abuse detection') || + lower.includes('abuse-rate-limits') || + lower.includes('you have triggered an abuse') || + ((isHttp403 || isHttp429) && hasRetryAfter) || + lower.includes('api rate limit exceeded') || + lower.includes('rate limit') + ) { + return 'rate_limited' + } + // Repository resolution failures (404) rank before the network branch: a "could + // not resolve to a Repository" message must not be captured by a connectivity + // heuristic, and matching 404 first isolates it from any incidental substring. + if (lower.includes('http 404') || lower.includes('could not resolve to a repository')) { + return 'repo_unavailable' + } + // Why: keep GitHub 5xx/network attribution aligned with other GitHub surfaces. + const unavailable = classifyGitHubUnavailable(lower) + if (unavailable) { + return unavailable + } + // Network: structured error codes and full connectivity phrases only. Never a + // bare "network" substring — a repo/branch/message containing "network" is not + // evidence of a connectivity failure. + const networkCodes = new Set([ + 'etimedout', + 'econnreset', + 'econnrefused', + 'enotfound', + 'eai_again', + 'enetunreach', + 'enetdown' + ]) + if ( + networkCodes.has(code) || + lower.includes('etimedout') || + lower.includes('econnreset') || + lower.includes('econnrefused') || + lower.includes('enotfound') || + lower.includes('eai_again') || + lower.includes('timed out') || + lower.includes('timeout') || + lower.includes('could not resolve host') || + lower.includes('no such host') || + lower.includes('network is unreachable') || + lower.includes('network is down') || + lower.includes('network error') || + lower.includes('connection refused') || + lower.includes('connection reset') + ) { + return 'network' + } + if (isHttp403 || lower.includes('resource not accessible')) { + return 'permission' + } + // gh CLI launch failure: prefer the structured spawn error code over a broad + // substring so a repo path merely containing "gh" is not misclassified. + if ( + code === 'enoent' || + lower.includes('spawn gh enoent') || + lower.includes('gh: command not found') || + lower.includes("'gh' is not recognized") + ) { + return 'gh_unavailable' + } + // Auth last: match full auth phrases and a 401, never a bare "auth" substring + // that also fires on "author"/"authored". "unauthorized"/"authorization" are + // long enough not to collide with "author". + if ( + lower.includes('http 401') || + lower.includes('unauthorized') || + lower.includes('authorization') || + lower.includes('authentication') || + lower.includes('bad credentials') || + lower.includes('gh auth') || + /\b(login|credentials?)\b/i.test(lower) + ) { + return 'auth' + } + return 'unknown' +} + +/** Stable, non-destructive fallback message for a classified refresh error. */ +export function safePRRefreshErrorMessage(errorType: PRRefreshErrorType): string { + switch (errorType) { + case 'rate_limited': + return 'GitHub rate limit is low. Try again after the limit resets.' + case 'auth': + return 'GitHub authentication is unavailable. Check your gh login.' + case 'network': + return 'GitHub is unreachable right now. Check your network and try again.' + case 'server_error': + return "GitHub's API is temporarily unavailable (server error). This is a GitHub-side issue." + case 'permission': + return 'GitHub did not allow access to this pull request.' + case 'repo_unavailable': + return 'The GitHub repository is unavailable or cannot be resolved.' + case 'gh_unavailable': + return 'GitHub CLI is unavailable.' + case 'unknown': + return 'GitHub pull request refresh failed.' + } +} diff --git a/src/main/gitlab/client-mr.test.ts b/src/main/gitlab/client-mr.test.ts index a090ec7963b..aac5193c1e3 100644 --- a/src/main/gitlab/client-mr.test.ts +++ b/src/main/gitlab/client-mr.test.ts @@ -41,6 +41,7 @@ import { addMRComment, getMergeRequest, getMergeRequestForBranch, + getMergeRequestForBranchOrThrow, getJobTrace, addMRInlineComment, closeMR, @@ -74,6 +75,19 @@ describe('gitlab client — MR operations', () => { }) }) + it('getMergeRequestForBranchOrThrow surfaces a glab failure instead of null (finding 4)', async () => { + getProjectRefMock.mockResolvedValue({ host: 'gitlab.com', path: 'g/p' }) + glabExecFileAsyncMock.mockRejectedValue(new Error('glab: connection refused')) + + // The swallowing variant collapses a real failure into a false "no MR". + await expect(getMergeRequestForBranch('/repo', 'feature/x')).resolves.toBeNull() + // The throwing variant makes the failure visible so eligibility records + // `unavailable` rather than a false "No merge request found". + await expect(getMergeRequestForBranchOrThrow('/repo', 'feature/x')).rejects.toThrow( + /connection refused/ + ) + }) + it('routes local WSL MR review-management and job actions through project resolution and glab options', async () => { const localGitOptions = { wslDistro: 'Ubuntu' } glabExecFileAsyncMock diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts index b6b32dfb8c3..370553db9e1 100644 --- a/src/main/gitlab/client.ts +++ b/src/main/gitlab/client.ts @@ -313,7 +313,11 @@ export async function getMergeRequestForBranch( branch: string, linkedMRIid?: number | null, connectionId?: string | null, - options: HostedReviewExecutionOptions = {} + options: HostedReviewExecutionOptions = {}, + // Why: the existing-review lookup behind Create must distinguish a real glab + // transport/auth failure from an accepted "no MR". When true, a failed lookup + // throws instead of collapsing to null so callers never report false not_found. + throwOnFailure = false ): Promise { const branchName = branch.replace(/^refs\/heads\//, '') if (!branchName && linkedMRIid == null) { @@ -369,13 +373,31 @@ export async function getMergeRequestForBranch( } const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null) return mapMRInfo(raw, pipelineStatus) - } catch { + } catch (error) { + if (throwOnFailure) { + throw error + } return null } finally { release() } } +/** + * Existing-review lookup that surfaces glab transport/auth failures instead of + * collapsing them to null, so a failed lookup becomes + * `reviewLookupOutcome: 'unavailable'` rather than a false "No merge request found". + */ +export function getMergeRequestForBranchOrThrow( + repoPath: string, + branch: string, + linkedMRIid?: number | null, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + return getMergeRequestForBranch(repoPath, branch, linkedMRIid, connectionId, options, true) +} + function mrListStateFlags(state: MRListState): string[] { switch (state) { case 'opened': diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 9e194212d35..a3f6e0b2cc0 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -193,6 +193,7 @@ const { getHostedReviewCreationEligibilityMock, getHostedReviewForBranchMock, getPRForBranchMock, + getPRForBranchOutcomeMock, getRepoSlugMock, getRepoUpstreamMock, getGitHubWorkItemMock, @@ -298,6 +299,7 @@ const { getHostedReviewCreationEligibilityMock: vi.fn(), getHostedReviewForBranchMock: vi.fn(), getPRForBranchMock: vi.fn().mockResolvedValue(null), + getPRForBranchOutcomeMock: vi.fn().mockResolvedValue({ kind: 'no-pr', fetchedAt: 0 }), getRepoSlugMock: vi.fn().mockResolvedValue(null), getRepoUpstreamMock: vi.fn().mockResolvedValue(null), getGitHubWorkItemMock: vi.fn(), @@ -463,6 +465,7 @@ vi.mock('../github/client', async (importOriginal) => { return { ...actual, getPRForBranch: getPRForBranchMock, + getPRForBranchOutcome: getPRForBranchOutcomeMock, getRepoSlug: getRepoSlugMock, getRepoUpstream: getRepoUpstreamMock, getWorkItem: getGitHubWorkItemMock, @@ -671,6 +674,8 @@ function resetRuntimeTestMocks(): void { getHostedReviewForBranchMock.mockResolvedValue(null) getPRForBranchMock.mockReset() getPRForBranchMock.mockResolvedValue(null) + getPRForBranchOutcomeMock.mockReset() + getPRForBranchOutcomeMock.mockResolvedValue({ kind: 'no-pr', fetchedAt: 0 }) getRepoSlugMock.mockReset() getRepoSlugMock.mockResolvedValue(null) getRepoUpstreamMock.mockReset() @@ -5572,9 +5577,16 @@ describe('OrcaRuntimeService', () => { prRepo }) - expect(getPRForBranchMock).toHaveBeenCalledWith(TEST_REPO_PATH, 'feature/wsl', 42, null, null, { - localGitExecOptions: localGitOptions - }) + expect(getPRForBranchOutcomeMock).toHaveBeenCalledWith( + TEST_REPO_PATH, + 'feature/wsl', + 42, + null, + null, + { + localGitExecOptions: localGitOptions + } + ) expect(getGitHubWorkItemMock).toHaveBeenCalledWith( TEST_REPO_PATH, 42, diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 418809d2f57..c255a65863c 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -378,6 +378,7 @@ import type { BrowserBackend } from '../browser/browser-backend' import { BrowserError } from '../browser/cdp-bridge' import { getPRForBranch, + getPRForBranchOutcome, getRepoSlug, getRepoUpstream, getWorkItem, @@ -456,7 +457,8 @@ import type { GitLabProjectRef, GitLabWorkItem, ListWorkItemsResult, - MRListState + MRListState, + PRRefreshOutcome } from '../../shared/types' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' import type { @@ -13977,7 +13979,7 @@ export class OrcaRuntimeService { fallbackPRNumber?: number | null, acceptMergedFallbackPR?: boolean, currentHeadOid?: string | null - ): Promise>> { + ): Promise { const repo = await this.resolveRepoSelector(repoSelector) const options: GitHubPRBranchLookupOptions = this.getHostedReviewExecutionOptions(repo) ?? {} const lookupOptions = { ...options } @@ -13989,7 +13991,11 @@ export class OrcaRuntimeService { } const lookupOptionArgs: [] | [GitHubPRBranchLookupOptions] = Object.keys(lookupOptions).length > 0 ? [lookupOptions] : [] - return getPRForBranch( + // Why: return the full classified outcome (not PRInfo|null) so a runtime gh + // auth/network failure crosses the RPC as `upstream-error` instead of + // collapsing to `null`, which the renderer would otherwise cache as a false + // accepted "no PR found" (design success criterion 1). + return getPRForBranchOutcome( repo.path, branch, linkedPRNumber ?? null, diff --git a/src/main/runtime/rpc/methods/hosted-review.test.ts b/src/main/runtime/rpc/methods/hosted-review.test.ts index 5c3c683b617..7c2e3cb7bb9 100644 --- a/src/main/runtime/rpc/methods/hosted-review.test.ts +++ b/src/main/runtime/rpc/methods/hosted-review.test.ts @@ -58,6 +58,7 @@ describe('hosted review RPC methods', () => { canCreate: true, blockedReason: null, nextAction: null, + reviewLookupOutcome: 'not_found', defaultBaseRef: 'main', head: 'feature/create-pr', title: 'Create PR' diff --git a/src/main/source-control/forge-provider.ts b/src/main/source-control/forge-provider.ts index a7d22ad48ae..1a6ea4a4368 100644 --- a/src/main/source-control/forge-provider.ts +++ b/src/main/source-control/forge-provider.ts @@ -6,24 +6,24 @@ import type { } from '../../shared/hosted-review' import { getAzureDevOpsPullRequest, - getAzureDevOpsPullRequestForBranch, + getAzureDevOpsPullRequestForBranchOrThrow, getAzureDevOpsRepoSlug } from '../azure-devops/client' import { createAzureDevOpsPullRequest } from '../azure-devops/pull-request-creation' import { getBitbucketPullRequest, - getBitbucketPullRequestForBranch, + getBitbucketPullRequestForBranchOrThrow, getBitbucketRepoSlug } from '../bitbucket/client' import { getGiteaPullRequest, - getGiteaPullRequestForBranch, + getGiteaPullRequestForBranchOrThrow, getGiteaRepoSlug } from '../gitea/client' import { createGiteaPullRequest } from '../gitea/pull-request-creation' import { createGitHubPullRequest, getPRForBranchOutcome, getRepoSlug } from '../github/client' import { getEnterpriseGitHubRepoSlug } from '../github/github-enterprise-repository' -import { getMergeRequest, getMergeRequestForBranch, getProjectSlug } from '../gitlab/client' +import { getMergeRequest, getMergeRequestForBranchOrThrow, getProjectSlug } from '../gitlab/client' import { createGitLabMergeRequest } from '../gitlab/merge-request-creation' import { mapAzureDevOpsReview, @@ -86,7 +86,10 @@ const gitLabForgeProvider = { resolveRepository: (context) => getProjectSlug(context.repoPath, context.connectionId, ...hostedReviewExecutionArgs(context)), async getReviewForBranch(input) { - const mr = await getMergeRequestForBranch( + // Why: throw (not null) on a real lookup failure so eligibility records + // `unavailable`, never a false "No merge request found" — same contract the + // GitHub adapter uses so hosted-review callers preserve last-known state. + const mr = await getMergeRequestForBranchOrThrow( input.repoPath, input.branch, input.linkedReviewNumber ?? null, @@ -188,7 +191,9 @@ const bitbucketForgeProvider = { ...hostedReviewExecutionArgs(context) ), async getReviewForBranch(input) { - const pr = await getBitbucketPullRequestForBranch( + // Why: surface a real lookup failure so eligibility records `unavailable` + // instead of a false "No pull request found". + const pr = await getBitbucketPullRequestForBranchOrThrow( input.repoPath, input.branch, input.linkedReviewNumber ?? null, @@ -218,7 +223,9 @@ const azureDevOpsForgeProvider = { ...hostedReviewExecutionArgs(context) ), async getReviewForBranch(input) { - const pr = await getAzureDevOpsPullRequestForBranch( + // Why: surface a real lookup failure so eligibility records `unavailable` + // instead of a false "No pull request found". + const pr = await getAzureDevOpsPullRequestForBranchOrThrow( input.repoPath, input.branch, input.linkedReviewNumber ?? null, @@ -245,7 +252,9 @@ const giteaForgeProvider = { resolveRepository: (context) => getGiteaRepoSlug(context.repoPath, context.connectionId, ...hostedReviewExecutionArgs(context)), async getReviewForBranch(input) { - const pr = await getGiteaPullRequestForBranch( + // Why: surface a real lookup failure so eligibility records `unavailable` + // instead of a false "No pull request found". + const pr = await getGiteaPullRequestForBranchOrThrow( input.repoPath, input.branch, input.linkedReviewNumber ?? null, diff --git a/src/main/source-control/hosted-review-creation-eligibility.test.ts b/src/main/source-control/hosted-review-creation-eligibility.test.ts index 010d950d50f..484ede6cbff 100644 --- a/src/main/source-control/hosted-review-creation-eligibility.test.ts +++ b/src/main/source-control/hosted-review-creation-eligibility.test.ts @@ -115,7 +115,7 @@ vi.mock('./hosted-review', () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock })) -import { getHostedReviewCreationEligibility } from './hosted-review-creation' +import { createHostedReview, getHostedReviewCreationEligibility } from './hosted-review-creation' function resetMocks(): void { for (const mock of [ @@ -232,6 +232,96 @@ describe('getHostedReviewCreationEligibility', () => { }) }) + it('reports reviewLookupOutcome: found when an existing review is returned', async () => { + getHostedReviewForBranchMock.mockResolvedValue({ number: 7, url: 'https://x/pull/7' }) + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/x', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ blockedReason: 'existing_review', reviewLookupOutcome: 'found' }) + }) + + it('reports reviewLookupOutcome: not_found when the lookup accepts no review', async () => { + getHostedReviewForBranchMock.mockResolvedValue(null) + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/x', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ reviewLookupOutcome: 'not_found' }) + }) + + it('reports reviewLookupOutcome: unavailable when a swallowed lookup failure yields a local blocker', async () => { + getHostedReviewForBranchMock.mockRejectedValue(new Error('ssh: connection refused')) + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/x', + hasUncommittedChanges: false, + hasUpstream: false, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ blockedReason: 'no_upstream', reviewLookupOutcome: 'unavailable' }) + }) + + it('never marks a clean, in-sync branch creatable when the review lookup fails', async () => { + // A failed existing-review lookup leaves review existence unproven, so the + // happy path must not claim canCreate — that would offer Create against a + // branch that might already have a review (finding 4). + getHostedReviewForBranchMock.mockRejectedValue(new Error('gh: could not connect to github.com')) + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/x', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + canCreate: false, + blockedReason: null, + reviewLookupOutcome: 'unavailable' + }) + }) + + it('refuses to create when the existing-review lookup is unavailable (finding 5)', async () => { + getHostedReviewForBranchMock.mockRejectedValue(new Error('glab: connection refused')) + getUpstreamStatusMock.mockResolvedValue({ hasUpstream: true, ahead: 0, behind: 0 }) + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse') { + return { stdout: 'feature/x\n', stderr: '' } + } + if (args[0] === 'status') { + return { stdout: '', stderr: '' } + } + // symbolic-ref / for-each-ref resolve the base on the remote. + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + }) + + const result = await createHostedReview('/repo', { + provider: 'github', + base: 'main', + head: 'feature/x', + title: 'Add feature' + }) + + expect(result).toMatchObject({ ok: false, code: 'validation' }) + // The provider create API must never run on an inconclusive lookup. + expect(createGitHubPullRequestMock).not.toHaveBeenCalled() + }) + // Stacked-worktree base resolution (Change 1/2). `stackedArgs` defaults to a // bare local-only parent; `mockRefs` controls the remote-tracking snapshot. const stackedArgs = ( diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index 1e19f1d4fa8..a0271d0ea3d 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -6,6 +6,7 @@ import type { HostedReviewCreationBlockedReason, HostedReviewCreationEligibility, HostedReviewCreationEligibilityArgs, + HostedReviewLookupOutcome, HostedReviewProvider } from '../../shared/hosted-review' import { @@ -435,6 +436,18 @@ async function validateCurrentBranchCanCreateReview( enforceBaseOnRemote: true, ...options }) + // Why: never call the provider create API when the existing-review lookup + // could not prove the branch has no review. An `unavailable` outcome means a + // real MR/PR might exist that a failed lookup could not see; refuse + // inconclusively (design invariant 8 / submission boundary) so we never + // create a duplicate. The composed title/body are preserved by the caller. + if (eligibility.reviewLookupOutcome === 'unavailable') { + return { + ok: false, + code: 'validation', + error: `Create ${copy.shortLabel} failed: Orca could not confirm whether this branch already has a ${copy.reviewLabel}. Retry once the ${copy.providerName} lookup succeeds.` + } + } // Why: renderer eligibility can be stale by submit time; the main process // is the last chance to avoid creating a PR from an out-of-date remote head. return blockedEligibilityToCreateResult(eligibility, submittedBase) @@ -475,6 +488,10 @@ export async function getHostedReviewCreationEligibility( } const baseBranch = defaultBaseRef ? normalizeHostedReviewBaseRef(defaultBaseRef) : null let review: Awaited> = null + // Why: a swallowed lookup failure must not masquerade as authoritative + // no-review evidence. Track it so the renderer can distinguish an accepted + // no-PR result (`not_found`) from a local-blocker fallback (`unavailable`). + let lookupFailed = false try { review = await getHostedReviewForBranch({ repoPath: args.repoPath, @@ -489,23 +506,25 @@ export async function getHostedReviewCreationEligibility( ...hostedReviewExecutionContext(args) }) } catch (error) { - const canReturnLocalBlocker = - branch && - branch !== 'HEAD' && - supportsHostedReviewCreation(provider) && - (!baseBranch || branch.toLowerCase() !== baseBranch.toLowerCase()) && - (args.hasUncommittedChanges || args.hasUpstream !== true || (args.behind ?? 0) > 0) - if (!canReturnLocalBlocker) { - throw error - } - // Why: local blockers still let the UI offer Create PR preparation; a - // flaky existing-review lookup should not hide the affordance entirely. - console.warn('Hosted review lookup failed while resolving local review blocker:', error) + // Why: a failed existing-review lookup can never authorize Create — it might + // be hiding a real PR. Record `unavailable` and fall through so any local + // blocker (dirty/no-upstream/behind) still supplies primary guidance, but the + // clean-branch happy path below is forced to `canCreate: false`. Never rethrow + // here: a thrown lookup must not collapse into a false `not_found` and must + // stay a structured, honest empty state for the renderer. + lookupFailed = true + console.warn('Hosted review lookup failed; treating existing-review as unavailable:', error) } + const reviewLookupOutcome: HostedReviewLookupOutcome = review + ? 'found' + : lookupFailed + ? 'unavailable' + : 'not_found' const baseResult = { provider, review: review ? { number: review.number, url: review.url } : null, + reviewLookupOutcome, defaultBaseRef, head: branch || null } @@ -575,7 +594,15 @@ export async function getHostedReviewCreationEligibility( nextAction: null } } - return { ...baseResult, canCreate: Boolean(baseBranch), blockedReason: null, nextAction: null } + // Why: a lookup that failed leaves review existence unproven, so the clean, + // in-sync happy path must not claim `canCreate` — that would open Create + // against a branch that may already have a review. Stays `unavailable`. + return { + ...baseResult, + canCreate: lookupFailed ? false : Boolean(baseBranch), + blockedReason: null, + nextAction: null + } } export async function createHostedReview( diff --git a/src/main/source-control/hosted-review.test.ts b/src/main/source-control/hosted-review.test.ts index 51c5ee8d927..102ebfebdbb 100644 --- a/src/main/source-control/hosted-review.test.ts +++ b/src/main/source-control/hosted-review.test.ts @@ -27,6 +27,9 @@ const { vi.mock('../gitlab/client', () => ({ getProjectSlug: getProjectSlugMock, getMergeRequestForBranch: getMergeRequestForBranchMock, + // Why: forge-provider resolves branch reviews via the OrThrow variant so + // lookup failures surface as unavailable instead of "no PR found". + getMergeRequestForBranchOrThrow: getMergeRequestForBranchMock, getMergeRequest: vi.fn() })) @@ -39,18 +42,27 @@ vi.mock('../github/client', () => ({ vi.mock('../bitbucket/client', () => ({ getBitbucketRepoSlug: getBitbucketRepoSlugMock, getBitbucketPullRequestForBranch: getBitbucketPullRequestForBranchMock, + // Why: forge-provider resolves branch reviews via the OrThrow variant so + // lookup failures surface as unavailable instead of "no PR found". + getBitbucketPullRequestForBranchOrThrow: getBitbucketPullRequestForBranchMock, getBitbucketPullRequest: vi.fn() })) vi.mock('../azure-devops/client', () => ({ getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock, getAzureDevOpsPullRequestForBranch: getAzureDevOpsPullRequestForBranchMock, + // Why: forge-provider resolves branch reviews via the OrThrow variant so + // lookup failures surface as unavailable instead of "no PR found". + getAzureDevOpsPullRequestForBranchOrThrow: getAzureDevOpsPullRequestForBranchMock, getAzureDevOpsPullRequest: vi.fn() })) vi.mock('../gitea/client', () => ({ getGiteaRepoSlug: getGiteaRepoSlugMock, getGiteaPullRequestForBranch: getGiteaPullRequestForBranchMock, + // Why: forge-provider resolves branch reviews via the OrThrow variant so + // lookup failures surface as unavailable instead of "no PR found". + getGiteaPullRequestForBranchOrThrow: getGiteaPullRequestForBranchMock, getGiteaPullRequest: vi.fn() })) diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index ac08b19fdf9..66ff0f85a35 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -60,7 +60,8 @@ import type { PRInfo, PRCheckDetail, PRCheckRunDetails, - PRComment + PRComment, + PRRefreshErrorType } from '../../../../shared/types' import { getConnectionId } from '@/lib/connection-context' import { @@ -82,6 +83,7 @@ import type { HostedReviewProvider } from '../../../../shared/hosted-review' import { resolveHostedReviewCreationProvider } from '../../../../shared/hosted-review-creation-providers' +import { normalizeGlobalWindowsRuntimeDefault } from '../../../../shared/project-execution-runtime' import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs' import { getHostedReviewCacheKey, refreshHostedReviewCard } from '@/store/slices/hosted-review' import { toast } from 'sonner' @@ -99,11 +101,17 @@ import { } from './pr-comment-thread-resolution' import { installWindowVisibilityTimeoutPoller } from '@/lib/window-visibility-timeout-poller' import { - getChecksPanelEmptyStateCopy, + getChecksPanelReviewState, shouldShowChecksPanelPublishBranchAction } from './checks-panel-empty-state' import { getChecksPanelRefreshErrorBannerLine } from './github-refresh-error-copy' -import { hasAmbiguousGitHubHostedReviewForChecksPanel } from './checks-panel-ambiguous-github-review' +import { resolveChecksPanelReviewLookup } from './checks-panel-review-lookup-authority' +import { + computeChecksPanelConfirmedReadiness, + isChecksPanelHardErrorCleared, + isChecksPanelHardRefreshErrorType, + type ChecksPanelConfirmedReadinessInput +} from './checks-panel-review-creation' import { recordChecksPanelPRRefreshBreadcrumb } from './checks-panel-pr-refresh-breadcrumb' import { cancelRuntimeGeneratePullRequestFields, @@ -181,12 +189,47 @@ const GIT_STATUS_FAILURE_RETRY_MS = 3000 type HostedReviewCreationSnapshot = { requestKey: string + /** Panel context key (repo/worktree/branch/host) at request time. */ + contextKey: string repoId: string worktreeId: string | null branch: string + /** Wall-clock time the eligibility request started (hard-error clear ordering). */ + requestStartedAt: number + /** Wall-clock time the eligibility result settled (confirmed freshness window). */ + completedAt: number + /** Git snapshot fingerprint used for this eligibility (confirmed freshness). */ + gitFingerprint: string data: HostedReviewCreationEligibility } +// Confirmed readiness must drop when HEAD, dirty, upstream, base, or the +// execution host move under the eligibility that produced it. Fingerprint those +// fields so a stale snapshot cannot keep an enabled Create open. +function buildChecksPanelEligibilityGitFingerprint(input: { + headOid: string | null + hasUncommittedChanges: boolean | undefined + hasUpstream: boolean | undefined + ahead: number | undefined + behind: number | undefined + base: string | null + runtimeEnvironmentId: string | null + repoConnectionId: string | null + localExecutionScope: string | null +}): string { + return JSON.stringify({ + headOid: input.headOid ?? null, + hasUncommittedChanges: input.hasUncommittedChanges ?? null, + hasUpstream: input.hasUpstream ?? null, + ahead: input.ahead ?? null, + behind: input.behind ?? null, + base: input.base ?? null, + runtimeEnvironmentId: input.runtimeEnvironmentId ?? null, + repoConnectionId: input.repoConnectionId ?? null, + localExecutionScope: input.localExecutionScope ?? null + }) +} + type ChecksAgentComposerState = { actionId: SourceControlLaunchActionId title: string @@ -416,6 +459,7 @@ export default function ChecksPanel(): React.JSX.Element { ) const isRemoteOperationActive = useAppStore((s) => s.isRemoteOperationActive) const pushBranch = useAppStore((s) => s.pushBranch) + const syncBranch = useAppStore((s) => s.syncBranch) const fetchUpstreamStatus = useAppStore((s) => s.fetchUpstreamStatus) const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen) const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab) @@ -452,6 +496,7 @@ export default function ChecksPanel(): React.JSX.Element { const [isCreatingPr, setIsCreatingPr] = useState(false) const [createPrError, setCreatePrError] = useState(null) const [isPublishingBranch, setIsPublishingBranch] = useState(false) + const [isSyncingBranch, setIsSyncingBranch] = useState(false) const isResolvingConflictsWithAI = false const [isFixingChecksWithAI, setIsFixingChecksWithAI] = useState(false) const [agentComposerState, setAgentComposerState] = useState( @@ -459,10 +504,28 @@ export default function ChecksPanel(): React.JSX.Element { ) const [hostedReviewCreationSnapshot, setHostedReviewCreationSnapshot] = useState(null) + // Sticky observation of the most recent hard refresh error for the exact + // context. Persisted across queued/in-flight auto-retries so Create cannot + // flap back until a qualifying eligibility request clears it. + const [hardRefreshError, setHardRefreshError] = useState<{ + observedAt: number + errorType: PRRefreshErrorType + contextKey: string + } | null>(null) const [gitStatusSnapshot, setGitStatusSnapshot] = useState( null ) + // Context key whose git-status probe failed with no usable snapshot, so the + // empty state can distinguish "checking branch status" from "could not check". + const [gitStatusProbeErrorContextKey, setGitStatusProbeErrorContextKey] = useState( + null + ) const [gitStatusRefreshNonce, setGitStatusRefreshNonce] = useState(0) + // Bumped by a manual Retry/Refresh so eligibility re-runs even when the Git + // snapshot is unchanged. A hard error can only be cleared by an eligibility + // request that started strictly after the error, so an auth fix followed by + // Retry (which does not change Git state) must still trigger a fresh request. + const [eligibilityRefreshNonce, setEligibilityRefreshNonce] = useState(0) const [editingTitle, setEditingTitle] = useState(false) const [titleDraft, setTitleDraft] = useState('') const [titleSaving, setTitleSaving] = useState(false) @@ -543,6 +606,16 @@ export default function ChecksPanel(): React.JSX.Element { [runtimeEnvironmentId, settings] ) const repoConnectionId = repo?.connectionId?.trim() || null + // Local execution host variant (`wsl:{distro}` vs `host`). WSL is a local-host + // variant, not a runtime/SSH host, so it only applies when execution is local; + // remote contexts are already scoped by runtimeEnvironmentId / connectionId. + const localExecutionScope = useMemo(() => { + if (runtimeEnvironmentId != null || repoConnectionId != null) { + return null + } + const localRuntime = normalizeGlobalWindowsRuntimeDefault(settings?.localWindowsRuntimeDefault) + return localRuntime.kind === 'wsl' ? `wsl:${localRuntime.distro ?? ''}` : 'host' + }, [runtimeEnvironmentId, repoConnectionId, settings?.localWindowsRuntimeDefault]) const sshConnectionStatus = useAppStore((s) => repoConnectionId ? s.sshConnectionStates.get(repoConnectionId)?.status : undefined ) @@ -558,6 +631,7 @@ export default function ChecksPanel(): React.JSX.Element { linkedGiteaPR: activeWorktree?.linkedGiteaPR ?? null, runtimeEnvironmentId, repoConnectionId, + localExecutionScope, pushTarget: activeWorktreePushTarget }) const panelContextKeyRef = useRef(panelContextKey) @@ -607,7 +681,9 @@ export default function ChecksPanel(): React.JSX.Element { setIsPublishingBranch(false) setAgentComposerState(null) setHostedReviewCreationSnapshot(null) + setHardRefreshError(null) setGitStatusSnapshot(null) + setGitStatusProbeErrorContextKey(null) setGitStatusRefreshNonce((value) => value + 1) pollIntervalRef.current = 30_000 prevChecksRef.current = '' @@ -659,11 +735,13 @@ export default function ChecksPanel(): React.JSX.Element { const hostedReview = useAppStore((s) => hostedReviewCacheKey ? (s.hostedReviewCache[hostedReviewCacheKey]?.data ?? null) : null ) - const hasAmbiguousGitHubHostedReview = hasAmbiguousGitHubHostedReviewForChecksPanel({ - hostedReview, - prCacheEntry, - prCacheKey - }) + const linkedReviewNumber = + activeWorktree?.linkedPR ?? + activeWorktree?.linkedGitLabMR ?? + activeWorktree?.linkedBitbucketPR ?? + activeWorktree?.linkedAzureDevOpsPR ?? + activeWorktree?.linkedGiteaPR ?? + null // Fetch PR data when the active worktree/branch changes. // Why: branch lookup is lossy for fork/deleted-head PRs; reuse a known PR // number from metadata or the visible cache whenever we have one. @@ -748,6 +826,25 @@ export default function ChecksPanel(): React.JSX.Element { panelVisibleSinceRef.current = Date.now() }, [isPanelVisible, panelContextKey]) + // Record the latest hard refresh error for the exact context. Kept sticky + // (only cleared via computeChecksPanelConfirmedReadiness) so a background + // queued/in-flight auto-retry — which replaces `status: 'error'` — cannot + // silently re-enable Create while the lookup is still impossible here. + useEffect(() => { + const errorType = prRefreshState?.status === 'error' ? prRefreshState.errorType : undefined + if (!isChecksPanelHardRefreshErrorType(errorType)) { + return + } + const observedAt = prRefreshState?.updatedAt ?? Date.now() + const contextKey = panelContextKeyRef.current + setHardRefreshError((prev) => { + if (prev && prev.contextKey === contextKey && prev.observedAt >= observedAt) { + return prev + } + return { observedAt, errorType: errorType as PRRefreshErrorType, contextKey } + }) + }, [prRefreshState]) + // Why: select only timestamps (not whole cache records) so the entry-refresh // effect doesn't re-run on every cache mutation. See // docs/refresh-on-checks-tab.md. @@ -824,6 +921,27 @@ export default function ChecksPanel(): React.JSX.Element { const gitStatusReadyForPanelContext = gitStatusInputs.hasUncommittedChanges !== undefined const hasUncommittedChanges = gitStatusInputs.hasUncommittedChanges const remoteStatus = gitStatusInputs.remoteStatus + const eligibilityHeadOid = + gitStatusSnapshot?.contextKey === panelContextKey + ? (gitStatusSnapshot.gitIdentity?.head ?? null) + : null + // Read at eligibility request time via a ref so a HEAD move drops confirmed + // (fingerprint mismatch) without re-triggering the eligibility network call. + const eligibilityHeadOidRef = useRef(eligibilityHeadOid) + eligibilityHeadOidRef.current = eligibilityHeadOid + const eligibilityGitFingerprint = gitStatusReadyForPanelContext + ? buildChecksPanelEligibilityGitFingerprint({ + headOid: eligibilityHeadOid, + hasUncommittedChanges, + hasUpstream: remoteStatus?.hasUpstream, + ahead: remoteStatus?.ahead, + behind: remoteStatus?.behind, + base: repo?.worktreeBaseRef ?? null, + runtimeEnvironmentId, + repoConnectionId, + localExecutionScope + }) + : null // Why: Create PR eligibility waits for the stricter panel snapshot, but the // Publish affordance can use the active worktree poller when SSH snapshot // refresh is delayed; publishing is still blocked for dirty fallback status. @@ -843,7 +961,70 @@ export default function ChecksPanel(): React.JSX.Element { const hostedReviewCreateProvider = resolveHostedReviewCreationProvider( hostedReviewCreation?.provider ) + // Only GitHub contexts run the gh-based PR refresh coordinator. Bitbucket / + // Azure DevOps / Gitea / GitLab must never be driven by (or show copy from) a + // GitHub `gh` failure — that would hide a valid composer and mislabel the CLI. + // Prefer the resolved eligibility provider (authoritative); before it resolves, + // treat a non-GitHub linked review as a definite non-GitHub signal and stay + // GitHub-optimistic otherwise so GitHub status is not delayed on initial load. + // (`resolveHostedReviewCreationProvider` defaults null → 'github', so the raw + // value cannot distinguish "unknown" from "GitHub".) + const hasNonGitHubLinkedReview = + activeWorktree?.linkedGitLabMR != null || + activeWorktree?.linkedBitbucketPR != null || + activeWorktree?.linkedAzureDevOpsPR != null || + activeWorktree?.linkedGiteaPR != null + const isGitHubReviewContext = hostedReviewCreation + ? hostedReviewCreation.provider === 'github' + : !hasNonGitHubLinkedReview const hostedReviewCreateCopy = localizedHostedReviewCopy(hostedReviewCreateProvider) + // The PR cache is branch/host scoped but not push-target scoped, so an accepted + // no-PR fetched for a previous push target could alias as `not_found` after the + // push target (or other exact-context field) changes. When an eligibility + // snapshot exists for a *different* context than the current one, the context + // has moved and that branch-scoped no-PR is not authoritative for it — demote + // it to unknown until eligibility re-resolves. Same-context background churn + // (snapshot matches, or no snapshot yet) keeps the sticky no-review intact. + const prCachedHasPRForContext = + hostedReviewCreationSnapshot && hostedReviewCreationSnapshot.contextKey !== panelContextKey + ? null + : prCachedHasPR + // Four-state review evidence: replaces the old ambiguous-GitHub boolean so the + // empty state can never claim "No review found" without accepted evidence. + const checksPanelReviewLookupResult = resolveChecksPanelReviewLookup({ + pr, + prCachedHasPR: prCachedHasPRForContext, + hostedReview, + linkedReviewNumber, + eligibilityReviewLookupOutcome: hostedReviewCreation?.reviewLookupOutcome ?? null, + eligibilityReview: hostedReviewCreation?.review ?? null + }) + const checksPanelReviewLookup = checksPanelReviewLookupResult.state + // Confirmed readiness for the exact context, computed from the last confirmed + // eligibility snapshot rather than live `canCreate` (which would be circular + // and would flap during transient refresh/eligibility failures). This both + // gates the composer and clears the sticky hard-error block below. + const hardErrorObservedAt = + isGitHubReviewContext && hardRefreshError && hardRefreshError.contextKey === panelContextKey + ? hardRefreshError.observedAt + : undefined + const confirmedReadinessInput: ChecksPanelConfirmedReadinessInput = { + contextKeyMatches: hostedReviewCreationSnapshot?.contextKey === panelContextKey, + eligibility: hostedReviewCreationSnapshot?.data ?? null, + eligibilityCompletedAt: hostedReviewCreationSnapshot?.completedAt, + eligibilityRequestStartedAt: hostedReviewCreationSnapshot?.requestStartedAt, + reviewLookup: checksPanelReviewLookup, + hardErrorObservedAt, + gitSnapshotMatches: + eligibilityGitFingerprint !== null && + hostedReviewCreationSnapshot?.gitFingerprint === eligibilityGitFingerprint, + now: Date.now() + } + const confirmedReadiness = computeChecksPanelConfirmedReadiness(confirmedReadinessInput) + // A hard error stays active across auto-retries until a qualifying eligibility + // request clears it, so `status: 'queued' | 'in-flight'` no longer un-hides Create. + const checksPanelHasHardRefreshError = + hardErrorObservedAt !== undefined && !isChecksPanelHardErrorCleared(confirmedReadinessInput) const activePullRequestGenerationKey = getPullRequestGenerationRecordKey({ worktreeId: activeWorktreeId, worktreePath: activeWorktreePath, @@ -919,12 +1100,12 @@ export default function ChecksPanel(): React.JSX.Element { () => (settings ? resolveSourceControlAiEnabled({ settings, repo }) : false), [repo, settings] ) + // Confirmed-only composer gate. Driven by confirmed readiness (context match, + // fresh eligibility, matching git snapshot, cleared hard error) so an + // already-confirmed composer survives transient refresh/eligibility failures + // but a refresh failure never *opens* a never-confirmed Create. const createComposerOpen = - !activeReview && - !isFolder && - Boolean(branch) && - (hostedReviewCreation?.canCreate === true || - hostedReviewCreation?.blockedReason === 'needs_push') + !isFolder && !activeReview && Boolean(branch) && confirmedReadiness.confirmed const handleGeneratePullRequestFieldsForActive = useCallback( async ( fields: PullRequestGenerationFields, @@ -1121,6 +1302,9 @@ export default function ChecksPanel(): React.JSX.Element { submitting: isCreatingPr, prCreationDefaults, sourceControlAiActionsVisible, + // Preserve the draft when a hard refresh error hides the composer so the + // user's title/body/base survive recovery for the same exact context. + retainDraftWhenClosed: true, generation: { generating: activePullRequestGenerationRecord?.status === 'running', generateError: activePullRequestGenerationRecord?.error ?? null, @@ -1232,7 +1416,10 @@ export default function ChecksPanel(): React.JSX.Element { linkedGiteaPR, staleWhileRevalidate: true }) - if (activeWorktreeId && !isGitLabReviewContext) { + // Why: the gh-based PR refresh coordinator is GitHub-only. Running it for + // Bitbucket/Azure/Gitea produced a spurious `gh_unavailable` hard error that + // hid a valid composer; those providers refresh via fetchHostedReviewForBranch. + if (activeWorktreeId && isGitHubReviewContext) { const refreshRequest = resolveChecksPanelPRRefreshRequest({ cachedHasPR: prCachedHasPR, cachedFetchedAt: prFetchedAt ?? null, @@ -1248,7 +1435,7 @@ export default function ChecksPanel(): React.JSX.Element { fallbackGitHubPRNumber, fetchHostedReviewForBranch, isFolder, - isGitLabReviewContext, + isGitHubReviewContext, isPanelVisible, activeWorktree?.head, linkedAzureDevOpsPR, @@ -1382,6 +1569,9 @@ export default function ChecksPanel(): React.JSX.Element { branch: status.branch ?? (status.head ? null : undefined) } }) + // A fresh probe succeeded, so this context is no longer in the failed + // "could not check branch status" state. + setGitStatusProbeErrorContextKey((key) => (key === requestContextKey ? null : key)) } }) .catch((error) => { @@ -1392,6 +1582,13 @@ export default function ChecksPanel(): React.JSX.Element { setGitStatusSnapshot((snapshot) => shouldClearChecksPanelGitStatusSnapshot(snapshot, requestContextKey) ? null : snapshot ) + // Mark the probe as failed so the empty state can show "Could not check + // branch status" instead of an indefinite "Checking branch status". + if ( + shouldCommitChecksPanelGitStatusSnapshot(panelContextKeyRef.current, requestContextKey) + ) { + setGitStatusProbeErrorContextKey(requestContextKey) + } gitStatusSnapshotRetryTimerRef.current = setTimeout(() => { gitStatusSnapshotRetryTimerRef.current = null if ( @@ -1454,6 +1651,19 @@ export default function ChecksPanel(): React.JSX.Element { return } let stale = false + const requestContextKey = panelContextKey + const requestStartedAt = Date.now() + const requestGitFingerprint = buildChecksPanelEligibilityGitFingerprint({ + headOid: eligibilityHeadOidRef.current, + hasUncommittedChanges, + hasUpstream: remoteStatus?.hasUpstream, + ahead: remoteStatus?.ahead, + behind: remoteStatus?.behind, + base: repo.worktreeBaseRef ?? null, + runtimeEnvironmentId, + repoConnectionId, + localExecutionScope + }) void getHostedReviewCreationEligibility({ repoPath: repo.path, repoId: repo.id, @@ -1475,22 +1685,31 @@ export default function ChecksPanel(): React.JSX.Element { if (!stale) { setHostedReviewCreationSnapshot({ requestKey: hostedReviewCreationRequestKey, + contextKey: requestContextKey, repoId: repo.id, worktreeId: activeWorktreeId, branch, + requestStartedAt, + completedAt: Date.now(), + gitFingerprint: requestGitFingerprint, data: result }) } }) .catch(() => { - if (!stale) { - setHostedReviewCreationSnapshot(null) - } + // Why: a transient GitHub outage makes the existing-review lookup inside + // eligibility rethrow. Do NOT tear down the last confirmed snapshot — a + // clean, already-confirmed composer must survive the outage. Confirmed + // readiness still gates it on context + git-fingerprint + freshness, so a + // stale snapshot can never open a never-confirmed Create. }) return () => { stale = true } }, [ + panelContextKey, + runtimeEnvironmentId, + repoConnectionId, activeWorktreeId, activeWorktreePath, branch, @@ -1498,6 +1717,8 @@ export default function ChecksPanel(): React.JSX.Element { gitStatusReadyForPanelContext, hasUncommittedChanges, hostedReviewCreationRequestKey, + eligibilityRefreshNonce, + localExecutionScope, isFolder, isPanelVisible, linkedPR, @@ -2181,6 +2402,10 @@ export default function ChecksPanel(): React.JSX.Element { if (isCurrentRequest()) { refreshInFlightRef.current = false setIsRefreshing(false) + // Why: force a fresh eligibility request whose start time is after any + // current hard-refresh error, so a resolved auth/permission failure can + // clear the sticky hard error even when Git state did not change. + setEligibilityRefreshNonce((value) => value + 1) } } }, [ @@ -3263,6 +3488,51 @@ export default function ChecksPanel(): React.JSX.Element { pushBranch ]) + // Sync the branch with its upstream using the same runtime-scoped operation and + // push target as Source Control, so a `needs_sync` create blocker is actionable + // from the Checks panel instead of showing guidance with no button. + const handleSyncBranch = useCallback(async (): Promise => { + if (!activeWorktreeId || !activeWorktree?.path || isSyncingBranch || isRemoteOperationActive) { + return + } + const connectionId = activeConnectionId ?? undefined + setIsSyncingBranch(true) + try { + await syncBranch( + activeWorktreeId, + activeWorktree.path, + connectionId, + activeWorktree.pushTarget, + { + runtimeTargetSettings: ownerSettings + } + ) + await fetchUpstreamStatus( + activeWorktreeId, + activeWorktree.path, + connectionId, + activeWorktree.pushTarget, + { runtimeTargetSettings: ownerSettings } + ) + } catch { + // Store remote actions already surface the sync failure toast. + } finally { + // Why: syncing changes ahead/behind, which the panel uses to choose between + // Sync, Create PR, and Push & Create PR. + setGitStatusRefreshNonce((value) => value + 1) + setIsSyncingBranch(false) + } + }, [ + activeWorktree, + activeWorktreeId, + activeConnectionId, + fetchUpstreamStatus, + isSyncingBranch, + isRemoteOperationActive, + ownerSettings, + syncBranch + ]) + const handlePullRequestCreated = useCallback( async (result: { provider: HostedReviewProvider @@ -3581,17 +3851,79 @@ export default function ChecksPanel(): React.JSX.Element { hasUpstream: publishActionRemoteStatus?.hasUpstream, hasCurrentBranch: Boolean(branch) })) - const emptyStateCopy = getChecksPanelEmptyStateCopy({ + // Non-GitHub providers (GitLab/Bitbucket/Azure/Gitea) have no typed GitHub + // refresh state; only feed the GitHub refresh classification into the selector + // for GitHub contexts. When a hard error is still active (sticky across + // auto-retries), surface it as the refresh state so the hard-error card and + // composer suppression persist even while a queued/in-flight retry is + // temporarily the live status. + const emptyRefreshInput = !isGitHubReviewContext + ? undefined + : checksPanelHasHardRefreshError && hardRefreshError + ? { status: 'error' as const, errorType: hardRefreshError.errorType } + : prRefreshState + ? { + status: prRefreshState.status, + errorType: prRefreshState.errorType, + skippedReason: prRefreshState.skippedReason, + nextAutoRetryAt: prRefreshState.nextAutoRetryAt, + retryDisabledUntil: prRefreshState.retryDisabledUntil + } + : undefined + const emptyGitStatusPhase: 'loading' | 'ready' | 'error' = + gitStatusInputs.hasUncommittedChanges !== undefined + ? 'ready' + : gitStatusProbeErrorContextKey === panelContextKey + ? 'error' + : 'loading' + const reviewState = getChecksPanelReviewState({ operationLabel, - prRefreshStatus: emptyReviewIsGitLab ? undefined : prRefreshState?.status, - prRefreshErrorType: emptyReviewIsGitLab ? undefined : prRefreshState?.errorType, - hostedReviewBlockedReason: hostedReviewCreation?.blockedReason, - hasUpstream: publishActionRemoteStatus?.hasUpstream, - hasCurrentBranch: Boolean(branch), reviewLabel: emptyReviewLabel, reviewShortLabel: emptyReviewShortLabel, - hasAmbiguousGitHubHostedReview + providerName: hostedReviewCreateCopy.providerName, + isGitHubProvider: hostedReviewCreateProvider === 'github', + reviewLookup: checksPanelReviewLookup, + openReviewUrl: checksPanelReviewLookupResult.openReviewUrl, + eligibilityBlockedReason: hostedReviewCreation?.blockedReason, + // Confirmed readiness (not the live create gate) drives composer mode so + // the selector matches the preserved-composer semantics. + confirmedReadiness: confirmedReadiness.confirmed, + confirmedNeedsPush: confirmedReadiness.needsPush, + refresh: emptyRefreshInput, + gitStatusPhase: emptyGitStatusPhase, + hasUpstream: publishActionRemoteStatus?.hasUpstream, + hasCurrentBranch: Boolean(branch) }) + const emptyStateCopy = { title: reviewState.title, description: reviewState.description } + const reviewStateAutoRetryText = + reviewState.autoRetryAt !== undefined && reviewState.autoRetryAt > Date.now() + ? translate( + 'auto.components.right.sidebar.ChecksPanel.review.auto_retry', + 'Orca will retry at {{time}}.', + { time: new Date(reviewState.autoRetryAt).toLocaleTimeString() } + ) + : null + const reviewRecoveryRetryDisabled = + reviewState.retryDisabledUntil !== undefined && Date.now() < reviewState.retryDisabledUntil + const reviewRecoveryLabelIsRefresh = reviewState.recovery.includes('refresh') + // Only offer Retry/Refresh when the selector's recovery set includes it; + // states like git-status loading, bare, or archived expose no recovery. + const reviewShowRetryOrRefresh = + reviewState.recovery.includes('retry') || reviewRecoveryLabelIsRefresh + const reviewShowOpenReview = + reviewState.recovery.includes('open_review') && Boolean(reviewState.openReviewUrl) + // The selector is the authority for the Sync workflow action; a `needs_sync` + // create blocker must expose Sync Branch, not just guidance copy. + const reviewShowSyncBranch = reviewState.workflowAction === 'sync_branch' + // The action row must render whenever any action is available — recovery + // actions (Retry/Refresh/Open Review) are shown independently of the composer + // so a preserved confirmed composer still exposes a working Retry during a + // transient failure. + const reviewShowActionRow = + canPublishBranch || + reviewShowSyncBranch || + (reviewShowOpenReview && Boolean(reviewState.openReviewUrl)) || + reviewShowRetryOrRefresh return (
{detachedHeadDisplay && ( @@ -3601,6 +3933,12 @@ export default function ChecksPanel(): React.JSX.Element { )}
{emptyStateCopy.title}
{emptyStateCopy.description}
+ {reviewState.detail ? ( +
{reviewState.detail}
+ ) : null} + {reviewStateAutoRetryText ? ( +
{reviewStateAutoRetryText}
+ ) : null} {!operationInProgress && createComposerOpen ? (
) : null} - {!operationInProgress && (!createComposerOpen || canPublishBranch) && ( + {!operationInProgress && reviewShowActionRow && (
{canPublishBranch && ( + )} + {reviewShowOpenReview && reviewState.openReviewUrl ? ( + ) : null} + {reviewShowRetryOrRefresh ? ( + ) : null}
diff --git a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx index b360795e662..a34540f7823 100644 --- a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx +++ b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx @@ -448,7 +448,8 @@ describe('CommitArea', () => { review: null, canCreate: false, blockedReason: 'dirty', - nextAction: 'commit' + nextAction: 'commit', + reviewLookupOutcome: 'not_found' } }) const markup = renderCommitArea(baseProps(input)) @@ -481,7 +482,8 @@ describe('CommitArea', () => { review: null, canCreate: false, blockedReason: 'needs_push', - nextAction: 'push' + nextAction: 'push', + reviewLookupOutcome: 'not_found' } }) const markup = renderCommitArea(baseProps(input)) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 665fd7419c1..a4eaf4250bc 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -3333,10 +3333,8 @@ function SourceControlInner(): React.JSX.Element { if (stale) { return } - // Why: a failed/timed-out remote probe must not leave an inert disabled - // button. When the local git status determines a blocker (dirty, no - // upstream, needs push/sync), surface the actionable preparation intent; - // otherwise fall back to the retryable failed state. + // Why: a failed remote probe can provide branch guidance, but it cannot + // authorize hosted-review creation. const localBlocker = buildLocalBlockerHostedReviewCreationEligibility( resolveCurrentHostedReviewCreationProvider(), { diff --git a/src/renderer/src/components/right-sidebar/checks-panel-ambiguous-github-review.test.ts b/src/renderer/src/components/right-sidebar/checks-panel-ambiguous-github-review.test.ts deleted file mode 100644 index db51fd6ef33..00000000000 --- a/src/renderer/src/components/right-sidebar/checks-panel-ambiguous-github-review.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { hasAmbiguousGitHubHostedReviewForChecksPanel } from './checks-panel-ambiguous-github-review' - -describe('hasAmbiguousGitHubHostedReviewForChecksPanel', () => { - it('treats missing PR cache as ambiguous when a GitHub hosted review exists', () => { - expect( - hasAmbiguousGitHubHostedReviewForChecksPanel({ - hostedReview: { provider: 'github' }, - prCacheEntry: undefined, - prCacheKey: 'repo::feature' - }) - ).toBe(true) - }) - - it('treats cached no-PR data as ambiguous when a GitHub hosted review exists', () => { - expect( - hasAmbiguousGitHubHostedReviewForChecksPanel({ - hostedReview: { provider: 'github' }, - prCacheEntry: { data: null }, - prCacheKey: 'repo::feature' - }) - ).toBe(true) - }) - - it('does not treat existing PR cache data as ambiguous', () => { - expect( - hasAmbiguousGitHubHostedReviewForChecksPanel({ - hostedReview: { provider: 'github' }, - prCacheEntry: { data: { number: 12 } }, - prCacheKey: 'repo::feature' - }) - ).toBe(false) - }) - - it('does not treat non-GitHub hosted reviews as ambiguous', () => { - expect( - hasAmbiguousGitHubHostedReviewForChecksPanel({ - hostedReview: { provider: 'gitlab' }, - prCacheEntry: { data: null }, - prCacheKey: 'repo::feature' - }) - ).toBe(false) - }) -}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel-ambiguous-github-review.ts b/src/renderer/src/components/right-sidebar/checks-panel-ambiguous-github-review.ts deleted file mode 100644 index bca25a7a228..00000000000 --- a/src/renderer/src/components/right-sidebar/checks-panel-ambiguous-github-review.ts +++ /dev/null @@ -1,22 +0,0 @@ -type HostedReviewProviderLike = { - provider?: string | null -} - -type PRCacheEntryLike = { - data?: unknown -} - -/** - * Flags GitHub-hosted review metadata whose matching PR cache may still be unknown. - */ -export function hasAmbiguousGitHubHostedReviewForChecksPanel(input: { - hostedReview: HostedReviewProviderLike | null | undefined - prCacheEntry: PRCacheEntryLike | null | undefined - prCacheKey: string -}): boolean { - return ( - input.hostedReview?.provider === 'github' && - input.prCacheKey !== '' && - input.prCacheEntry?.data == null - ) -} diff --git a/src/renderer/src/components/right-sidebar/checks-panel-blocker-copy.ts b/src/renderer/src/components/right-sidebar/checks-panel-blocker-copy.ts new file mode 100644 index 00000000000..1313afa7e05 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel-blocker-copy.ts @@ -0,0 +1,220 @@ +import type { HostedReviewCreationBlockedReason } from '../../../../shared/hosted-review' +import { translate } from '@/i18n/i18n' +import { + autoRetrySchedule, + capitalizeReviewLabel, + isHardRefreshError, + type ChecksPanelRecoveryAction, + type ChecksPanelReviewState, + type ChecksPanelReviewStateInput +} from './checks-panel-review-state-model' +import { concurrentLookupDetail } from './checks-panel-review-copy' + +type KeyedCopy = { key: string; fallback: string } + +const SAFETY_COPY: Record< + | 'detached_head' + | 'dirty' + | 'default_branch' + | 'fork_head_unsupported' + | 'base_not_on_remote' + | 'unsupported_provider', + { title: KeyedCopy; body: KeyedCopy } +> = { + detached_head: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.detached.title', + fallback: 'No current branch' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.detached.body', + fallback: 'Check out a branch before creating a {{reviewLabel}}.' + } + }, + dirty: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.dirty.title', + fallback: 'Commit changes first' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.dirty.body', + fallback: 'Commit or stash your changes before creating a {{reviewLabel}}.' + } + }, + default_branch: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.default_branch.title', + fallback: 'On the default branch' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.default_branch.body', + fallback: 'Switch to a feature branch before creating a {{reviewLabel}}.' + } + }, + fork_head_unsupported: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.fork.title', + fallback: 'Fork head unsupported' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.fork.body', + fallback: 'Orca cannot create a {{reviewLabel}} from this fork head here.' + } + }, + base_not_on_remote: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.base_missing.title', + fallback: 'Base branch not on remote' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.base_missing.body', + fallback: + "This branch's base is not on the remote yet, so a {{reviewLabel}} cannot target it." + } + }, + unsupported_provider: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.unsupported.title', + fallback: '{{reviewLabelCap}} not supported here' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.unsupported.body', + fallback: 'This repository provider does not support creating a {{reviewLabel}} from Orca.' + } + } +} + +export function safetyBlockerState( + input: ChecksPanelReviewStateInput, + reason: NonNullable +): ChecksPanelReviewState { + const { reviewLabel } = input + if (reason === 'existing_review') { + // Same family as positive evidence — offer trusted Open Review, never Create. + return { + renderReview: false, + title: translate( + 'auto.components.right.sidebar.checks.panel.review.existing.title', + '{{reviewLabelCap}} already exists', + { reviewLabelCap: capitalizeReviewLabel(reviewLabel) } + ), + description: translate( + 'auto.components.right.sidebar.checks.panel.review.existing.body', + 'Orca found an existing {{reviewLabel}} for this branch.', + { reviewLabel } + ), + composerMode: 'hidden', + workflowAction: null, + recovery: input.openReviewUrl ? ['open_review'] : [], + openReviewUrl: input.openReviewUrl + } + } + const copy = SAFETY_COPY[reason as keyof typeof SAFETY_COPY] ?? SAFETY_COPY.unsupported_provider + const vars = { reviewLabel, reviewLabelCap: capitalizeReviewLabel(reviewLabel) } + return { + renderReview: false, + title: translate(copy.title.key, copy.title.fallback, vars), + description: translate(copy.body.key, copy.body.fallback, vars), + composerMode: 'hidden', + workflowAction: null, + recovery: [] + } +} + +const BRANCH_BLOCKER_COPY: Record< + 'no_upstream' | 'needs_sync' | 'auth_required', + { title: KeyedCopy; body: KeyedCopy; workflow: ChecksPanelReviewState['workflowAction'] } +> = { + no_upstream: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.no_upstream.title', + fallback: 'No upstream configured' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.no_upstream.body', + fallback: 'Publish this branch to set its upstream before creating a {{reviewLabel}}.' + }, + workflow: 'publish_branch' + }, + needs_sync: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.needs_sync.title', + fallback: 'Branch needs to sync' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.needs_sync.body', + fallback: 'Sync this branch with its upstream before creating a {{reviewLabel}}.' + }, + workflow: 'sync_branch' + }, + auth_required: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.auth_required.title', + fallback: 'Connect {{provider}}' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.auth_required.body', + fallback: + '{{provider}} must be connected in this environment before Orca can create a {{reviewLabel}}.' + }, + workflow: null + } +} + +export function branchBlockerState( + input: ChecksPanelReviewStateInput, + reason: NonNullable +): ChecksPanelReviewState { + const { reviewLabel, providerName } = input + const detail = concurrentLookupDetail(input) + const schedule = detail ? autoRetrySchedule(input) : {} + // Positive-unresolved evidence with a trusted URL must still expose Open Review + // beneath the blocker (Create/Push & Create stay suppressed). Retry is offered + // whenever a concurrent lookup failure produced a detail sentence. + const canOpenReview = input.reviewLookup === 'positive_unresolved' && Boolean(input.openReviewUrl) + const lookupRecovery: ChecksPanelRecoveryAction[] = [ + ...(canOpenReview ? (['open_review'] as ChecksPanelRecoveryAction[]) : []), + ...(detail ? (['retry'] as ChecksPanelRecoveryAction[]) : []) + ] + const openReviewUrl = canOpenReview ? input.openReviewUrl : undefined + const vars = { reviewLabel, provider: providerName } + + if (reason === 'needs_push') { + // Push & Create unless review evidence or a hard error blocks create. + const blocked = + input.reviewLookup === 'positive_unresolved' || + isHardRefreshError(input.refresh) || + !input.confirmedReadiness + return { + renderReview: false, + title: translate( + 'auto.components.right.sidebar.checks.panel.review.needs_push.title', + 'Branch has unpushed commits' + ), + description: translate( + 'auto.components.right.sidebar.checks.panel.review.needs_push.body', + 'Push the latest commits before creating a {{reviewLabel}}.', + { reviewLabel } + ), + detail, + composerMode: blocked ? 'hidden' : 'needs_push_open', + workflowAction: blocked ? null : 'push_and_create', + recovery: lookupRecovery, + openReviewUrl, + ...schedule + } + } + + const copy = BRANCH_BLOCKER_COPY[reason as keyof typeof BRANCH_BLOCKER_COPY] + return { + renderReview: false, + title: translate(copy.title.key, copy.title.fallback, vars), + description: translate(copy.body.key, copy.body.fallback, vars), + detail, + composerMode: 'hidden', + workflowAction: copy.workflow, + recovery: lookupRecovery, + openReviewUrl, + ...schedule + } +} diff --git a/src/renderer/src/components/right-sidebar/checks-panel-empty-state.test.ts b/src/renderer/src/components/right-sidebar/checks-panel-empty-state.test.ts index 7e3f8ca3c41..941012d7a8d 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-empty-state.test.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-empty-state.test.ts @@ -1,281 +1,263 @@ import { describe, expect, it } from 'vitest' import { - getChecksPanelEmptyStateCopy, - shouldShowChecksPanelPublishBranchAction + getChecksPanelReviewState, + shouldShowChecksPanelPublishBranchAction, + type ChecksPanelReviewStateInput } from './checks-panel-empty-state' -describe('getChecksPanelEmptyStateCopy', () => { - it('shows a local-only branch message instead of a refresh error', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - hostedReviewBlockedReason: 'no_upstream', - hasUpstream: false - }) - ).toEqual({ - title: 'Branch not published', - description: 'Publish this branch before creating a pull request.' - }) +function input(overrides: Partial = {}): ChecksPanelReviewStateInput { + return { + operationLabel: null, + reviewLabel: 'pull request', + reviewShortLabel: 'PR', + providerName: 'GitHub', + isGitHubProvider: true, + reviewLookup: 'unknown', + openReviewUrl: null, + eligibilityBlockedReason: undefined, + confirmedReadiness: false, + confirmedNeedsPush: false, + refresh: undefined, + gitStatusPhase: 'ready', + hasUpstream: true, + hasCurrentBranch: true, + ...overrides + } +} + +describe('getChecksPanelReviewState — no-review honesty', () => { + it('only renders "No pull request found" for an accepted no-review result', () => { + expect(getChecksPanelReviewState(input({ reviewLookup: 'not_found' })).title).toBe( + 'No pull request found' + ) }) - it('uses remote status as a fallback before eligibility finishes', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - hostedReviewBlockedReason: undefined, - hasUpstream: false - }).title - ).toBe('Branch not published') + it('never says "No pull request found" for a missing/unknown lookup', () => { + const state = getChecksPanelReviewState(input({ reviewLookup: 'unknown' })) + expect(state.title).not.toContain('No pull request found') + expect(state.title).toBe('Pull request status unavailable') }) - it('does not show unpublished branch copy when HEAD is detached', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - hostedReviewBlockedReason: 'no_upstream', - hasUpstream: false, - hasCurrentBranch: false - }).title - ).toBe('Could not refresh pull request') + it('never says "No pull request found" for a paused refresh', () => { + const state = getChecksPanelReviewState( + input({ reviewLookup: 'unknown', refresh: { status: 'paused' } }) + ) + expect(state.title).toBe('GitHub refresh paused') }) - it('uses remote status as a fallback when eligibility has no concrete blocker', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - hostedReviewBlockedReason: null, - hasUpstream: false - }).title - ).toBe('Branch not published') + it('never says "No pull request found" for a skipped refresh', () => { + const state = getChecksPanelReviewState( + input({ reviewLookup: 'unknown', refresh: { status: 'skipped', skippedReason: 'bare' } }) + ) + expect(state.title).toBe('Bare repository') }) - it('shows unpushed commits before a refresh error', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - hostedReviewBlockedReason: 'needs_push', - hasUpstream: true - }) - ).toEqual({ - title: 'Branch has unpushed commits', - description: 'Push your branch before creating a pull request.' - }) - }) - - it('shows unpublished branch copy even when PR provider eligibility has another blocker', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - hostedReviewBlockedReason: 'unsupported_provider', - hasUpstream: false - }).title - ).toBe('Branch not published') - }) - - it('keeps the generic refresh error when no local branch action is known', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - hostedReviewBlockedReason: null, - hasUpstream: true - }).title - ).toBe('Could not refresh pull request') - }) - - it('uses merge request copy for GitLab review contexts', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: undefined, - hostedReviewBlockedReason: 'unsupported_provider', - hasUpstream: true, + it('uses merge request wording for GitLab', () => { + const state = getChecksPanelReviewState( + input({ reviewLabel: 'merge request', - reviewShortLabel: 'MR' + reviewShortLabel: 'MR', + providerName: 'GitLab', + isGitHubProvider: false, + reviewLookup: 'not_found' }) - ).toEqual({ - title: 'No merge request found', - description: 'Create a merge request to start checks and review.' - }) + ) + expect(state.title).toBe('No merge request found') + }) +}) + +describe('getChecksPanelReviewState — precedence', () => { + it('operation in progress wins', () => { + expect( + getChecksPanelReviewState(input({ operationLabel: 'Rebase', reviewLookup: 'not_found' })) + .title + ).toBe('Rebase in progress') }) - it('uses neutral copy when GitHub hosted-review data exists without PR cache', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: undefined, - hostedReviewBlockedReason: null, - hasUpstream: true, - hasAmbiguousGitHubHostedReview: true + it('renderReview for found lookup', () => { + expect(getChecksPanelReviewState(input({ reviewLookup: 'found' })).renderReview).toBe(true) + }) + + it('positive_unresolved blocks create and offers Open Review', () => { + const state = getChecksPanelReviewState( + input({ reviewLookup: 'positive_unresolved', openReviewUrl: 'https://x/pull/1' }) + ) + expect(state.title).toBe('Pull request details unavailable') + expect(state.composerMode).toBe('hidden') + expect(state.workflowAction).toBeNull() + expect(state.recovery).toContain('open_review') + expect(state.openReviewUrl).toBe('https://x/pull/1') + }) + + it('offers Open Review under a branch blocker when positive evidence has a trusted URL', () => { + // A concurrent branch blocker owns the copy but must not swallow the known + // review: Open Review is exposed while Create / Push & Create stay suppressed. + const state = getChecksPanelReviewState( + input({ + reviewLookup: 'positive_unresolved', + openReviewUrl: 'https://x/pull/7', + eligibilityBlockedReason: 'no_upstream', + hasUpstream: false }) - ).toEqual({ - title: 'Pull request status unavailable', - description: 'Refresh GitHub status for this branch to load checks and review.' - }) + ) + expect(state.title).toBe('No upstream configured') + expect(state.workflowAction).toBe('publish_branch') + expect(state.composerMode).toBe('hidden') + expect(state.recovery).toContain('open_review') + expect(state.openReviewUrl).toBe('https://x/pull/7') }) - it('does not show no-PR copy for paused ambiguous GitHub hosted-review data', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'paused', - hostedReviewBlockedReason: null, - hasUpstream: true, - hasAmbiguousGitHubHostedReview: true - }).title - ).toBe('Pull request status unavailable') + it('needs_sync emits a sync_branch workflow action for the renderer', () => { + const state = getChecksPanelReviewState(input({ eligibilityBlockedReason: 'needs_sync' })) + expect(state.title).toBe('Branch needs to sync') + expect(state.workflowAction).toBe('sync_branch') }) - it('keeps refresh errors for ambiguous GitHub hosted-review data', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - hostedReviewBlockedReason: null, - hasUpstream: true, - hasAmbiguousGitHubHostedReview: true - }).title - ).toBe('Could not refresh pull request') - }) - - it('uses neutral copy for ambiguous GitHub hosted-review data before unpublished branch copy', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: undefined, - hostedReviewBlockedReason: 'no_upstream', + it('branch blocker (no_upstream) ranks above a refresh error', () => { + const state = getChecksPanelReviewState( + input({ + eligibilityBlockedReason: 'no_upstream', hasUpstream: false, - hasAmbiguousGitHubHostedReview: true - }).title - ).toBe('Pull request status unavailable') - }) - - it('uses neutral copy for ambiguous GitHub hosted-review data before remote fallback publish copy', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: undefined, - hostedReviewBlockedReason: undefined, - hasUpstream: false, - hasAmbiguousGitHubHostedReview: true - }).title - ).toBe('Pull request status unavailable') - }) - - it('uses neutral copy for ambiguous GitHub hosted-review data before unpushed commit copy', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: undefined, - hostedReviewBlockedReason: 'needs_push', - hasUpstream: true, - hasAmbiguousGitHubHostedReview: true - }).title - ).toBe('Pull request status unavailable') - }) - - // Regression: a local-only branch with an ambiguous GitHub hosted-review card - // flip-flopped between "Branch not published" (during an active PR refresh) and - // "Pull request status unavailable" (while idle) as background refreshes cycled - // the status. The ambiguous copy must stay stable across the whole lifecycle. - it('keeps stable ambiguous copy while a PR refresh is queued on an unpublished branch', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'queued', - hostedReviewBlockedReason: 'no_upstream', - hasUpstream: false, - hasAmbiguousGitHubHostedReview: true - }).title - ).toBe('Pull request status unavailable') - }) - - it('keeps stable ambiguous copy while a PR refresh is in-flight on an unpublished branch', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'in-flight', - hostedReviewBlockedReason: undefined, - hasUpstream: false, - hasAmbiguousGitHubHostedReview: true - }).title - ).toBe('Pull request status unavailable') - }) - - it('does not fall back to unpublished-branch copy on refresh error for ambiguous data', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - hostedReviewBlockedReason: 'no_upstream', - hasUpstream: false, - hasAmbiguousGitHubHostedReview: true - }).title - ).toBe('Could not refresh pull request') - }) - - it('attributes a 5xx outage to GitHub instead of the generic refresh error', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - prRefreshErrorType: 'server_error', - hostedReviewBlockedReason: null, - hasUpstream: true + refresh: { status: 'error', errorType: 'auth' } }) - ).toEqual({ + ) + expect(state.title).toBe('No upstream configured') + expect(state.workflowAction).toBe('publish_branch') + // Hard error concurrent with the blocker: detail appended, create suppressed. + expect(state.detail).toBe( + 'Orca also could not confirm whether this branch already has a pull request.' + ) + }) + + it('hard refresh error hides the composer', () => { + const state = getChecksPanelReviewState( + input({ refresh: { status: 'error', errorType: 'auth' } }) + ) + expect(state.title).toBe('GitHub authentication failed') + expect(state.composerMode).toBe('hidden') + expect(state.workflowAction).toBeNull() + }) + + it('hard refresh error overrides an accepted no-review', () => { + const state = getChecksPanelReviewState( + input({ reviewLookup: 'not_found', refresh: { status: 'error', errorType: 'permission' } }) + ) + expect(state.title).toBe('GitHub access denied') + }) +}) + +describe('getChecksPanelReviewState — composer preserve', () => { + it('accepted no-review with confirmed readiness opens the composer', () => { + const state = getChecksPanelReviewState( + input({ reviewLookup: 'not_found', confirmedReadiness: true }) + ) + expect(state.composerMode).toBe('confirmed_open') + expect(state.workflowAction).toBe('create') + }) + + it('transient error preserves a confirmed composer', () => { + const state = getChecksPanelReviewState( + input({ confirmedReadiness: true, refresh: { status: 'error', errorType: 'network' } }) + ) + expect(state.composerMode).toBe('confirmed_open') + expect(state.title).toBe('Could not reach GitHub') + }) + + it('attributes a transient 5xx outage to GitHub and preserves a confirmed composer', () => { + const state = getChecksPanelReviewState( + input({ confirmedReadiness: true, refresh: { status: 'error', errorType: 'server_error' } }) + ) + expect(state).toMatchObject({ title: 'GitHub is unavailable', description: - "GitHub's API is temporarily unavailable. This panel reloads automatically once it recovers." + "GitHub's API is temporarily unavailable. This panel reloads automatically once it recovers.", + composerMode: 'confirmed_open', + workflowAction: 'create' }) }) - it('attributes a network failure to GitHub in the ambiguous hosted-review branch', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - prRefreshErrorType: 'network', - hostedReviewBlockedReason: null, - hasUpstream: true, - hasAmbiguousGitHubHostedReview: true - }).title - ).toBe("Can't reach GitHub") - }) - - it('keeps the generic refresh error for non-outage error types', () => { - expect( - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus: 'error', - prRefreshErrorType: 'auth', - hostedReviewBlockedReason: null, - hasUpstream: true - }).title - ).toBe('Could not refresh pull request') - }) - - it('renders a single stable title across a full ambiguous refresh cycle', () => { - const cycle = ['queued', 'in-flight', undefined, 'skipped', 'paused'] as const - const titles = new Set( - cycle.map( - (prRefreshStatus) => - getChecksPanelEmptyStateCopy({ - operationLabel: null, - prRefreshStatus, - hostedReviewBlockedReason: 'no_upstream', - hasUpstream: false, - hasAmbiguousGitHubHostedReview: true - }).title - ) + it('transient error without confirmed readiness never opens a composer', () => { + const state = getChecksPanelReviewState( + input({ confirmedReadiness: false, refresh: { status: 'error', errorType: 'network' } }) ) - expect([...titles]).toEqual(['Pull request status unavailable']) + expect(state.composerMode).toBe('hidden') + }) + + it('needs_push with positive_unresolved never exposes Push & Create', () => { + const state = getChecksPanelReviewState( + input({ + eligibilityBlockedReason: 'needs_push', + reviewLookup: 'positive_unresolved', + confirmedReadiness: true, + confirmedNeedsPush: true + }) + ) + expect(state.composerMode).toBe('hidden') + expect(state.workflowAction).toBeNull() + }) + + it('needs_push with confirmed readiness offers Push & Create', () => { + const state = getChecksPanelReviewState( + input({ + eligibilityBlockedReason: 'needs_push', + reviewLookup: 'not_found', + confirmedReadiness: true, + confirmedNeedsPush: true + }) + ) + expect(state.composerMode).toBe('needs_push_open') + expect(state.workflowAction).toBe('push_and_create') + }) +}) + +describe('getChecksPanelReviewState — schedule', () => { + it('carries auto-retry schedule on the paused row', () => { + const state = getChecksPanelReviewState( + input({ refresh: { status: 'paused', nextAutoRetryAt: 123, retryDisabledUntil: 123 } }) + ) + expect(state.autoRetryAt).toBe(123) + expect(state.retryDisabledUntil).toBe(123) + }) + + it('carries auto-retry schedule on a transient network error', () => { + const state = getChecksPanelReviewState( + input({ refresh: { status: 'error', errorType: 'network', nextAutoRetryAt: 456 } }) + ) + expect(state.autoRetryAt).toBe(456) + expect(state.retryDisabledUntil).toBeUndefined() + }) +}) + +describe('getChecksPanelReviewState — git status', () => { + it('shows loading copy when upstream is unknown', () => { + expect( + getChecksPanelReviewState(input({ gitStatusPhase: 'loading', hasUpstream: undefined })).title + ).toBe('Checking branch status') + }) + + it('shows failed copy when the probe failed and upstream is unknown', () => { + const state = getChecksPanelReviewState( + input({ gitStatusPhase: 'error', hasUpstream: undefined }) + ) + expect(state.title).toBe('Could not check branch status') + expect(state.recovery).toContain('retry') + expect(state.composerMode).toBe('hidden') + }) +}) + +describe('getChecksPanelReviewState — active and skipped', () => { + it('queued shows checking copy', () => { + expect(getChecksPanelReviewState(input({ refresh: { status: 'queued' } })).title).toBe( + 'Checking pull request status' + ) + }) + + it('disconnected host row', () => { + expect( + getChecksPanelReviewState( + input({ refresh: { status: 'skipped', skippedReason: 'disconnected' } }) + ).title + ).toBe('Host disconnected') }) }) @@ -289,21 +271,6 @@ describe('shouldShowChecksPanelPublishBranchAction', () => { ).toBe(true) }) - it('uses remote status even when provider eligibility has a separate blocker', () => { - expect( - shouldShowChecksPanelPublishBranchAction({ - hostedReviewBlockedReason: undefined, - hasUpstream: false - }) - ).toBe(true) - expect( - shouldShowChecksPanelPublishBranchAction({ - hostedReviewBlockedReason: 'unsupported_provider', - hasUpstream: false - }) - ).toBe(true) - }) - it('does not show publish when HEAD is detached', () => { expect( shouldShowChecksPanelPublishBranchAction({ diff --git a/src/renderer/src/components/right-sidebar/checks-panel-empty-state.ts b/src/renderer/src/components/right-sidebar/checks-panel-empty-state.ts index 78dd6610ae4..6679a57fa3f 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-empty-state.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-empty-state.ts @@ -1,40 +1,49 @@ import type { HostedReviewCreationBlockedReason } from '../../../../shared/hosted-review' -import type { PRRefreshUpstreamErrorType } from '../../../../shared/types' import { translate } from '@/i18n/i18n' -import { getGitHubUnavailableEmptyStateCopy } from './github-refresh-error-copy' +import { + autoRetrySchedule, + capitalizeReviewLabel, + confirmedComposerMode, + ELIGIBILITY_SAFETY_BLOCKERS, + isBranchBlocker, + isHardRefreshError, + isTransientRefreshFailure, + workflowActionForComposer, + type ChecksPanelReviewState, + type ChecksPanelReviewStateInput +} from './checks-panel-review-state-model' +import { + concurrentLookupDetail, + hardRefreshErrorState, + skippedRefreshState, + transientRefreshState +} from './checks-panel-review-copy' +import { branchBlockerState, safetyBlockerState } from './checks-panel-blocker-copy' -type PRRefreshStatus = 'queued' | 'in-flight' | 'paused' | 'error' | 'skipped' | undefined - -type ChecksPanelEmptyStateInput = { - operationLabel: string | null - prRefreshStatus: PRRefreshStatus - // Why: lets the error empty-state attribute a GitHub outage (5xx/network/rate - // limit) instead of showing generic "could not refresh" copy that reads like - // an Orca bug. - prRefreshErrorType?: PRRefreshUpstreamErrorType - hostedReviewBlockedReason: HostedReviewCreationBlockedReason | undefined - hasUpstream: boolean | undefined - hasCurrentBranch?: boolean - reviewLabel?: 'pull request' | 'merge request' - reviewShortLabel?: 'PR' | 'MR' - hasAmbiguousGitHubHostedReview?: boolean -} - -type ChecksPanelEmptyStateCopy = { - title: string - description: string -} +export type { + ChecksPanelComposerMode, + ChecksPanelWorkflowAction, + ChecksPanelRecoveryAction, + ChecksPanelReviewState, + ChecksPanelRefreshInput, + ChecksPanelReviewStateInput +} from './checks-panel-review-state-model' /** - * Chooses neutral empty-state copy when GitHub review status is still ambiguous. + * One exhaustive selector for the Checks-panel empty state. Copy, composer mode, + * workflow action, and recovery actions are decided together — never in separate + * branches — so an error, pause, skip, or unknown state can never be rendered as + * "No {reviewLabel} found." See docs/design/pr-panel-refresh-guidance.md. */ -export function getChecksPanelEmptyStateCopy( - input: ChecksPanelEmptyStateInput -): ChecksPanelEmptyStateCopy { - const reviewLabel = input.reviewLabel ?? 'pull request' - const reviewShortLabel = input.reviewShortLabel ?? 'PR' +export function getChecksPanelReviewState( + input: ChecksPanelReviewStateInput +): ChecksPanelReviewState { + const { reviewLabel, reviewShortLabel, providerName } = input + + // 0. Operation in progress — never overridden by refresh copy. if (input.operationLabel) { return { + renderReview: false, title: translate( 'auto.components.right.sidebar.checks.panel.empty.state.d77c513c1e', '{{value0}} in progress', @@ -44,142 +53,186 @@ export function getChecksPanelEmptyStateCopy( 'auto.components.right.sidebar.checks.panel.empty.state.05e4aec17b', '{{value0}} checks will be available after the operation completes', { value0: reviewShortLabel } - ) + ), + composerMode: 'hidden', + workflowAction: null, + recovery: [] } } - const blockedReason = input.hostedReviewBlockedReason - // Why: a GitHub hosted-review card with no cached PR is ambiguous. Resolve the - // whole empty state here so the copy stays stable across a background PR - // refresh's lifecycle (idle/queued/in-flight/paused/skipped). Previously only - // the idle statuses were handled and active ones fell through to the - // publish-branch branch, so the panel flip-flopped between "Branch not - // published" and "Pull request status unavailable" as refreshes cycled. Only a - // hard refresh error still surfaces a distinct — but equally stable — message. - if (input.hasAmbiguousGitHubHostedReview === true) { - if (input.prRefreshStatus === 'error') { - return ( - getGitHubUnavailableEmptyStateCopy(input.prRefreshErrorType) ?? { - title: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.5f478ab3d3', - 'Could not refresh pull request' - ), - description: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.2bdd7aaf2d', - 'GitHub status could not be refreshed. Existing cached data was preserved.' - ) - } - ) - } + // 1. Renderable review wins over every empty state. + if (input.reviewLookup === 'found') { return { + renderReview: true, + title: '', + description: '', + composerMode: 'hidden', + workflowAction: null, + recovery: [] + } + } + + const blockedReason = input.eligibilityBlockedReason + + // 2. Eligibility safety states own their guidance (existing_review is a hard + // create block in the positive-evidence family). + if (blockedReason && ELIGIBILITY_SAFETY_BLOCKERS.has(blockedReason)) { + return safetyBlockerState(input, blockedReason) + } + + // 3. Positive unresolved review evidence: never offer Create / Push & Create. + if (input.reviewLookup === 'positive_unresolved' && !isBranchBlocker(blockedReason)) { + return { + renderReview: false, title: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.3322603418', - 'Pull request status unavailable' + 'auto.components.right.sidebar.checks.panel.review.positive.title', + '{{reviewLabelCap}} details unavailable', + { reviewLabelCap: capitalizeReviewLabel(reviewLabel) } ), description: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.b597440265', - 'Refresh GitHub status for this branch to load checks and review.' - ) + 'auto.components.right.sidebar.checks.panel.review.positive.body', + 'Orca has saved {{reviewLabel}} information for this branch but could not confirm its current status.', + { reviewLabel } + ), + composerMode: 'hidden', + workflowAction: null, + recovery: input.openReviewUrl ? ['open_review', 'retry'] : ['retry'], + openReviewUrl: input.openReviewUrl } } - if ( - shouldShowChecksPanelPublishBranchAction({ - hostedReviewBlockedReason: blockedReason, - hasUpstream: input.hasUpstream, - hasCurrentBranch: input.hasCurrentBranch - }) - ) { - // Why: a local-only branch cannot have GitHub PR status yet; surfacing a - // refresh error here makes a normal pre-publish state look broken. + // 4. Actionable creation blockers (publish / sync / auth / needs_push). + if (isBranchBlocker(blockedReason)) { + return branchBlockerState( + input, + blockedReason as NonNullable + ) + } + // A ready Git status reporting no upstream is a publish state even before + // eligibility resolves. `hasUpstream === undefined` is unknown, never false. + if (input.gitStatusPhase === 'ready' && input.hasUpstream === false && input.hasCurrentBranch) { + return branchBlockerState(input, 'no_upstream') + } + + // 5. Hard refresh error with no blocker above → hide composer until cleared. + if (isHardRefreshError(input.refresh)) { + return hardRefreshErrorState(input) + } + + // 6. Accepted no-review for the exact context. + if (input.reviewLookup === 'not_found') { + const detail = isTransientRefreshFailure(input.refresh) + ? concurrentLookupDetail(input) + : undefined + const mode = confirmedComposerMode(input) return { + renderReview: false, title: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.41252bc53f', - 'Branch not published' + 'auto.components.right.sidebar.checks.panel.review.no_review.title', + 'No {{reviewLabel}} found', + { reviewLabel } ), description: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.f8543140cc', - 'Publish this branch before creating a {{value0}}.', - { value0: reviewLabel } - ) + 'auto.components.right.sidebar.checks.panel.review.no_review.body', + 'Create a {{reviewLabel}} to start checks and review.', + { reviewLabel } + ), + detail, + composerMode: mode, + workflowAction: workflowActionForComposer(mode), + recovery: detail ? ['retry'] : ['refresh'], + ...(detail ? autoRetrySchedule(input) : {}) } } - if (blockedReason === 'needs_push') { + // 7. Transient classified error / rate-limit pause with no accepted result. + if (isTransientRefreshFailure(input.refresh)) { + const mode = confirmedComposerMode(input) + return transientRefreshState(input, mode, workflowActionForComposer(mode)) + } + + // 8. Active refresh (queued / in-flight) with no accepted result. + if (input.refresh?.status === 'queued' || input.refresh?.status === 'in-flight') { + const mode = confirmedComposerMode(input) return { + renderReview: false, title: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.76e15946a9', - 'Branch has unpushed commits' + 'auto.components.right.sidebar.checks.panel.review.active.title', + 'Checking {{reviewLabel}} status', + { reviewLabel } ), description: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.6ce9d4e069', - 'Push your branch before creating a {{value0}}.', - { value0: reviewLabel } - ) + 'auto.components.right.sidebar.checks.panel.review.active.body', + 'Orca is checking {{provider}} for a {{reviewLabel}} on this branch.', + { reviewLabel, provider: providerName } + ), + composerMode: mode, + workflowAction: workflowActionForComposer(mode), + recovery: [] } } - switch (input.prRefreshStatus) { - case 'error': - return ( - getGitHubUnavailableEmptyStateCopy(input.prRefreshErrorType) ?? { - title: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.5f478ab3d3', - 'Could not refresh pull request' - ), - description: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.2bdd7aaf2d', - 'GitHub status could not be refreshed. Existing cached data was preserved.' - ) - } - ) - case 'queued': - return { - title: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.938b5606a6', - 'Checking for pull request' - ), - description: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.6ba2440770', - 'Waiting to refresh GitHub status for this branch' - ) - } - case 'in-flight': - return { - title: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.938b5606a6', - 'Checking for pull request' - ), - description: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.3d4af82ff4', - 'Refreshing GitHub status for this branch' - ) - } - case 'paused': - return { - title: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.7c299df37b', - 'No pull request found' - ), - description: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.d372072df1', - 'GitHub refresh is paused by the current rate-limit budget' - ) - } - case 'skipped': - case undefined: - return { - title: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.13e1c7d5ed', - 'No {{value0}} found', - { value0: reviewLabel } - ), - description: translate( - 'auto.components.right.sidebar.checks.panel.empty.state.5b0cfae9a5', - 'Create a {{value0}} to start checks and review.', - { value0: reviewLabel } - ) - } + // 9. Git status loading / failure when upstream is still unknown. + if (input.gitStatusPhase === 'loading' && input.hasUpstream === undefined) { + return { + renderReview: false, + title: translate( + 'auto.components.right.sidebar.checks.panel.review.git_loading.title', + 'Checking branch status' + ), + description: translate( + 'auto.components.right.sidebar.checks.panel.review.git_loading.body', + 'Orca is checking this branch before showing create or publish actions.' + ), + composerMode: 'hidden', + workflowAction: null, + recovery: [] + } + } + if (input.gitStatusPhase === 'error' && input.hasUpstream === undefined) { + return { + renderReview: false, + title: translate( + 'auto.components.right.sidebar.checks.panel.review.git_error.title', + 'Could not check branch status' + ), + description: translate( + 'auto.components.right.sidebar.checks.panel.review.git_error.body', + "Orca could not confirm this branch's upstream from this environment. Retry before publishing or creating a {{reviewLabel}}.", + { reviewLabel } + ), + composerMode: 'hidden', + workflowAction: null, + recovery: ['retry'] + } + } + + // 10. Skipped structural reasons and missing / unknown — status unavailable. + if (input.refresh?.status === 'skipped' && input.refresh.skippedReason) { + if (input.refresh.skippedReason === 'rate-limit') { + const mode = confirmedComposerMode(input) + return transientRefreshState(input, mode, workflowActionForComposer(mode)) + } + const skipped = skippedRefreshState(input, input.refresh.skippedReason) + if (skipped) { + return skipped + } + } + return { + renderReview: false, + title: translate( + 'auto.components.right.sidebar.checks.panel.review.unknown.title', + '{{reviewLabelCap}} status unavailable', + { reviewLabelCap: capitalizeReviewLabel(reviewLabel) } + ), + description: translate( + 'auto.components.right.sidebar.checks.panel.review.unknown.body', + 'Orca has not confirmed the {{reviewLabel}} status for this branch. Retry to check again.', + { reviewLabel } + ), + composerMode: 'hidden', + workflowAction: null, + recovery: ['retry'] } } diff --git a/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.ts b/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.ts index 897b93b3e22..78063f3b211 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-git-status-snapshot.ts @@ -13,6 +13,10 @@ export type ChecksPanelGitStatusContextInput = { runtimeEnvironmentId: string | null repoConnectionId: string | null pushTarget: GitPushTarget | null | undefined + // Local execution host variant (`wsl:{distro}` vs `host`) so a Windows-host + // result never authorizes a WSL context (or another distro), and vice versa. + // Null for SSH/runtime contexts, which are already scoped by their host ids. + localExecutionScope?: string | null } export type ChecksPanelGitStatusSnapshot = { @@ -60,6 +64,7 @@ export function buildChecksPanelGitStatusContextKey( linkedGiteaPR: input.linkedGiteaPR ?? null, runtimeEnvironmentId: input.runtimeEnvironmentId ?? '', repoConnectionId: input.repoConnectionId ?? '', + localExecutionScope: input.localExecutionScope ?? null, pushTarget: input.pushTarget ? { remoteName: input.pushTarget.remoteName, diff --git a/src/renderer/src/components/right-sidebar/checks-panel-review-copy.ts b/src/renderer/src/components/right-sidebar/checks-panel-review-copy.ts new file mode 100644 index 00000000000..b4486bec769 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel-review-copy.ts @@ -0,0 +1,288 @@ +import type { GitHubPRRefreshSkippedReason } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { getGitHubUnavailableEmptyStateCopy } from './github-refresh-error-copy' +import { + autoRetrySchedule, + capitalizeReviewLabel, + isHardRefreshError, + isRateLimitRefresh, + type ChecksPanelRecoveryAction, + type ChecksPanelReviewState, + type ChecksPanelReviewStateInput +} from './checks-panel-review-state-model' + +type KeyedCopy = { key: string; fallback: string } + +/** + * Muted sentence appended beneath a blocker or accepted-no-review body when a + * concurrent lookup failure or positive-unresolved evidence is also current. + */ +export function concurrentLookupDetail(input: ChecksPanelReviewStateInput): string | undefined { + const { reviewLabel, providerName, refresh } = input + if (input.reviewLookup === 'positive_unresolved') { + return translate( + 'auto.components.right.sidebar.checks.panel.review.detail.positive', + 'Orca also has saved {{reviewLabel}} information that it could not verify.', + { reviewLabel } + ) + } + if (isRateLimitRefresh(refresh)) { + return translate( + 'auto.components.right.sidebar.checks.panel.review.detail.rate_limited', + 'Orca also could not check {{reviewLabel}} status because {{provider}} is temporarily limiting requests.', + { reviewLabel, provider: providerName } + ) + } + if (refresh?.errorType === 'network') { + return translate( + 'auto.components.right.sidebar.checks.panel.review.detail.network', + 'Orca also could not check {{reviewLabel}} status because this environment could not reach {{provider}}.', + { reviewLabel, provider: providerName } + ) + } + if (refresh?.status === 'error' || isHardRefreshError(refresh)) { + return translate( + 'auto.components.right.sidebar.checks.panel.review.detail.untyped', + 'Orca also could not confirm whether this branch already has a {{reviewLabel}}.', + { reviewLabel } + ) + } + return undefined +} + +export function transientRefreshState( + input: ChecksPanelReviewStateInput, + composerMode: ChecksPanelReviewState['composerMode'], + workflowAction: ChecksPanelReviewState['workflowAction'] +): ChecksPanelReviewState { + const { reviewLabel, providerName, refresh } = input + const schedule = autoRetrySchedule(input) + const base = { + renderReview: false as const, + composerMode, + workflowAction, + recovery: ['retry'] as ChecksPanelRecoveryAction[], + ...schedule + } + // Why: preserve the upstream GitHub-attributed 5xx copy in the unified state model. + const githubUnavailableCopy = + refresh?.errorType === 'server_error' + ? getGitHubUnavailableEmptyStateCopy(refresh.errorType) + : null + if (githubUnavailableCopy) { + return { ...base, ...githubUnavailableCopy } + } + if (isRateLimitRefresh(refresh)) { + return { + ...base, + title: translate( + 'auto.components.right.sidebar.checks.panel.review.paused.title', + '{{provider}} refresh paused', + { provider: providerName } + ), + description: translate( + 'auto.components.right.sidebar.checks.panel.review.paused.body', + '{{provider}} is temporarily limiting requests. This can happen even when the displayed API quota is not exhausted.', + { provider: providerName } + ) + } + } + if (refresh?.errorType === 'network') { + return { + ...base, + title: translate( + 'auto.components.right.sidebar.checks.panel.review.network.title', + 'Could not reach {{provider}}', + { provider: providerName } + ), + description: translate( + 'auto.components.right.sidebar.checks.panel.review.network.body', + 'This environment could not reach {{provider}}. Check its connection, then retry.', + { provider: providerName } + ) + } + } + if (refresh?.errorType === 'unknown') { + return { + ...base, + title: translate( + 'auto.components.right.sidebar.checks.panel.review.unknown_error.title', + 'Could not check {{reviewLabel}} status', + { reviewLabel } + ), + description: translate( + 'auto.components.right.sidebar.checks.panel.review.unknown_error.body', + 'The lookup failed, so Orca could not confirm whether this branch already has a {{reviewLabel}}.', + { reviewLabel } + ) + } + } + return { + ...base, + title: translate( + 'auto.components.right.sidebar.checks.panel.review.untyped.title', + '{{reviewLabelCap}} status unavailable', + { reviewLabelCap: capitalizeReviewLabel(reviewLabel) } + ), + description: translate( + 'auto.components.right.sidebar.checks.panel.review.untyped.body', + 'Orca could not confirm whether this branch already has a {{reviewLabel}}. Retry to check again.', + { reviewLabel } + ) + } +} + +const HARD_ERROR_COPY: Record< + 'auth' | 'permission' | 'repo_unavailable' | 'gh_unavailable', + { + title: KeyedCopy + body: KeyedCopy + } +> = { + auth: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.auth.title', + fallback: '{{provider}} authentication failed' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.auth.body', + fallback: + '{{provider}} could not authenticate the credentials available in this environment. Check the {{provider}} login or environment token, then retry.' + } + }, + permission: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.permission.title', + fallback: '{{provider}} access denied' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.permission.body', + fallback: + "The current {{provider}} credentials cannot read this repository's {{reviewLabel}}s. Check the account, token scopes, and repository access, then retry." + } + }, + repo_unavailable: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.repo.title', + fallback: '{{provider}} repository unavailable' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.repo.body', + fallback: + '{{provider}} could not resolve or access the repository for the current remote and account. Check the remote and repository access, then retry.' + } + }, + gh_unavailable: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.cli.title', + fallback: '{{provider}} CLI unavailable' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.cli.body', + fallback: + 'Orca could not run {{provider}} CLI in this environment. Set it up here, then retry.' + } + } +} + +export function hardRefreshErrorState(input: ChecksPanelReviewStateInput): ChecksPanelReviewState { + const { reviewLabel, providerName, refresh } = input + const copy = + HARD_ERROR_COPY[(refresh?.errorType as keyof typeof HARD_ERROR_COPY) ?? 'gh_unavailable'] ?? + HARD_ERROR_COPY.gh_unavailable + const vars = { provider: providerName, reviewLabel } + return { + renderReview: false, + title: translate(copy.title.key, copy.title.fallback, vars), + description: translate(copy.body.key, copy.body.fallback, vars), + composerMode: 'hidden', + workflowAction: null, + recovery: ['retry'] + } +} + +const SKIPPED_COPY: Partial< + Record< + GitHubPRRefreshSkippedReason, + { title: KeyedCopy; body: KeyedCopy; recovery: ChecksPanelRecoveryAction[] } + > +> = { + disconnected: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.skipped.disconnected.title', + fallback: 'Host disconnected' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.skipped.disconnected.body', + fallback: + "This repository's execution host is disconnected, so Orca cannot refresh {{reviewLabel}} status." + }, + recovery: ['retry'] + }, + bare: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.skipped.bare.title', + fallback: 'Bare repository' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.skipped.bare.body', + fallback: 'This repository is bare, so {{reviewLabel}} status is not available here.' + }, + recovery: [] + }, + archived: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.skipped.archived.title', + fallback: 'Repository archived' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.skipped.archived.body', + fallback: 'This repository is archived, so Orca is not refreshing {{reviewLabel}} status.' + }, + recovery: [] + }, + 'not-git': { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.skipped.not_git.title', + fallback: 'Not a Git repository' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.skipped.not_git.body', + fallback: 'Orca could not treat this folder as a Git repository for {{reviewLabel}} status.' + }, + recovery: [] + }, + remote: { + title: { + key: 'auto.components.right.sidebar.checks.panel.review.skipped.remote.title', + fallback: 'Remote-only context' + }, + body: { + key: 'auto.components.right.sidebar.checks.panel.review.skipped.remote.body', + fallback: + 'Orca could not refresh {{reviewLabel}} status for this remote context. Retry after the host is available.' + }, + recovery: ['retry'] + } +} + +/** Returns the skipped-reason row, or `null` to defer to the missing/unknown row. */ +export function skippedRefreshState( + input: ChecksPanelReviewStateInput, + skippedReason: GitHubPRRefreshSkippedReason +): ChecksPanelReviewState | null { + const copy = SKIPPED_COPY[skippedReason] + if (!copy) { + // `fresh` (and any unknown skip) with no accepted result → missing/unknown. + return null + } + const vars = { reviewLabel: input.reviewLabel } + return { + renderReview: false, + title: translate(copy.title.key, copy.title.fallback, vars), + description: translate(copy.body.key, copy.body.fallback, vars), + composerMode: 'hidden', + workflowAction: null, + recovery: copy.recovery + } +} diff --git a/src/renderer/src/components/right-sidebar/checks-panel-review-creation.test.ts b/src/renderer/src/components/right-sidebar/checks-panel-review-creation.test.ts index 69d1dc32690..4a53eab87b0 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-review-creation.test.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-review-creation.test.ts @@ -1,9 +1,33 @@ import { describe, expect, it } from 'vitest' +import type { + HostedReviewCreationBlockedReason, + HostedReviewCreationEligibility, + HostedReviewLookupOutcome +} from '../../../../shared/hosted-review' import { + computeChecksPanelConfirmedReadiness, + isChecksPanelCreateEligibilityConfirmable, + isChecksPanelHardErrorCleared, + isChecksPanelHardRefreshErrorType, resolveChecksPanelHostedReviewBaseRef, - shouldOpenChecksPanelCreateComposer + shouldOpenChecksPanelCreateComposer, + type ChecksPanelConfirmedReadinessInput } from './checks-panel-review-creation' +function eligibility( + overrides: Partial = {} +): HostedReviewCreationEligibility { + return { + provider: 'github', + review: null, + canCreate: true, + blockedReason: null, + nextAction: null, + reviewLookupOutcome: 'not_found', + ...overrides + } +} + describe('resolveChecksPanelHostedReviewBaseRef', () => { it('prefers the worktree base ref over the repo default', () => { expect( @@ -14,66 +38,70 @@ describe('resolveChecksPanelHostedReviewBaseRef', () => { ).toBe('release/1.4') }) - it('falls back to the repo base ref when the worktree has no override', () => { - expect( - resolveChecksPanelHostedReviewBaseRef({ - worktreeBaseRef: null, - repoBaseRef: ' main ' - }) - ).toBe('main') - }) - it('returns null when both inputs are null', () => { expect( - resolveChecksPanelHostedReviewBaseRef({ - worktreeBaseRef: null, - repoBaseRef: null - }) + resolveChecksPanelHostedReviewBaseRef({ worktreeBaseRef: null, repoBaseRef: null }) ).toBe(null) }) +}) - it('returns null when worktree base ref is whitespace-only', () => { +describe('isChecksPanelCreateEligibilityConfirmable (shared desktop/mobile floor)', () => { + const base = { reviewLookup: 'not_found' as const, hasHardRefreshError: false } + + it('allows canCreate and needs_push when nothing hard-blocks', () => { + expect(isChecksPanelCreateEligibilityConfirmable({ ...base, eligibility: eligibility() })).toBe( + true + ) expect( - resolveChecksPanelHostedReviewBaseRef({ - worktreeBaseRef: ' ', - repoBaseRef: null + isChecksPanelCreateEligibilityConfirmable({ + ...base, + eligibility: eligibility({ canCreate: false, blockedReason: 'needs_push' }) }) - ).toBe(null) + ).toBe(true) }) - it('strips origin prefix from the worktree base ref', () => { + it('fails closed on every hard-block: null, existing_review, positive, hard error, unavailable', () => { + expect(isChecksPanelCreateEligibilityConfirmable({ ...base, eligibility: null })).toBe(false) expect( - resolveChecksPanelHostedReviewBaseRef({ - worktreeBaseRef: 'origin/main', - repoBaseRef: 'develop' + isChecksPanelCreateEligibilityConfirmable({ + ...base, + eligibility: eligibility({ canCreate: false, blockedReason: 'existing_review' }) }) - ).toBe('main') - }) - - it('strips upstream prefix from the repo base ref', () => { + ).toBe(false) expect( - resolveChecksPanelHostedReviewBaseRef({ - worktreeBaseRef: null, - repoBaseRef: 'upstream/develop' + isChecksPanelCreateEligibilityConfirmable({ + ...base, + eligibility: eligibility(), + reviewLookup: 'positive_unresolved' }) - ).toBe('develop') + ).toBe(false) + expect( + isChecksPanelCreateEligibilityConfirmable({ + ...base, + eligibility: eligibility(), + hasHardRefreshError: true + }) + ).toBe(false) + expect( + isChecksPanelCreateEligibilityConfirmable({ + ...base, + eligibility: eligibility({ + blockedReason: 'needs_push', + reviewLookupOutcome: 'unavailable' + }) + }) + ).toBe(false) }) }) describe('shouldOpenChecksPanelCreateComposer', () => { - it('opens for GitLab MR creation eligibility', () => { + it('opens for creation eligibility', () => { expect( shouldOpenChecksPanelCreateComposer({ activeReview: null, isFolder: false, - branch: 'feature/gitlab-mr', - hostedReviewCreation: { - provider: 'gitlab', - review: null, - canCreate: true, - blockedReason: null, - nextAction: null - } + branch: 'feature/x', + hostedReviewCreation: eligibility({ canCreate: true }) }) ).toBe(true) }) @@ -83,66 +111,281 @@ describe('shouldOpenChecksPanelCreateComposer', () => { shouldOpenChecksPanelCreateComposer({ activeReview: null, isFolder: false, - branch: 'feature/gitlab-mr', - hostedReviewCreation: { - provider: 'gitlab', - review: null, - canCreate: false, - blockedReason: 'needs_push', - nextAction: 'push' - } + branch: 'feature/x', + hostedReviewCreation: eligibility({ canCreate: false, blockedReason: 'needs_push' }) }) ).toBe(true) }) - it('does not open when an active review exists', () => { + it('does not open on positive unresolved review evidence', () => { expect( shouldOpenChecksPanelCreateComposer({ - activeReview: { provider: 'github', number: 123 }, + activeReview: null, isFolder: false, - branch: 'feature/test', - hostedReviewCreation: { - provider: 'github', - review: null, - canCreate: true, - blockedReason: null, - nextAction: null - } + branch: 'feature/x', + hostedReviewCreation: eligibility({ canCreate: false, blockedReason: 'needs_push' }), + reviewLookup: 'positive_unresolved' }) ).toBe(false) }) - it('does not open for folder repos', () => { + it('does not open during a hard refresh error', () => { + expect( + shouldOpenChecksPanelCreateComposer({ + activeReview: null, + isFolder: false, + branch: 'feature/x', + hostedReviewCreation: eligibility({ canCreate: true }), + hasHardRefreshError: true + }) + ).toBe(false) + }) + + it('does not open when a review already exists', () => { + expect( + shouldOpenChecksPanelCreateComposer({ + activeReview: null, + isFolder: false, + branch: 'feature/x', + hostedReviewCreation: eligibility({ canCreate: false, blockedReason: 'existing_review' }) + }) + ).toBe(false) + }) + + it('does not open for folders or empty branches', () => { expect( shouldOpenChecksPanelCreateComposer({ activeReview: null, isFolder: true, - branch: 'feature/test', - hostedReviewCreation: { - provider: 'github', - review: null, - canCreate: true, - blockedReason: null, - nextAction: null - } + branch: 'feature/x', + hostedReviewCreation: eligibility() }) ).toBe(false) - }) - - it('does not open when branch is empty', () => { expect( shouldOpenChecksPanelCreateComposer({ activeReview: null, isFolder: false, branch: '', - hostedReviewCreation: { - provider: 'github', - review: null, - canCreate: true, - blockedReason: null, - nextAction: null - } + hostedReviewCreation: eligibility() }) ).toBe(false) }) }) + +describe('isChecksPanelHardRefreshErrorType', () => { + it('flags the four hard error types', () => { + for (const t of ['auth', 'permission', 'repo_unavailable', 'gh_unavailable']) { + expect(isChecksPanelHardRefreshErrorType(t)).toBe(true) + } + }) + it('does not flag transient types or undefined', () => { + for (const t of ['rate_limited', 'network', 'unknown']) { + expect(isChecksPanelHardRefreshErrorType(t)).toBe(false) + } + expect(isChecksPanelHardRefreshErrorType(undefined)).toBe(false) + }) +}) + +describe('computeChecksPanelConfirmedReadiness', () => { + const NOW = 1_000_000 + function readiness( + overrides: Partial = {} + ): ChecksPanelConfirmedReadinessInput { + return { + contextKeyMatches: true, + eligibility: { canCreate: true, blockedReason: null, reviewLookupOutcome: 'not_found' }, + eligibilityCompletedAt: NOW - 1_000, + eligibilityRequestStartedAt: NOW - 2_000, + reviewLookup: 'not_found', + hardErrorObservedAt: undefined, + gitSnapshotMatches: true, + now: NOW, + ...overrides + } + } + + it('confirms fresh, matching eligibility', () => { + expect(computeChecksPanelConfirmedReadiness(readiness())).toEqual({ + confirmed: true, + needsPush: false + }) + }) + + it('confirms needs_push as the Push & Create path', () => { + expect( + computeChecksPanelConfirmedReadiness( + readiness({ + eligibility: { + canCreate: false, + blockedReason: 'needs_push', + reviewLookupOutcome: 'not_found' + } + }) + ) + ).toEqual({ confirmed: true, needsPush: true }) + }) + + it('drops confirmed when the context key does not match', () => { + expect( + computeChecksPanelConfirmedReadiness(readiness({ contextKeyMatches: false })).confirmed + ).toBe(false) + }) + + it('drops confirmed on positive unresolved and existing_review', () => { + expect( + computeChecksPanelConfirmedReadiness(readiness({ reviewLookup: 'positive_unresolved' })) + .confirmed + ).toBe(false) + expect( + computeChecksPanelConfirmedReadiness( + readiness({ + eligibility: { + canCreate: false, + blockedReason: 'existing_review', + reviewLookupOutcome: 'found' + } + }) + ).confirmed + ).toBe(false) + }) + + it('drops confirmed when eligibility is older than 5 minutes', () => { + expect( + computeChecksPanelConfirmedReadiness( + readiness({ eligibilityCompletedAt: NOW - 5 * 60_000 - 1 }) + ).confirmed + ).toBe(false) + }) + + it('drops confirmed when the git snapshot no longer matches', () => { + expect( + computeChecksPanelConfirmedReadiness(readiness({ gitSnapshotMatches: false })).confirmed + ).toBe(false) + }) + + it('preserves confirmed across a transient refresh failure once past the error', () => { + // The panel keeps the last confirmed eligibility snapshot; a transient + // failure (no hard error observed) must not drop confirmed. + expect( + computeChecksPanelConfirmedReadiness( + readiness({ hardErrorObservedAt: undefined, reviewLookup: 'not_found' }) + ) + ).toEqual({ confirmed: true, needsPush: false }) + }) + + describe('hard error clearing', () => { + it('clears when the request started strictly after the error with an accepted outcome', () => { + expect( + computeChecksPanelConfirmedReadiness( + readiness({ + hardErrorObservedAt: NOW - 3_000, + eligibilityRequestStartedAt: NOW - 2_000, + eligibility: { canCreate: true, blockedReason: null, reviewLookupOutcome: 'not_found' } + }) + ).confirmed + ).toBe(true) + }) + + it('does not clear for an in-flight request that started before the error', () => { + expect( + computeChecksPanelConfirmedReadiness( + readiness({ hardErrorObservedAt: NOW - 1_500, eligibilityRequestStartedAt: NOW - 2_000 }) + ).confirmed + ).toBe(false) + }) + + it('does not clear on a late unavailable fallback', () => { + expect( + computeChecksPanelConfirmedReadiness( + readiness({ + hardErrorObservedAt: NOW - 3_000, + eligibilityRequestStartedAt: NOW - 2_000, + eligibility: { + canCreate: false, + blockedReason: 'no_upstream', + reviewLookupOutcome: 'unavailable' + } + }) + ).confirmed + ).toBe(false) + }) + }) +}) + +describe('isChecksPanelHardErrorCleared', () => { + const NOW = 1_000_000 + function cleared( + overrides: Partial = {} + ): ChecksPanelConfirmedReadinessInput { + return { + contextKeyMatches: true, + eligibility: { canCreate: true, blockedReason: null, reviewLookupOutcome: 'not_found' }, + eligibilityCompletedAt: NOW - 1_000, + eligibilityRequestStartedAt: NOW - 2_000, + reviewLookup: 'not_found', + hardErrorObservedAt: NOW - 3_000, + gitSnapshotMatches: true, + now: NOW, + ...overrides + } + } + + it('is cleared (true) when no hard error is observed', () => { + expect(isChecksPanelHardErrorCleared(cleared({ hardErrorObservedAt: undefined }))).toBe(true) + }) + + it('stays blocked while only a queued/in-flight retry replaced the error', () => { + // No request that both started after the error and settled with an accepted + // outcome, so Create must not flap back. + expect(isChecksPanelHardErrorCleared(cleared({ eligibilityRequestStartedAt: undefined }))).toBe( + false + ) + }) + + it('stays blocked for a request that started before the error', () => { + expect( + isChecksPanelHardErrorCleared( + cleared({ hardErrorObservedAt: NOW - 1_000, eligibilityRequestStartedAt: NOW - 2_000 }) + ) + ).toBe(false) + }) + + it('stays blocked when the clearing request is for a different context', () => { + expect(isChecksPanelHardErrorCleared(cleared({ contextKeyMatches: false }))).toBe(false) + }) + + it('stays blocked on an unavailable outcome even if the request started after', () => { + expect( + isChecksPanelHardErrorCleared( + cleared({ + eligibility: { + canCreate: false, + blockedReason: 'no_upstream', + reviewLookupOutcome: 'unavailable' + } + }) + ) + ).toBe(false) + }) + + it('clears when a later request settles found or not_found', () => { + expect(isChecksPanelHardErrorCleared(cleared())).toBe(true) + expect( + isChecksPanelHardErrorCleared( + cleared({ + eligibility: { + canCreate: false, + blockedReason: 'existing_review', + reviewLookupOutcome: 'found' + } + }) + ) + ).toBe(true) + }) +}) + +// Type-only usage to keep the imports meaningful across refactors. +const _blocked: HostedReviewCreationBlockedReason = null +const _outcome: HostedReviewLookupOutcome = 'not_found' +void _blocked +void _outcome diff --git a/src/renderer/src/components/right-sidebar/checks-panel-review-creation.ts b/src/renderer/src/components/right-sidebar/checks-panel-review-creation.ts index 11e42d40fe1..95c9ccfde97 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-review-creation.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel-review-creation.ts @@ -1,5 +1,14 @@ -import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' +import type { + HostedReviewCreationBlockedReason, + HostedReviewCreationEligibility, + HostedReviewLookupOutcome +} from '../../../../shared/hosted-review' +import type { PRRefreshErrorType } from '../../../../shared/types' import { normalizeHostedReviewBaseRef } from '../../../../shared/hosted-review-refs' +import type { ChecksPanelReviewLookup } from './checks-panel-review-lookup-authority' +// Single source of truth for the hard-refresh error set, shared with the +// review-state model so the composer gate and empty-state selector cannot drift. +import { HARD_REFRESH_ERROR_TYPES } from './checks-panel-review-state-model' export function resolveChecksPanelHostedReviewBaseRef(input: { worktreeBaseRef?: string | null @@ -14,17 +23,160 @@ function normalizeChecksPanelHostedReviewBaseRef(ref: string | null | undefined) return normalizedRef || null } +/** + * Shared create-eligibility floor for the desktop confirmed gate and the + * mobile-shared composer gate. Create / Push & Create is impossible when review + * existence cannot be proven no-review: positive unresolved evidence, a current + * hard refresh error, `existing_review`, or an `unavailable` existing-review + * lookup all fail closed. Otherwise it allows the `canCreate` / `needs_push` + * paths. Both callers route through this so their hard-block semantics cannot + * drift; the desktop confirmed gate layers freshness/context checks on top. + */ +export function isChecksPanelCreateEligibilityConfirmable(input: { + eligibility: Pick< + HostedReviewCreationEligibility, + 'canCreate' | 'blockedReason' | 'reviewLookupOutcome' + > | null + reviewLookup: ChecksPanelReviewLookup + hasHardRefreshError: boolean +}): boolean { + const eligibility = input.eligibility + if (!eligibility || eligibility.blockedReason === 'existing_review') { + return false + } + if (input.reviewLookup === 'positive_unresolved' || input.hasHardRefreshError) { + return false + } + // An `unavailable` existing-review lookup could be hiding a real PR. Block even + // the Push & Create (needs_push) path, which would otherwise slip through the + // canCreate check below with review existence unproven. + if (eligibility.reviewLookupOutcome === 'unavailable') { + return false + } + return eligibility.canCreate === true || eligibility.blockedReason === 'needs_push' +} + +/** + * Confirmed-only composer gate shared with mobile. Delegates the hard-block + * decision to {@link isChecksPanelCreateEligibilityConfirmable} so the desktop + * confirmed gate and this mobile-shared gate cannot drift. Phase 1 adds no + * provisional or draft-preserve path. + */ export function shouldOpenChecksPanelCreateComposer(input: { activeReview: unknown | null isFolder: boolean branch: string hostedReviewCreation: HostedReviewCreationEligibility | null + reviewLookup?: ChecksPanelReviewLookup + hasHardRefreshError?: boolean }): boolean { - return ( - !input.activeReview && - !input.isFolder && - Boolean(input.branch) && - (input.hostedReviewCreation?.canCreate === true || - input.hostedReviewCreation?.blockedReason === 'needs_push') - ) + if (input.activeReview || input.isFolder || !input.branch) { + return false + } + return isChecksPanelCreateEligibilityConfirmable({ + eligibility: input.hostedReviewCreation, + reviewLookup: input.reviewLookup ?? 'unknown', + hasHardRefreshError: input.hasHardRefreshError === true + }) } + +const CONFIRMED_ELIGIBILITY_MAX_AGE_MS = 5 * 60_000 + +export function isChecksPanelHardRefreshErrorType(errorType: string | undefined): boolean { + return errorType != null && HARD_REFRESH_ERROR_TYPES.has(errorType as PRRefreshErrorType) +} + +export type ChecksPanelConfirmedReadiness = { + confirmed: boolean + /** The confirmed path is Push & Create rather than plain Create. */ + needsPush: boolean +} + +export type ChecksPanelConfirmedReadinessInput = { + /** The eligibility result's context key equals the panel's current context. */ + contextKeyMatches: boolean + eligibility: Pick< + HostedReviewCreationEligibility, + 'canCreate' | 'blockedReason' | 'reviewLookupOutcome' + > | null + /** Wall-clock time the eligibility result settled. */ + eligibilityCompletedAt?: number + /** Wall-clock time the eligibility request that produced the result started. */ + eligibilityRequestStartedAt?: number + reviewLookup: ChecksPanelReviewLookup + /** + * Observation time of the most recent hard refresh error for this context, or + * undefined when no hard error is current. + */ + hardErrorObservedAt?: number + /** + * Whether the Git snapshot used for the eligibility still matches current HEAD, + * branch, upstream/ahead/behind/dirty, base, and execution-host fields. + */ + gitSnapshotMatches: boolean + now: number +} + +const NOT_CONFIRMED: ChecksPanelConfirmedReadiness = { confirmed: false, needsPush: false } + +/** + * A hard error is cleared only by an eligibility request that started strictly + * after the error was observed, completed for the same exact context with a + * `found` / `not_found` lookup outcome, with no newer hard error since. A late + * `unavailable` fallback or an already-in-flight request cannot clear it. + * + * Exported so the panel can keep the hard-error block sticky across + * `queued` / `in-flight` auto-retries (which drop `status: 'error'` and would + * otherwise let Create flap back) until this predicate says it is cleared. + */ +export function isChecksPanelHardErrorCleared(input: ChecksPanelConfirmedReadinessInput): boolean { + if (input.hardErrorObservedAt === undefined) { + return true + } + const startedAt = input.eligibilityRequestStartedAt + if (startedAt === undefined || !(startedAt > input.hardErrorObservedAt)) { + return false + } + if (!input.contextKeyMatches) { + return false + } + const outcome: HostedReviewLookupOutcome | undefined = input.eligibility?.reviewLookupOutcome + return outcome === 'found' || outcome === 'not_found' +} + +/** + * Confirmed readiness for the exact context. Any failed check drops confirmed + * immediately; transient refresh failures never appear here (the caller only + * passes a hard error observation, never a transient one). + */ +export function computeChecksPanelConfirmedReadiness( + input: ChecksPanelConfirmedReadinessInput +): ChecksPanelConfirmedReadiness { + const eligibility = input.eligibility + if (!input.contextKeyMatches) { + return NOT_CONFIRMED + } + // Share the hard-block floor with the mobile-shared gate; an uncleared hard + // error is folded in as the `hasHardRefreshError` input so the two cannot drift. + if ( + !isChecksPanelCreateEligibilityConfirmable({ + eligibility, + reviewLookup: input.reviewLookup, + hasHardRefreshError: !isChecksPanelHardErrorCleared(input) + }) + ) { + return NOT_CONFIRMED + } + // Freshness: eligibility must be recent and its Git snapshot must still match. + if ( + input.eligibilityCompletedAt === undefined || + input.now - input.eligibilityCompletedAt > CONFIRMED_ELIGIBILITY_MAX_AGE_MS || + !input.gitSnapshotMatches + ) { + return NOT_CONFIRMED + } + return { confirmed: true, needsPush: eligibility?.blockedReason === 'needs_push' } +} + +// Re-exported for callers that need the blocker type alongside the gate. +export type { HostedReviewCreationBlockedReason } diff --git a/src/renderer/src/components/right-sidebar/checks-panel-review-lookup-authority.test.ts b/src/renderer/src/components/right-sidebar/checks-panel-review-lookup-authority.test.ts new file mode 100644 index 00000000000..36a814a930c --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel-review-lookup-authority.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { + isRenderableChecksPanelReviewDetails, + normalizeTrustedReviewUrl, + resolveChecksPanelReviewLookup +} from './checks-panel-review-lookup-authority' + +const EMPTY = { + pr: null, + prCachedHasPR: null, + hostedReview: null +} as const + +describe('resolveChecksPanelReviewLookup', () => { + it('returns found with a trusted URL when PR details are renderable', () => { + expect( + resolveChecksPanelReviewLookup({ + ...EMPTY, + pr: { number: 42, url: 'https://github.com/o/r/pull/42' } + }) + ).toEqual({ state: 'found', openReviewUrl: 'https://github.com/o/r/pull/42' }) + }) + + it('does not treat a summary without a positive number as found', () => { + expect( + resolveChecksPanelReviewLookup({ + ...EMPTY, + pr: { number: 0, url: 'https://github.com/o/r/pull/0' }, + hostedReview: { provider: 'github', url: 'https://github.com/o/r/pull/1', number: 1 } + }).state + ).toBe('positive_unresolved') + }) + + it('classifies a github hosted-review card with no PR cache as positive_unresolved', () => { + // Replaces the old hasAmbiguousGitHubHostedReview boolean. + expect( + resolveChecksPanelReviewLookup({ + ...EMPTY, + prCachedHasPR: null, + hostedReview: { provider: 'github', number: 7, url: 'https://github.com/o/r/pull/7' } + }) + ).toEqual({ state: 'positive_unresolved', openReviewUrl: 'https://github.com/o/r/pull/7' }) + }) + + it('keeps positive evidence over an accepted null no-PR entry (conflict)', () => { + expect( + resolveChecksPanelReviewLookup({ + ...EMPTY, + prCachedHasPR: false, + linkedReviewNumber: 5 + }).state + ).toBe('positive_unresolved') + }) + + it('returns not_found for an accepted no-PR cache entry', () => { + expect(resolveChecksPanelReviewLookup({ ...EMPTY, prCachedHasPR: false })).toEqual({ + state: 'not_found', + openReviewUrl: null + }) + }) + + it('returns not_found for eligibility not_found', () => { + expect( + resolveChecksPanelReviewLookup({ + ...EMPTY, + eligibilityReviewLookupOutcome: 'not_found' + }).state + ).toBe('not_found') + }) + + it('treats eligibility found as positive_unresolved when no renderable details', () => { + expect( + resolveChecksPanelReviewLookup({ + ...EMPTY, + eligibilityReviewLookupOutcome: 'found', + eligibilityReview: { url: 'https://gitlab.com/o/r/-/merge_requests/3' } + }) + ).toEqual({ + state: 'positive_unresolved', + openReviewUrl: 'https://gitlab.com/o/r/-/merge_requests/3' + }) + }) + + it('returns unknown for a missing entry and unavailable eligibility', () => { + expect( + resolveChecksPanelReviewLookup({ + ...EMPTY, + prCachedHasPR: null, + eligibilityReviewLookupOutcome: 'unavailable' + }).state + ).toBe('unknown') + }) + + it('does not offer Open Review for a non-http url', () => { + expect( + resolveChecksPanelReviewLookup({ + ...EMPTY, + hostedReview: { provider: 'github', number: 9, url: 'javascript:alert(1)' } + }) + ).toEqual({ state: 'positive_unresolved', openReviewUrl: null }) + }) +}) + +describe('normalizeTrustedReviewUrl', () => { + it('accepts http and https', () => { + expect(normalizeTrustedReviewUrl(' https://x.test/pull/1 ')).toBe('https://x.test/pull/1') + expect(normalizeTrustedReviewUrl('http://x.test/pull/1')).toBe('http://x.test/pull/1') + }) + + it('rejects empty and non-web schemes', () => { + expect(normalizeTrustedReviewUrl(null)).toBe(null) + expect(normalizeTrustedReviewUrl('')).toBe(null) + expect(normalizeTrustedReviewUrl('ftp://x.test')).toBe(null) + expect(normalizeTrustedReviewUrl('file:///etc/passwd')).toBe(null) + expect(normalizeTrustedReviewUrl('not a url')).toBe(null) + }) + + it('rejects credential-bearing URLs so tokens never reach the browser/shell', () => { + expect(normalizeTrustedReviewUrl('https://user:token@git.test/repo/pull/1')).toBe(null) + expect(normalizeTrustedReviewUrl('https://user@git.test/repo/pull/1')).toBe(null) + }) +}) + +describe('isRenderableChecksPanelReviewDetails', () => { + it('requires a positive number', () => { + expect(isRenderableChecksPanelReviewDetails({ number: 1 })).toBe(true) + expect(isRenderableChecksPanelReviewDetails({ number: 0 })).toBe(false) + expect(isRenderableChecksPanelReviewDetails(null)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel-review-lookup-authority.ts b/src/renderer/src/components/right-sidebar/checks-panel-review-lookup-authority.ts new file mode 100644 index 00000000000..55d24fefd39 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel-review-lookup-authority.ts @@ -0,0 +1,120 @@ +import type { HostedReviewLookupOutcome } from '../../../../shared/hosted-review' +import { isPositiveHostedReviewNumber } from '../../../../shared/hosted-review' + +/** + * Four-state review evidence model for the Checks panel. Replaces the single + * `hasAmbiguousGitHubHostedReview` Boolean, which collapsed materially different + * states (missing lookup, failed lookup, positive-but-unrenderable evidence). + * + * - `found`: current review details are renderable — leave the empty state. + * - `positive_unresolved`: a review is known to exist but details are not + * renderable. Never offer Create / Push & Create; prefer trusted Open Review. + * - `not_found`: an accepted no-review result for the exact context. + * - `unknown`: no accepted positive or no-review result (missing entry, failed + * or skipped lookup, in-flight). + */ +export type ChecksPanelReviewLookup = 'found' | 'positive_unresolved' | 'not_found' | 'unknown' + +type ReviewLookupNumberLike = number | null | undefined + +export type ChecksPanelReviewLookupInput = { + /** + * Renderable review details for the exact context (GitHub `PRInfo` payload or + * equivalent). Treated as renderable only when it carries a usable identity. + */ + pr: { number?: ReviewLookupNumberLike; url?: string | null } | null | undefined + /** + * Tri-state from the PR cache entry: `true` = has PR, `false` = accepted + * no-PR, `null` = no accepted entry (missing / never fetched). Distinguishes an + * accepted no-review outcome from a missing lookup. + */ + prCachedHasPR: boolean | null + /** Positive hosted-review cache for the exact context, when present. */ + hostedReview: + | { provider?: string | null; number?: ReviewLookupNumberLike; url?: string | null } + | null + | undefined + /** Durable linked review number for the exact context. */ + linkedReviewNumber?: ReviewLookupNumberLike + /** Settled eligibility lookup outcome for the exact context. */ + eligibilityReviewLookupOutcome?: HostedReviewLookupOutcome | null + /** Eligibility review summary (may be summary-only). */ + eligibilityReview?: { number?: ReviewLookupNumberLike; url?: string | null } | null +} + +export type ChecksPanelReviewLookupResult = { + state: ChecksPanelReviewLookup + /** Trusted http(s) URL for Open Review, when one is known; else null. */ + openReviewUrl: string | null +} + +/** + * Returns the URL only when it is a plain http(s) link with no embedded + * credentials, so a credential-bearing or non-web value never reaches an Open + * Review affordance (and never lands in browser history / shell logs). + */ +export function normalizeTrustedReviewUrl(url: string | null | undefined): string | null { + if (!url) { + return null + } + const trimmed = url.trim() + let parsed: URL + try { + parsed = new URL(trimmed) + } catch { + return null + } + // Reject non-web schemes and any userinfo (`user:token@host`) — a prefix regex + // alone would forward embedded credentials to the browser/shell. + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null + } + if (parsed.username !== '' || parsed.password !== '') { + return null + } + return trimmed +} + +/** + * Review details render the normal review chrome only with a usable identity: a + * positive review number. A provider-neutral summary without renderable details + * maps to `positive_unresolved`, not `found`. + */ +export function isRenderableChecksPanelReviewDetails( + pr: { number?: ReviewLookupNumberLike } | null | undefined +): boolean { + return pr != null && isPositiveHostedReviewNumber(pr.number) +} + +export function resolveChecksPanelReviewLookup( + input: ChecksPanelReviewLookupInput +): ChecksPanelReviewLookupResult { + if (isRenderableChecksPanelReviewDetails(input.pr)) { + return { + state: 'found', + openReviewUrl: normalizeTrustedReviewUrl(input.pr?.url) + } + } + + // Positive evidence wins over an accepted null no-PR entry: a conflict must + // stay `positive_unresolved` until a newer accepted result clears it, never + // collapse to "no PR." + const openReviewUrl = + normalizeTrustedReviewUrl(input.hostedReview?.url) ?? + normalizeTrustedReviewUrl(input.eligibilityReview?.url) + const hasPositiveEvidence = + isPositiveHostedReviewNumber(input.linkedReviewNumber) || + isPositiveHostedReviewNumber(input.hostedReview?.number) || + isPositiveHostedReviewNumber(input.eligibilityReview?.number) || + input.eligibilityReviewLookupOutcome === 'found' || + (input.hostedReview != null && openReviewUrl !== null) + if (hasPositiveEvidence) { + return { state: 'positive_unresolved', openReviewUrl } + } + + if (input.prCachedHasPR === false || input.eligibilityReviewLookupOutcome === 'not_found') { + return { state: 'not_found', openReviewUrl: null } + } + + return { state: 'unknown', openReviewUrl: null } +} diff --git a/src/renderer/src/components/right-sidebar/checks-panel-review-state-model.ts b/src/renderer/src/components/right-sidebar/checks-panel-review-state-model.ts new file mode 100644 index 00000000000..4b43615ebd9 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel-review-state-model.ts @@ -0,0 +1,165 @@ +import type { HostedReviewCreationBlockedReason } from '../../../../shared/hosted-review' +import type { GitHubPRRefreshSkippedReason, PRRefreshErrorType } from '../../../../shared/types' +import type { ChecksPanelReviewLookup } from './checks-panel-review-lookup-authority' + +/** + * Composer visibility the panel should render. Phase 1 never *opens* a new + * composer from a refresh failure — `confirmed_open` / `needs_push_open` mean an + * already-confirmed composer stays open. (Phase 2's disabled draft-preserve mode + * is intentionally absent until that phase ships.) + */ +export type ChecksPanelComposerMode = 'hidden' | 'confirmed_open' | 'needs_push_open' + +export type ChecksPanelWorkflowAction = + | 'create' + | 'push_and_create' + | 'publish_branch' + | 'sync_branch' + | 'authenticate' + | null + +export type ChecksPanelRecoveryAction = 'retry' | 'refresh' | 'open_review' + +export type ChecksPanelReviewState = { + /** True only for `reviewLookup: found` — the caller renders review chrome. */ + renderReview: boolean + title: string + description: string + /** One muted sentence appended beneath the body (concurrent lookup failure). */ + detail?: string + composerMode: ChecksPanelComposerMode + workflowAction: ChecksPanelWorkflowAction + recovery: ChecksPanelRecoveryAction[] + openReviewUrl?: string | null + /** Drives "Orca will retry at {time}"; only when a future auto-retry exists. */ + autoRetryAt?: number + /** Retry button stays disabled while now < retryDisabledUntil. */ + retryDisabledUntil?: number +} + +export type ChecksPanelRefreshInput = { + status?: 'queued' | 'in-flight' | 'paused' | 'error' | 'skipped' + errorType?: PRRefreshErrorType + skippedReason?: GitHubPRRefreshSkippedReason + nextAutoRetryAt?: number + retryDisabledUntil?: number +} + +export type ChecksPanelReviewStateInput = { + operationLabel: string | null + reviewLabel: 'pull request' | 'merge request' + reviewShortLabel: 'PR' | 'MR' + /** Localized provider display name, e.g. "GitHub". */ + providerName: string + /** GitHub gets typed error rows; other providers use neutral copy. */ + isGitHubProvider: boolean + reviewLookup: ChecksPanelReviewLookup + openReviewUrl: string | null + eligibilityBlockedReason: HostedReviewCreationBlockedReason | undefined + /** Confirmed readiness held for the exact context (see review-creation). */ + confirmedReadiness: boolean + /** The confirmed path is the Push & Create workflow rather than plain Create. */ + confirmedNeedsPush: boolean + refresh?: ChecksPanelRefreshInput + gitStatusPhase: 'loading' | 'ready' | 'error' + hasUpstream: boolean | undefined + hasCurrentBranch: boolean +} + +export const HARD_REFRESH_ERROR_TYPES = new Set([ + 'auth', + 'permission', + 'repo_unavailable', + 'gh_unavailable' +]) + +/** Eligibility blockers that own their own guidance and outrank refresh copy. */ +export const ELIGIBILITY_SAFETY_BLOCKERS = new Set>([ + 'detached_head', + 'dirty', + 'default_branch', + 'existing_review', + 'fork_head_unsupported', + 'base_not_on_remote', + 'unsupported_provider' +]) + +export function isHardRefreshError(refresh: ChecksPanelRefreshInput | undefined): boolean { + return ( + refresh?.status === 'error' && + refresh.errorType != null && + HARD_REFRESH_ERROR_TYPES.has(refresh.errorType) + ) +} + +export function isRateLimitRefresh(refresh: ChecksPanelRefreshInput | undefined): boolean { + return ( + refresh?.status === 'paused' || + refresh?.errorType === 'rate_limited' || + (refresh?.status === 'skipped' && refresh.skippedReason === 'rate-limit') + ) +} + +export function isTransientRefreshFailure(refresh: ChecksPanelRefreshInput | undefined): boolean { + if (!refresh) { + return false + } + if (isRateLimitRefresh(refresh)) { + return true + } + if (refresh.status === 'error') { + // Untyped and typed-transient errors only pause background refresh; they do + // not prove a fresh user-initiated lookup would fail. + return ( + refresh.errorType == null || + refresh.errorType === 'network' || + refresh.errorType === 'server_error' || + refresh.errorType === 'unknown' + ) + } + return false +} + +export function isBranchBlocker(reason: HostedReviewCreationBlockedReason | undefined): boolean { + return ( + reason === 'no_upstream' || + reason === 'needs_sync' || + reason === 'auth_required' || + reason === 'needs_push' + ) +} + +export function confirmedComposerMode( + input: Pick +): ChecksPanelComposerMode { + if (!input.confirmedReadiness) { + return 'hidden' + } + return input.confirmedNeedsPush ? 'needs_push_open' : 'confirmed_open' +} + +export function workflowActionForComposer( + mode: ChecksPanelComposerMode +): ChecksPanelWorkflowAction { + if (mode === 'confirmed_open') { + return 'create' + } + if (mode === 'needs_push_open') { + return 'push_and_create' + } + return null +} + +export function autoRetrySchedule(input: ChecksPanelReviewStateInput): { + autoRetryAt?: number + retryDisabledUntil?: number +} { + return { + autoRetryAt: input.refresh?.nextAutoRetryAt, + retryDisabledUntil: input.refresh?.retryDisabledUntil + } +} + +export function capitalizeReviewLabel(value: string): string { + return value.length > 0 ? value[0].toUpperCase() + value.slice(1) : value +} diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts index 4f14097388d..a1f6459dce2 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts @@ -190,7 +190,8 @@ describe('source-control Create PR intent flow helpers', () => { review: null, canCreate: false, blockedReason: 'no_upstream', - nextAction: 'publish' + nextAction: 'publish', + reviewLookupOutcome: 'not_found' } }) ).toBe('publish') @@ -204,7 +205,8 @@ describe('source-control Create PR intent flow helpers', () => { review: null, canCreate: false, blockedReason: 'needs_push', - nextAction: 'push' + nextAction: 'push', + reviewLookupOutcome: 'not_found' } }) ).toBe('push') @@ -224,7 +226,8 @@ describe('source-control Create PR intent flow helpers', () => { review: null, canCreate: false, blockedReason: 'needs_sync', - nextAction: 'sync' + nextAction: 'sync', + reviewLookupOutcome: 'not_found' } }) ).toBe('force_push') @@ -242,7 +245,8 @@ describe('source-control Create PR intent flow helpers', () => { review: null, canCreate: false, blockedReason: 'needs_sync', - nextAction: 'sync' + nextAction: 'sync', + reviewLookupOutcome: 'not_found' } }) ).toBe('blocked') @@ -257,7 +261,8 @@ describe('source-control Create PR intent flow helpers', () => { review: null, canCreate: false, blockedReason: 'needs_sync', - nextAction: 'sync' + nextAction: 'sync', + reviewLookupOutcome: 'not_found' } }) ).toBe('sync') @@ -272,7 +277,8 @@ describe('source-control Create PR intent flow helpers', () => { review: null, canCreate: false, blockedReason: 'no_upstream', - nextAction: 'publish' + nextAction: 'publish', + reviewLookupOutcome: 'not_found' } }) ).toBe('blocked') diff --git a/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts index 10419bad3ea..e2bd937f1c2 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-review-blocked-action.test.ts @@ -14,6 +14,7 @@ function eligibility( canCreate: false, blockedReason: 'needs_push', nextAction: 'push', + reviewLookupOutcome: 'not_found', ...overrides } } diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts index 86af5bafdb7..d97707dd273 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts @@ -183,7 +183,8 @@ describe('resolveDropdownItems', () => { review: null, canCreate: false, blockedReason: 'needs_sync', - nextAction: 'sync' + nextAction: 'sync', + reviewLookupOutcome: 'not_found' } }) ) @@ -360,7 +361,8 @@ describe('resolveDropdownItems', () => { review: null, canCreate: true, blockedReason: null, - nextAction: null + nextAction: null, + reviewLookupOutcome: 'not_found' } }) ) @@ -671,7 +673,8 @@ describe('resolveDropdownItems', () => { review: null, canCreate: false, blockedReason: 'needs_push', - nextAction: 'push' + nextAction: 'push', + reviewLookupOutcome: 'not_found' } }) ) @@ -693,7 +696,8 @@ describe('resolveDropdownItems', () => { review: null, canCreate: false, blockedReason: 'needs_push', - nextAction: 'push' + nextAction: 'push', + reviewLookupOutcome: 'not_found' } }) ) @@ -719,7 +723,8 @@ describe('resolveDropdownItems', () => { review: null, canCreate: false, blockedReason: 'needs_push', - nextAction: 'push' + nextAction: 'push', + reviewLookupOutcome: 'not_found' } }) ) @@ -747,7 +752,8 @@ describe('resolveDropdownItems', () => { review: null, canCreate: false, blockedReason: 'auth_required', - nextAction: 'authenticate' + nextAction: 'authenticate', + reviewLookupOutcome: 'not_found' } }) ) @@ -766,7 +772,8 @@ describe('resolveDropdownItems', () => { review: null, canCreate: false, blockedReason: 'auth_required', - nextAction: 'authenticate' + nextAction: 'authenticate', + reviewLookupOutcome: 'not_found' } }) ) diff --git a/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts b/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts index ae6dceadc7c..13d7bc5dac9 100644 --- a/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.test.ts @@ -32,7 +32,7 @@ describe('resolveHostedReviewCreationProviderForTarget', () => { }) describe('buildLocalBlockerHostedReviewCreationEligibility', () => { - it('reports dirty when there are uncommitted changes', () => { + it('reports dirty without offering create intent when the review lookup failed', () => { const eligibility = buildLocalBlockerHostedReviewCreationEligibility('github', { ...featureBranch, hasUncommittedChanges: true, @@ -40,9 +40,12 @@ describe('buildLocalBlockerHostedReviewCreationEligibility', () => { ahead: 0, behind: 0 }) - expect(eligibility).toMatchObject({ blockedReason: 'dirty', nextAction: 'commit' }) - // The synthesized blocker must drive the actionable Create PR intent so a - // failed remote probe still offers commit/publish preparation. + expect(eligibility).toMatchObject({ + blockedReason: 'dirty', + nextAction: 'commit', + reviewLookupOutcome: 'unavailable' + }) + // Why: branch guidance can remain specific, but a failed lookup cannot authorize creation. expect( resolveCreatePrIntentEligibility({ stagedCount: 1, @@ -53,7 +56,7 @@ describe('buildLocalBlockerHostedReviewCreationEligibility', () => { hostedReviewCreation: eligibility, branchCommitsAhead: 0 }) - ).toEqual({ eligible: true, kind: 'dirty' }) + ).toEqual({ eligible: false, kind: null }) }) it('prefers dirty over no_upstream when both apply, matching main-process ordering', () => { diff --git a/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.ts b/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.ts index f951af458f0..0857f94f6a0 100644 --- a/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.ts +++ b/src/renderer/src/components/right-sidebar/source-control-hosted-review-creation-eligibility-snapshot.ts @@ -13,7 +13,9 @@ export function buildLoadingHostedReviewCreationEligibility( review: null, canCreate: false, blockedReason: null, - nextAction: null + nextAction: null, + // Why: a loading placeholder has no authoritative existing-review result. + reviewLookupOutcome: 'unavailable' } } @@ -36,8 +38,8 @@ export function resolveHostedReviewCreationProviderForTarget( /** * Local-status-only eligibility used when the remote creation probe fails or - * times out, so a failed probe still offers the actionable commit/publish/sync - * preparation action instead of leaving an inert disabled button. + * times out, so the UI can show branch guidance without treating the failed + * lookup as authority to create a review. * * Mirrors the main process's own lookup-failure fallback exactly — both its * `canReturnLocalBlocker` guard and its blocker ordering @@ -77,7 +79,9 @@ export function buildLocalBlockerHostedReviewCreationEligibility( review: null, canCreate: false as const, defaultBaseRef: null, - head: branch + head: branch, + // Why: local Git blockers cannot prove that a hosted review does not exist. + reviewLookupOutcome: 'unavailable' as const } if (status.hasUncommittedChanges) { return { ...base, blockedReason: 'dirty', nextAction: 'commit' } diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts index 086f86565ed..5d5f249a3bd 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts @@ -39,7 +39,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'no_upstream', - nextAction: 'publish' + nextAction: 'publish', + reviewLookupOutcome: 'not_found' } }) ) @@ -63,7 +64,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'needs_sync', - nextAction: 'sync' + nextAction: 'sync', + reviewLookupOutcome: 'not_found' } }) ) @@ -84,7 +86,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'needs_sync', - nextAction: 'sync' + nextAction: 'sync', + reviewLookupOutcome: 'not_found' } }) const result = resolvePrimaryAction(input) @@ -106,7 +109,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'needs_push', - nextAction: 'push' + nextAction: 'push', + reviewLookupOutcome: 'not_found' } }) const result = resolvePrimaryAction(input) @@ -137,7 +141,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'dirty', - nextAction: 'commit' + nextAction: 'commit', + reviewLookupOutcome: 'not_found' } }) ) @@ -159,7 +164,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'dirty', - nextAction: 'commit' + nextAction: 'commit', + reviewLookupOutcome: 'not_found' } }) ) @@ -181,7 +187,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'dirty', - nextAction: 'commit' + nextAction: 'commit', + reviewLookupOutcome: 'not_found' } }) const result = resolvePrimaryAction(input) @@ -206,7 +213,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'default_branch', - nextAction: null + nextAction: null, + reviewLookupOutcome: 'not_found' } }) ) @@ -229,7 +237,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'dirty', - nextAction: 'commit' + nextAction: 'commit', + reviewLookupOutcome: 'not_found' } }) ) @@ -250,7 +259,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'dirty', - nextAction: 'commit' + nextAction: 'commit', + reviewLookupOutcome: 'not_found' } }) ) @@ -270,7 +280,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'dirty', - nextAction: 'commit' + nextAction: 'commit', + reviewLookupOutcome: 'not_found' } }) @@ -299,7 +310,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'needs_push', - nextAction: 'push' + nextAction: 'push', + reviewLookupOutcome: 'not_found' } }) ) @@ -321,7 +333,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'no_upstream', - nextAction: 'publish' + nextAction: 'publish', + reviewLookupOutcome: 'not_found' } }) @@ -354,7 +367,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'needs_sync', - nextAction: 'sync' + nextAction: 'sync', + reviewLookupOutcome: 'not_found' } }) @@ -387,7 +401,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'needs_sync', - nextAction: 'sync' + nextAction: 'sync', + reviewLookupOutcome: 'not_found' } }) @@ -409,7 +424,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: true, blockedReason: null, - nextAction: null + nextAction: null, + reviewLookupOutcome: 'not_found' } }) ) @@ -430,7 +446,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: null, - nextAction: null + nextAction: null, + reviewLookupOutcome: 'not_found' }, isHostedReviewCreationLoading: true }) @@ -452,7 +469,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: true, blockedReason: null, - nextAction: null + nextAction: null, + reviewLookupOutcome: 'not_found' }, isHostedReviewCreationLoading: true }) @@ -474,7 +492,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: true, blockedReason: null, - nextAction: null + nextAction: null, + reviewLookupOutcome: 'not_found' }, isHostedReviewCreationLoading: true }) @@ -498,7 +517,8 @@ describe('resolvePrimaryAction Create PR intent', () => { review: null, canCreate: false, blockedReason: 'no_upstream', - nextAction: 'publish' + nextAction: 'publish', + reviewLookupOutcome: 'not_found' } }) ) diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts index b71ae52185d..f03f1e490d7 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.test.ts @@ -429,7 +429,8 @@ describe('resolvePrimaryAction', () => { review: null, canCreate: false, blockedReason: 'dirty', - nextAction: 'commit' + nextAction: 'commit', + reviewLookupOutcome: 'not_found' } }) @@ -455,7 +456,8 @@ describe('resolvePrimaryAction', () => { review: null, canCreate: false, blockedReason: 'dirty', - nextAction: 'commit' + nextAction: 'commit', + reviewLookupOutcome: 'not_found' } }) @@ -542,7 +544,8 @@ describe('resolvePrimaryAction', () => { review: null, canCreate: true, blockedReason: null, - nextAction: null + nextAction: null, + reviewLookupOutcome: 'not_found' } }) ) @@ -563,7 +566,8 @@ describe('resolvePrimaryAction', () => { review: null, canCreate: true, blockedReason: null, - nextAction: null + nextAction: null, + reviewLookupOutcome: 'not_found' } }) ) @@ -586,7 +590,8 @@ describe('resolvePrimaryAction', () => { review: null, canCreate: true, blockedReason: null, - nextAction: null + nextAction: null, + reviewLookupOutcome: 'not_found' } }) ) diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts index d09fc41cc32..8017aea4445 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.test.ts @@ -51,6 +51,7 @@ function createEligibility( canCreate: true, blockedReason: null, nextAction: null, + reviewLookupOutcome: 'not_found', defaultBaseRef: 'refs/remotes/origin/main', title: 'Review title', body: 'Review body', diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts index 08a3b311de6..815a18abba3 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts @@ -48,6 +48,11 @@ type UseCreatePullRequestDialogFieldsOptions = { submitting: boolean prCreationDefaults?: SourceControlAiPrCreationDefaults sourceControlAiActionsVisible?: boolean + // When the composer is hidden by a temporary policy (a hard refresh error) for + // the same context rather than dismissed, retain the in-memory draft so + // recovery does not discard the user's title/body/base edits. Reopening a + // different context still reseeds (guarded by the eligibility key). + retainDraftWhenClosed?: boolean onBranchChangedByGeneration?: () => Promise generation?: { generating: boolean @@ -131,6 +136,7 @@ export function useCreatePullRequestDialogFields({ submitting, prCreationDefaults, sourceControlAiActionsVisible = true, + retainDraftWhenClosed = false, onBranchChangedByGeneration, generation }: UseCreatePullRequestDialogFieldsOptions) { @@ -254,12 +260,18 @@ export function useCreatePullRequestDialogFields({ } } generateInFlightRef.current = false - generationSeedRef.current = null - initializedFromEligibilityRef.current = null - syncedDefaultBaseRef.current = null - baseEditedByUserRef.current = false - setInitializedEligibilityKey(null) - autoGeneratedForKeyRef.current = null + // Why: retain the draft + init marker across a temporary policy hide so a + // hard-error recovery reopening the same context does not reseed and wipe + // edits. A genuine context change still reseeds (the eligibility key on + // reopen differs from the retained marker). + if (!retainDraftWhenClosed) { + generationSeedRef.current = null + initializedFromEligibilityRef.current = null + syncedDefaultBaseRef.current = null + baseEditedByUserRef.current = false + setInitializedEligibilityKey(null) + autoGeneratedForKeyRef.current = null + } setGenerating(false) setGenerateError(null) } @@ -316,6 +328,7 @@ export function useCreatePullRequestDialogFields({ repoId, resolvedDefaultBaseRef, resolvedPrDefaults.draft, + retainDraftWhenClosed, worktreeId, worktreePath ]) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 06fe05685de..23cfccec709 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9292,7 +9292,16 @@ "f273f2271c": "Started the agent. Marked {{value0}} resolved, skipped {{value1}}, failed {{value2}}.", "aa95b81a3a": "Started the agent. Marked {{value0}} resolved, skipped {{value1}}, failed {{value2}}.", "495b2f8c4b": "Started the agent, but could not mark the selected comments resolved.", - "3c3ad3a1d2": "Started the agent. No selected comments can be marked resolved on the host." + "3c3ad3a1d2": "Started the agent. No selected comments can be marked resolved on the host.", + "review": { + "auto_retry": "Orca will retry at {{time}}.", + "open_review": "Open Review", + "retry": "Retry" + }, + "sync": { + "pending": "Syncing…", + "branch": "Sync Branch" + } }, "CreatePullRequestDialog": { "2bc1b4345e": "Cancel", @@ -9908,6 +9917,136 @@ "d77c513c1e": "{{value0}} in progress", "b597440265": "Refresh GitHub status for this branch to load checks and review." } + }, + "review": { + "detail": { + "positive": "Orca also has saved {{reviewLabel}} information that it could not verify.", + "rate_limited": "Orca also could not check {{reviewLabel}} status because {{provider}} is temporarily limiting requests.", + "network": "Orca also could not check {{reviewLabel}} status because this environment could not reach {{provider}}.", + "untyped": "Orca also could not confirm whether this branch already has a {{reviewLabel}}." + }, + "positive": { + "title": "{{reviewLabelCap}} details unavailable", + "body": "Orca has saved {{reviewLabel}} information for this branch but could not confirm its current status." + }, + "no_review": { + "title": "No {{reviewLabel}} found", + "body": "Create a {{reviewLabel}} to start checks and review." + }, + "active": { + "title": "Checking {{reviewLabel}} status", + "body": "Orca is checking {{provider}} for a {{reviewLabel}} on this branch." + }, + "git_loading": { + "title": "Checking branch status", + "body": "Orca is checking this branch before showing create or publish actions." + }, + "git_error": { + "title": "Could not check branch status", + "body": "Orca could not confirm this branch's upstream from this environment. Retry before publishing or creating a {{reviewLabel}}." + }, + "unknown": { + "title": "{{reviewLabelCap}} status unavailable", + "body": "Orca has not confirmed the {{reviewLabel}} status for this branch. Retry to check again." + }, + "paused": { + "title": "{{provider}} refresh paused", + "body": "{{provider}} is temporarily limiting requests. This can happen even when the displayed API quota is not exhausted." + }, + "network": { + "title": "Could not reach {{provider}}", + "body": "This environment could not reach {{provider}}. Check its connection, then retry." + }, + "unknown_error": { + "title": "Could not check {{reviewLabel}} status", + "body": "The lookup failed, so Orca could not confirm whether this branch already has a {{reviewLabel}}." + }, + "untyped": { + "title": "{{reviewLabelCap}} status unavailable", + "body": "Orca could not confirm whether this branch already has a {{reviewLabel}}. Retry to check again." + }, + "auth": { + "title": "{{provider}} authentication failed", + "body": "{{provider}} could not authenticate the credentials available in this environment. Check the {{provider}} login or environment token, then retry." + }, + "permission": { + "title": "{{provider}} access denied", + "body": "The current {{provider}} credentials cannot read this repository's {{reviewLabel}}s. Check the account, token scopes, and repository access, then retry." + }, + "repo": { + "title": "{{provider}} repository unavailable", + "body": "{{provider}} could not resolve or access the repository for the current remote and account. Check the remote and repository access, then retry." + }, + "cli": { + "title": "{{provider}} CLI unavailable", + "body": "Orca could not run {{provider}} CLI in this environment. Set it up here, then retry." + }, + "skipped": { + "disconnected": { + "title": "Host disconnected", + "body": "This repository's execution host is disconnected, so Orca cannot refresh {{reviewLabel}} status." + }, + "bare": { + "title": "Bare repository", + "body": "This repository is bare, so {{reviewLabel}} status is not available here." + }, + "archived": { + "title": "Repository archived", + "body": "This repository is archived, so Orca is not refreshing {{reviewLabel}} status." + }, + "not_git": { + "title": "Not a Git repository", + "body": "Orca could not treat this folder as a Git repository for {{reviewLabel}} status." + }, + "remote": { + "title": "Remote-only context", + "body": "Orca could not refresh {{reviewLabel}} status for this remote context. Retry after the host is available." + } + }, + "no_upstream": { + "title": "No upstream configured", + "body": "Publish this branch to set its upstream before creating a {{reviewLabel}}." + }, + "needs_sync": { + "title": "Branch needs to sync", + "body": "Sync this branch with its upstream before creating a {{reviewLabel}}." + }, + "auth_required": { + "title": "Connect {{provider}}", + "body": "{{provider}} must be connected in this environment before Orca can create a {{reviewLabel}}." + }, + "needs_push": { + "title": "Branch has unpushed commits", + "body": "Push the latest commits before creating a {{reviewLabel}}." + }, + "existing": { + "title": "{{reviewLabelCap}} already exists", + "body": "Orca found an existing {{reviewLabel}} for this branch." + }, + "detached": { + "title": "No current branch", + "body": "Check out a branch before creating a {{reviewLabel}}." + }, + "dirty": { + "title": "Commit changes first", + "body": "Commit or stash your changes before creating a {{reviewLabel}}." + }, + "default_branch": { + "title": "On the default branch", + "body": "Switch to a feature branch before creating a {{reviewLabel}}." + }, + "fork": { + "title": "Fork head unsupported", + "body": "Orca cannot create a {{reviewLabel}} from this fork head here." + }, + "base_missing": { + "title": "Base branch not on remote", + "body": "This branch's base is not on the remote yet, so a {{reviewLabel}} cannot target it." + }, + "unsupported": { + "title": "{{reviewLabelCap}} not supported here", + "body": "This repository provider does not support creating a {{reviewLabel}} from Orca." + } } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index fe010cb3095..fe3ac1620a8 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9269,7 +9269,16 @@ "f273f2271c": "Agente iniciado. Marcados como resueltos: {{value0}}; omitidos: {{value1}}; con error: {{value2}}.", "aa95b81a3a": "Agente iniciado. Marcados como resueltos: {{value0}}; omitidos: {{value1}}; con error: {{value2}}.", "495b2f8c4b": "Agente iniciado, pero no se pudieron marcar los comentarios seleccionados como resueltos.", - "3c3ad3a1d2": "Agente iniciado. No se puede marcar ningún comentario seleccionado como resuelto en el host." + "3c3ad3a1d2": "Agente iniciado. No se puede marcar ningún comentario seleccionado como resuelto en el host.", + "review": { + "auto_retry": "Orca reintentará a las {{time}}.", + "open_review": "Abrir revisión", + "retry": "Reintentar" + }, + "sync": { + "pending": "Syncing…", + "branch": "Sync Branch" + } }, "CreatePullRequestDialog": { "2bc1b4345e": "Cancelar", @@ -9885,6 +9894,136 @@ "d77c513c1e": "{{value0}} en progreso", "b597440265": "Actualiza el estado de GitHub de esta rama para cargar checks y revisión." } + }, + "review": { + "detail": { + "positive": "Orca también tiene información de {{reviewLabel}} guardada que no pudo verificar.", + "rate_limited": "Orca también no pudo verificar el estado de {{reviewLabel}} porque {{provider}} está limitando temporalmente las solicitudes.", + "network": "Orca también no pudo verificar el estado de {{reviewLabel}} porque este entorno no pudo conectar con {{provider}}.", + "untyped": "Orca también no pudo confirmar si esta rama ya tiene un {{reviewLabel}}." + }, + "positive": { + "title": "Detalles de {{reviewLabelCap}} no disponibles", + "body": "Orca ha guardado información de {{reviewLabel}} para esta rama pero no pudo confirmar su estado actual." + }, + "no_review": { + "title": "No se encontró ningún {{reviewLabel}}", + "body": "Crea un {{reviewLabel}} para iniciar las verificaciones y la revisión." + }, + "active": { + "title": "Verificando estado de {{reviewLabel}}", + "body": "Orca está verificando {{provider}} para un {{reviewLabel}} en esta rama." + }, + "git_loading": { + "title": "Verificando estado de la rama", + "body": "Orca está verificando esta rama antes de mostrar las acciones de creación o publicación." + }, + "git_error": { + "title": "No se pudo verificar el estado de la rama", + "body": "Orca no pudo confirmar el upstream de esta rama desde este entorno. Reintenta antes de publicar o crear un {{reviewLabel}}." + }, + "unknown": { + "title": "Estado de {{reviewLabelCap}} no disponible", + "body": "Orca no ha confirmado el estado de {{reviewLabel}} para esta rama. Reintenta para verificar de nuevo." + }, + "paused": { + "title": "Actualización de {{provider}} pausada", + "body": "{{provider}} está limitando temporalmente las solicitudes. Esto puede ocurrir incluso cuando la cuota de API mostrada no está agotada." + }, + "network": { + "title": "No se pudo conectar con {{provider}}", + "body": "Este entorno no pudo conectar con {{provider}}. Verifica su conexión y reintenta." + }, + "unknown_error": { + "title": "No se pudo verificar el estado de {{reviewLabel}}", + "body": "La búsqueda falló, por lo que Orca no pudo confirmar si esta rama ya tiene un {{reviewLabel}}." + }, + "untyped": { + "title": "Estado de {{reviewLabelCap}} no disponible", + "body": "Orca no pudo confirmar si esta rama ya tiene un {{reviewLabel}}. Reintenta para verificar de nuevo." + }, + "auth": { + "title": "Autenticación de {{provider}} fallida", + "body": "{{provider}} no pudo autenticar las credenciales disponibles en este entorno. Verifica el inicio de sesión de {{provider}} o el token del entorno y reintenta." + }, + "permission": { + "title": "Acceso denegado por {{provider}}", + "body": "Las credenciales actuales de {{provider}} no pueden leer los {{reviewLabel}} de este repositorio. Verifica la cuenta, los alcances del token y el acceso al repositorio, luego reintenta." + }, + "repo": { + "title": "Repositorio de {{provider}} no disponible", + "body": "{{provider}} no pudo resolver o acceder al repositorio del remote y cuenta actuales. Verifica el remote y el acceso al repositorio, luego reintenta." + }, + "cli": { + "title": "CLI de {{provider}} no disponible", + "body": "Orca no pudo ejecutar la CLI de {{provider}} en este entorno. Configúrala aquí y reintenta." + }, + "skipped": { + "disconnected": { + "title": "Host desconectado", + "body": "El host de ejecución de este repositorio está desconectado, por lo que Orca no puede actualizar el estado de {{reviewLabel}}." + }, + "bare": { + "title": "Repositorio bare", + "body": "Este repositorio es bare, por lo que el estado de {{reviewLabel}} no está disponible aquí." + }, + "archived": { + "title": "Repositorio archivado", + "body": "Este repositorio está archivado, por lo que Orca no está actualizando el estado de {{reviewLabel}}." + }, + "not_git": { + "title": "No es un repositorio Git", + "body": "Orca no pudo tratar esta carpeta como un repositorio Git para el estado de {{reviewLabel}}." + }, + "remote": { + "title": "Contexto solo remoto", + "body": "Orca no pudo actualizar el estado de {{reviewLabel}} para este contexto remoto. Reintenta cuando el host esté disponible." + } + }, + "no_upstream": { + "title": "Sin upstream configurado", + "body": "Publica esta rama para configurar su upstream antes de crear un {{reviewLabel}}." + }, + "needs_sync": { + "title": "La rama necesita sincronizarse", + "body": "Sincroniza esta rama con su upstream antes de crear un {{reviewLabel}}." + }, + "auth_required": { + "title": "Conecta {{provider}}", + "body": "{{provider}} debe estar conectado en este entorno antes de que Orca pueda crear un {{reviewLabel}}." + }, + "needs_push": { + "title": "La rama tiene commits sin enviar", + "body": "Envía los últimos commits antes de crear un {{reviewLabel}}." + }, + "existing": { + "title": "{{reviewLabelCap}} ya existe", + "body": "Orca encontró un {{reviewLabel}} existente para esta rama." + }, + "detached": { + "title": "Sin rama actual", + "body": "Selecciona una rama antes de crear un {{reviewLabel}}." + }, + "dirty": { + "title": "Primero confirma los cambios", + "body": "Confirma o guarda tus cambios antes de crear un {{reviewLabel}}." + }, + "default_branch": { + "title": "En la rama por defecto", + "body": "Cambia a una rama de características antes de crear un {{reviewLabel}}." + }, + "fork": { + "title": "Fork head no soportado", + "body": "Orca no puede crear un {{reviewLabel}} desde este fork head aquí." + }, + "base_missing": { + "title": "La rama base no está en el remote", + "body": "La base de esta rama aún no está en el remote, por lo que un {{reviewLabel}} no puede apuntar a ella." + }, + "unsupported": { + "title": "{{reviewLabelCap}} no soportado aquí", + "body": "Este proveedor de repositorio no soporta la creación de un {{reviewLabel}} desde Orca." + } } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 326b87d99b2..eaf4518ccbb 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9269,7 +9269,16 @@ "f273f2271c": "agent を開始しました。{{value0}} 件を解決済みにし、{{value1}} 件をスキップ、{{value2}} 件が失敗しました。", "aa95b81a3a": "agent を開始しました。{{value0}} 件を解決済みにし、{{value1}} 件をスキップ、{{value2}} 件が失敗しました。", "495b2f8c4b": "agent を開始しましたが、選択したコメントを解決済みにできませんでした。", - "3c3ad3a1d2": "agent を開始しました。選択したコメントにホスト上で解決済みにできるものはありません。" + "3c3ad3a1d2": "agent を開始しました。選択したコメントにホスト上で解決済みにできるものはありません。", + "review": { + "auto_retry": "Orca は {{time}} に再試行します。", + "open_review": "レビューを開く", + "retry": "再試行" + }, + "sync": { + "pending": "Syncing…", + "branch": "Sync Branch" + } }, "CreatePullRequestDialog": { "2bc1b4345e": "キャンセル", @@ -9885,6 +9894,136 @@ "d77c513c1e": "{{value0}} が進行中です", "b597440265": "Refresh GitHub status for this branch to load checks and review." } + }, + "review": { + "detail": { + "positive": "Orca は保存された {{reviewLabel}} 情報を持っていますが、検証できませんでした。", + "rate_limited": "Orca は {{provider}} がリクエストを一時的に制限しているため、{{reviewLabel}} ステータスを確認できませんでした。", + "network": "Orca はこの環境から {{provider}} に接続できないため、{{reviewLabel}} ステータスを確認できませんでした。", + "untyped": "Orca はこのブランチに {{reviewLabel}} が既に存在するかどうかを確認できませんでした。" + }, + "positive": { + "title": "{{reviewLabelCap}} の詳細が利用できません", + "body": "Orca はこのブランチの {{reviewLabel}} 情報を保存しましたが、現在のステータスを確認できませんでした。" + }, + "no_review": { + "title": "{{reviewLabel}} が見つかりません", + "body": "{{reviewLabel}} を作成して、チェックとレビューを開始します。" + }, + "active": { + "title": "{{reviewLabel}} ステータスを確認中", + "body": "Orca はこのブランチの {{provider}} 上の {{reviewLabel}} を確認しています。" + }, + "git_loading": { + "title": "ブランチのステータスを確認中", + "body": "Orca はこのブランチの作成または公開アクションを表示する前に確認しています。" + }, + "git_error": { + "title": "ブランチのステータスを確認できませんでした", + "body": "Orca はこの環境からこのブランチのアップストリームを確認できませんでした。公開または {{reviewLabel}} の作成前に再試行してください。" + }, + "unknown": { + "title": "{{reviewLabelCap}} ステータスが利用できません", + "body": "Orca はこのブランチの {{reviewLabel}} ステータスを確認していません。再試行して再度確認してください。" + }, + "paused": { + "title": "{{provider}} の更新が一時停止されています", + "body": "{{provider}} がリクエストを一時的に制限しています。表示された API クォータが枯渇していない場合でも発生する可能性があります。" + }, + "network": { + "title": "{{provider}} に接続できませんでした", + "body": "この環境から {{provider}} に接続できませんでした。接続を確認して再試行してください。" + }, + "unknown_error": { + "title": "{{reviewLabel}} ステータスを確認できませんでした", + "body": "検索に失敗したため、Orca はこのブランチに {{reviewLabel}} が既に存在するかどうかを確認できませんでした。" + }, + "untyped": { + "title": "{{reviewLabelCap}} ステータスが利用できません", + "body": "Orca はこのブランチに {{reviewLabel}} が既に存在するかどうかを確認できませんでした。再試行して再度確認してください。" + }, + "auth": { + "title": "{{provider}} の認証に失敗しました", + "body": "{{provider}} はこの環境で使用可能な資格情報を認証できませんでした。{{provider}} のログインまたは環境トークンを確認して再試行してください。" + }, + "permission": { + "title": "{{provider}} のアクセスが拒否されました", + "body": "現在の {{provider}} の資格情報ではこのリポジトリの {{reviewLabel}} を読み取れません。アカウント、トークンスコープ、リポジトリのアクセス権を確認して再試行してください。" + }, + "repo": { + "title": "{{provider}} リポジトリが利用できません", + "body": "{{provider}} は現在のリモートとアカウントのリポジトリを解決またはアクセスできませんでした。リモートとリポジトリのアクセス権を確認して再試行してください。" + }, + "cli": { + "title": "{{provider}} CLI が利用できません", + "body": "Orca はこの環境で {{provider}} CLI を実行できませんでした。ここでセットアップして再試行してください。" + }, + "skipped": { + "disconnected": { + "title": "ホストが切断されました", + "body": "このリポジトリの実行ホストが切断されたため、Orca は {{reviewLabel}} ステータスを更新できません。" + }, + "bare": { + "title": "ベアリポジトリ", + "body": "このリポジトリはベアリポジトリであるため、{{reviewLabel}} ステータスはここでは利用できません。" + }, + "archived": { + "title": "リポジトリがアーカイブされています", + "body": "このリポジトリはアーカイブされているため、Orca は {{reviewLabel}} ステータスを更新しません。" + }, + "not_git": { + "title": "Git リポジトリではありません", + "body": "Orca はこのフォルダを {{reviewLabel}} ステータス用の Git リポジトリとして扱えませんでした。" + }, + "remote": { + "title": "リモートのみのコンテキスト", + "body": "Orca はこのリモートコンテキストの {{reviewLabel}} ステータスを更新できませんでした。ホストが利用可能になった後に再試行してください。" + } + }, + "no_upstream": { + "title": "アップストリームが設定されていません", + "body": "このブランチを公開してアップストリームを設定してから {{reviewLabel}} を作成してください。" + }, + "needs_sync": { + "title": "ブランチの同期が必要です", + "body": "{{reviewLabel}} を作成する前に、このブランチをアップストリームと同期してください。" + }, + "auth_required": { + "title": "{{provider}} を接続してください", + "body": "Orca が {{reviewLabel}} を作成する前に、この環境で {{provider}} を接続する必要があります。" + }, + "needs_push": { + "title": "ブランチに未プッシュのコミットがあります", + "body": "{{reviewLabel}} を作成する前に、最新のコミットをプッシュしてください。" + }, + "existing": { + "title": "{{reviewLabelCap}} は既に存在します", + "body": "Orca はこのブランチの既存の {{reviewLabel}} を見つけました。" + }, + "detached": { + "title": "現在のブランチがありません", + "body": "{{reviewLabel}} を作成する前にブランチをチェックアウトしてください。" + }, + "dirty": { + "title": "まず変更をコミットしてください", + "body": "{{reviewLabel}} を作成する前に、変更をコミットまたはスタッシュしてください。" + }, + "default_branch": { + "title": "デフォルトブランチ上です", + "body": "{{reviewLabel}} を作成する前に、フィーチャーブランチに切り替えてください。" + }, + "fork": { + "title": "フォークヘッドはサポートされていません", + "body": "Orca はここからこのフォークヘッドの {{reviewLabel}} を作成できません。" + }, + "base_missing": { + "title": "ベースブランチがリモートにありません", + "body": "このブランチのベースがまだリモートにないため、{{reviewLabel}} をターゲットにできません。" + }, + "unsupported": { + "title": "ここでは {{reviewLabelCap}} はサポートされていません", + "body": "このリポジトリプロバイダーは、Orca からの {{reviewLabel}} の作成をサポートしていません。" + } } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 0de35bc772f..742d8531e4c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9269,7 +9269,16 @@ "f273f2271c": "agent를 시작했습니다. {{value0}}개 해결됨으로 표시, {{value1}}개 건너뜀, {{value2}}개 실패.", "aa95b81a3a": "agent를 시작했습니다. {{value0}}개 해결됨으로 표시, {{value1}}개 건너뜀, {{value2}}개 실패.", "495b2f8c4b": "agent를 시작했지만 선택한 댓글을 해결됨으로 표시할 수 없습니다.", - "3c3ad3a1d2": "agent를 시작했습니다. 호스트에서 해결됨으로 표시할 수 있는 선택된 댓글이 없습니다." + "3c3ad3a1d2": "agent를 시작했습니다. 호스트에서 해결됨으로 표시할 수 있는 선택된 댓글이 없습니다.", + "review": { + "auto_retry": "Orca가 {{time}}에 다시 시도합니다.", + "open_review": "검토 열기", + "retry": "다시 시도" + }, + "sync": { + "pending": "Syncing…", + "branch": "Sync Branch" + } }, "CreatePullRequestDialog": { "2bc1b4345e": "취소", @@ -9885,6 +9894,136 @@ "d77c513c1e": "{{value0}} 진행 중", "b597440265": "검사와 리뷰를 불러오려면 이 브랜치의 GitHub 상태를 새로 고치세요." } + }, + "review": { + "detail": { + "positive": "Orca에는 확인할 수 없는 저장된 {{reviewLabel}} 정보가 있습니다.", + "rate_limited": "{{provider}}가 요청을 일시적으로 제한하고 있어 Orca도 {{reviewLabel}} 상태를 확인할 수 없었습니다.", + "network": "이 환경에서 {{provider}}에 연결할 수 없어 Orca도 {{reviewLabel}} 상태를 확인할 수 없었습니다.", + "untyped": "Orca는 이 브랜치에 이미 {{reviewLabel}}가 있는지 확인할 수 없었습니다." + }, + "positive": { + "title": "{{reviewLabelCap}} 세부 정보를 사용할 수 없음", + "body": "Orca는 이 브랜치의 {{reviewLabel}} 정보를 저장했지만 현재 상태를 확인할 수 없었습니다." + }, + "no_review": { + "title": "{{reviewLabel}}를 찾을 수 없음", + "body": "{{reviewLabel}}를 만들어 검사와 리뷰를 시작하세요." + }, + "active": { + "title": "{{reviewLabel}} 상태 확인 중", + "body": "Orca는 이 브랜치의 {{provider}}에서 {{reviewLabel}}를 확인하고 있습니다." + }, + "git_loading": { + "title": "브랜치 상태 확인 중", + "body": "Orca는 생성 또는 게시 작업을 표시하기 전에 이 브랜치를 확인하고 있습니다." + }, + "git_error": { + "title": "브랜치 상태를 확인할 수 없음", + "body": "Orca는 이 환경에서 이 브랜치의 업스트림을 확인할 수 없었습니다. 게시하거나 {{reviewLabel}}를 만들기 전에 다시 시도하세요." + }, + "unknown": { + "title": "{{reviewLabelCap}} 상태를 사용할 수 없음", + "body": "Orca가 이 브랜치의 {{reviewLabel}} 상태를 확인하지 못했습니다. 다시 시도하여 확인하세요." + }, + "paused": { + "title": "{{provider}} 새로고침 일시 중지됨", + "body": "{{provider}}가 요청을 일시적으로 제한하고 있습니다. 표시된 API 할당량이 소진되지 않은 경우에도 발생할 수 있습니다." + }, + "network": { + "title": "{{provider}}에 연결할 수 없음", + "body": "이 환경에서 {{provider}}에 연결할 수 없었습니다. 연결을 확인한 후 다시 시도하세요." + }, + "unknown_error": { + "title": "{{reviewLabel}} 상태를 확인할 수 없음", + "body": "조회에 실패하여 Orca는 이 브랜치에 이미 {{reviewLabel}}가 있는지 확인할 수 없었습니다." + }, + "untyped": { + "title": "{{reviewLabelCap}} 상태를 사용할 수 없음", + "body": "Orca는 이 브랜치에 이미 {{reviewLabel}}가 있는지 확인할 수 없었습니다. 다시 시도하여 확인하세요." + }, + "auth": { + "title": "{{provider}} 인증 실패", + "body": "{{provider}}는 이 환경에서 사용 가능한 자격 증명을 인증할 수 없었습니다. {{provider}} 로그인 또는 환경 토큰을 확인한 후 다시 시도하세요." + }, + "permission": { + "title": "{{provider}} 접근 거부됨", + "body": "현재 {{provider}} 자격 증명으로는 이 리포지토리의 {{reviewLabel}}를 읽을 수 없습니다. 계정, 토큰 범위 및 리포지토리 접근 권한을 확인한 후 다시 시도하세요." + }, + "repo": { + "title": "{{provider}} 리포지토리를 사용할 수 없음", + "body": "{{provider}}는 현재 리모트와 계정의 리포지토리를 확인하거나 접근할 수 없었습니다. 리모트와 리포지토리 접근 권한을 확인한 후 다시 시도하세요." + }, + "cli": { + "title": "{{provider}} CLI를 사용할 수 없음", + "body": "Orca는 이 환경에서 {{provider}} CLI를 실행할 수 없었습니다. 여기에서 설정한 후 다시 시도하세요." + }, + "skipped": { + "disconnected": { + "title": "호스트 연결 끊김", + "body": "이 리포지토리의 실행 호스트가 연결 해제되어 Orca가 {{reviewLabel}} 상태를 새로고칠 수 없습니다." + }, + "bare": { + "title": "베어 리포지토리", + "body": "이 리포지토리는 베어 리포지토리이므로 여기에서 {{reviewLabel}} 상태를 사용할 수 없습니다." + }, + "archived": { + "title": "리포지토리가 보관됨", + "body": "이 리포지토리가 보관되어 있으므로 Orca가 {{reviewLabel}} 상태를 새로고치지 않습니다." + }, + "not_git": { + "title": "Git 리포지토리가 아님", + "body": "Orca는 {{reviewLabel}} 상태용으로 이 폴더를 Git 리포지토리로 처리할 수 없었습니다." + }, + "remote": { + "title": "리모트 전용 컨텍스트", + "body": "Orca는 이 리모트 컨텍스트의 {{reviewLabel}} 상태를 새로고칠 수 없었습니다. 호스트를 사용할 수 있을 때 다시 시도하세요." + } + }, + "no_upstream": { + "title": "업스트림이 설정되지 않음", + "body": "{{reviewLabel}}를 만들기 전에 이 브랜치를 게시하여 업스트림을 설정하세요." + }, + "needs_sync": { + "title": "브랜치 동기화 필요", + "body": "{{reviewLabel}}를 만들기 전에 이 브랜치를 업스트림과 동기화하세요." + }, + "auth_required": { + "title": "{{provider}} 연결", + "body": "Orca가 {{reviewLabel}}를 만들려면 이 환경에서 {{provider}}가 연결되어 있어야 합니다." + }, + "needs_push": { + "title": "브랜치에 푸시하지 않은 커밋이 있음", + "body": "{{reviewLabel}}를 만들기 전에 최신 커밋을 푸시하세요." + }, + "existing": { + "title": "{{reviewLabelCap}}이(가) 이미 존재함", + "body": "Orca는 이 브랜치의 기존 {{reviewLabel}}를 찾았습니다." + }, + "detached": { + "title": "현재 브랜치가 없음", + "body": "{{reviewLabel}}를 만들기 전에 브랜치를 체크아웃하세요." + }, + "dirty": { + "title": "먼저 변경 사항을 커밋하세요", + "body": "{{reviewLabel}}를 만들기 전에 변경 사항을 커밋하거나 스태시하세요." + }, + "default_branch": { + "title": "기본 브랜치 위에 있음", + "body": "{{reviewLabel}}를 만들기 전에 기능 브랜치로 전환하세요." + }, + "fork": { + "title": "포크 헤드 지원되지 않음", + "body": "Orca는 여기에서 이 포크 헤드의 {{reviewLabel}}를 만들 수 없습니다." + }, + "base_missing": { + "title": "기본 브랜치가 리모트에 없음", + "body": "이 브랜치의 기본이 아직 리모트에 없으므로 {{reviewLabel}}가 이를 대상으로 할 수 없습니다." + }, + "unsupported": { + "title": "여기서 {{reviewLabelCap}}이(가) 지원되지 않음", + "body": "이 리포지토리 제공자는 Orca에서 {{reviewLabel}}를 만드는 것을 지원하지 않습니다." + } } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6a8e1bd94a6..657a353bd8c 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9269,7 +9269,16 @@ "f273f2271c": "已启动智能体。已标记 {{value0}} 个为已解决,跳过 {{value1}} 个,失败 {{value2}} 个。", "aa95b81a3a": "已启动智能体。已标记 {{value0}} 个为已解决,跳过 {{value1}} 个,失败 {{value2}} 个。", "495b2f8c4b": "已启动智能体,但无法将选中的评论标记为已解决。", - "3c3ad3a1d2": "已启动智能体。选中的评论中没有可在托管平台上标记为已解决的评论。" + "3c3ad3a1d2": "已启动智能体。选中的评论中没有可在托管平台上标记为已解决的评论。", + "review": { + "auto_retry": "Orca 将在 {{time}} 重试。", + "open_review": "打开评审", + "retry": "重试" + }, + "sync": { + "pending": "Syncing…", + "branch": "Sync Branch" + } }, "CreatePullRequestDialog": { "2bc1b4345e": "取消", @@ -9885,6 +9894,136 @@ "d77c513c1e": "{{value0}} 正在进行中", "b597440265": "刷新此分支的 GitHub 状态以加载检查和审核。" } + }, + "review": { + "detail": { + "positive": "Orca 也有已保存的 {{reviewLabel}} 信息但无法验证。", + "rate_limited": "Orca 无法检查 {{reviewLabel}} 状态,因为 {{provider}} 正在临时限制请求。", + "network": "Orca 无法检查 {{reviewLabel}} 状态,因为当前环境无法连接到 {{provider}}。", + "untyped": "Orca 无法确认此分支是否已有 {{reviewLabel}}。" + }, + "positive": { + "title": "{{reviewLabelCap}} 详细信息不可用", + "body": "Orca 已为此分支保存了 {{reviewLabel}} 信息,但无法确认其当前状态。" + }, + "no_review": { + "title": "未找到 {{reviewLabel}}", + "body": "创建 {{reviewLabel}} 以开始检查和评审。" + }, + "active": { + "title": "正在检查 {{reviewLabel}} 状态", + "body": "Orca 正在检查 {{provider}} 上此分支的 {{reviewLabel}}。" + }, + "git_loading": { + "title": "正在检查分支状态", + "body": "Orca 正在检查此分支,然后显示创建或发布操作。" + }, + "git_error": { + "title": "无法检查分支状态", + "body": "Orca 无法从当前环境确认此分支的上游。请在发布或创建 {{reviewLabel}} 之前重试。" + }, + "unknown": { + "title": "{{reviewLabelCap}} 状态不可用", + "body": "Orca 尚未确认此分支的 {{reviewLabel}} 状态。请重试以再次检查。" + }, + "paused": { + "title": "{{provider}} 刷新已暂停", + "body": "{{provider}} 正在临时限制请求。即使显示的 API 配额未用完,也可能发生这种情况。" + }, + "network": { + "title": "无法连接到 {{provider}}", + "body": "当前环境无法连接到 {{provider}}。请检查连接后重试。" + }, + "unknown_error": { + "title": "无法检查 {{reviewLabel}} 状态", + "body": "查询失败,Orca 无法确认此分支是否已有 {{reviewLabel}}。" + }, + "untyped": { + "title": "{{reviewLabelCap}} 状态不可用", + "body": "Orca 无法确认此分支是否已有 {{reviewLabel}}。请重试以再次检查。" + }, + "auth": { + "title": "{{provider}} 身份验证失败", + "body": "{{provider}} 无法验证当前环境中的凭据。请检查 {{provider}} 登录或环境令牌后重试。" + }, + "permission": { + "title": "{{provider}} 访问被拒绝", + "body": "当前 {{provider}} 凭据无法读取此仓库的 {{reviewLabel}}。请检查账户、令牌范围和仓库访问权限后重试。" + }, + "repo": { + "title": "{{provider}} 仓库不可用", + "body": "{{provider}} 无法解析或访问当前远程和账户的仓库。请检查远程和仓库访问权限后重试。" + }, + "cli": { + "title": "{{provider}} CLI 不可用", + "body": "Orca 无法在当前环境中运行 {{provider}} CLI。请在此处设置后重试。" + }, + "skipped": { + "disconnected": { + "title": "主机已断开连接", + "body": "此仓库的执行主机已断开连接,Orca 无法刷新 {{reviewLabel}} 状态。" + }, + "bare": { + "title": "裸仓库", + "body": "此仓库是裸仓库,因此此处无法使用 {{reviewLabel}} 状态。" + }, + "archived": { + "title": "仓库已归档", + "body": "此仓库已归档,Orca 不会刷新 {{reviewLabel}} 状态。" + }, + "not_git": { + "title": "不是 Git 仓库", + "body": "Orca 无法将此文件夹作为 Git 仓库来获取 {{reviewLabel}} 状态。" + }, + "remote": { + "title": "仅远程上下文", + "body": "Orca 无法刷新此远程上下文的 {{reviewLabel}} 状态。请在主机可用后重试。" + } + }, + "no_upstream": { + "title": "未配置上游", + "body": "请在创建 {{reviewLabel}} 之前发布此分支以设置其上游。" + }, + "needs_sync": { + "title": "分支需要同步", + "body": "请在创建 {{reviewLabel}} 之前将此分支与其上游同步。" + }, + "auth_required": { + "title": "连接 {{provider}}", + "body": "Orca 创建 {{reviewLabel}} 之前,必须在当前环境中连接 {{provider}}。" + }, + "needs_push": { + "title": "分支有未推送的提交", + "body": "请在创建 {{reviewLabel}} 之前推送最新提交。" + }, + "existing": { + "title": "{{reviewLabelCap}} 已存在", + "body": "Orca 找到了此分支的现有 {{reviewLabel}}。" + }, + "detached": { + "title": "没有当前分支", + "body": "请在创建 {{reviewLabel}} 之前检出分支。" + }, + "dirty": { + "title": "请先提交更改", + "body": "请在创建 {{reviewLabel}} 之前提交或暂存更改。" + }, + "default_branch": { + "title": "在默认分支上", + "body": "请在创建 {{reviewLabel}} 之前切换到功能分支。" + }, + "fork": { + "title": "不支持 Fork 头", + "body": "Orca 无法从此处的 Fork 头创建 {{reviewLabel}}。" + }, + "base_missing": { + "title": "基础分支不在远程上", + "body": "此分支的基础尚未在远程上,因此无法将 {{reviewLabel}} 定向到它。" + }, + "unsupported": { + "title": "此处不支持 {{reviewLabelCap}}", + "body": "此仓库提供程序不支持从 Orca 创建 {{reviewLabel}}。" + } } } }, diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 322fc8568c0..7a6527e46f0 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -12,7 +12,9 @@ import type { GitHubPRRefreshCandidate, GitHubPRRefreshEvent, GitHubPRRefreshReason, - PRRefreshUpstreamErrorType, + GitHubPRRefreshSkippedReason, + PRRefreshErrorType, + PRRefreshOutcome, GitHubCommentResult, IssueInfo, PRCheckDetail, @@ -676,10 +678,11 @@ export type PRRefreshState = { updatedAt: number pausedUntil?: number message?: string - // Why: lets error surfaces render translated, GitHub-attributed copy keyed on - // the failure kind (e.g. a 5xx outage vs. a rate limit) rather than showing - // the main process's English `message` verbatim. - errorType?: PRRefreshUpstreamErrorType + // Why: classified errors drive stable copy without exposing raw upstream messages. + errorType?: PRRefreshErrorType + skippedReason?: GitHubPRRefreshSkippedReason + nextAutoRetryAt?: number + retryDisabledUntil?: number } export type PRRefreshStateClearToken = { @@ -2171,6 +2174,24 @@ export type GitHubSlice = { patchProjectRowContent: (cacheKey: string, rowId: string, patch: ProjectRowContentPatch) => void } +/** + * Normalizes the `github.prForBranch` runtime RPC result into a + * {@link PRRefreshOutcome}. Current hosts return the full outcome (with a + * `kind`), so a runtime `upstream-error` is preserved instead of collapsing to a + * false accepted "no PR". A legacy host that still returns `PRInfo | null` is + * mapped to `found` / `no-pr` to preserve backward behavior on that host only. + */ +function normalizeRuntimePRForBranchOutcome( + result: PRRefreshOutcome | PRInfo | null +): PRRefreshOutcome { + if (result && typeof result === 'object' && 'kind' in result) { + return result + } + return result + ? { kind: 'found', pr: result, fetchedAt: Date.now() } + : { kind: 'no-pr', fetchedAt: Date.now() } +} + export const createGitHubSlice: StateCreator = (set, get) => ({ prCache: {}, issueCache: {}, @@ -3202,7 +3223,7 @@ export const createGitHubSlice: StateCreator = (s : null const requestHeadOid = candidateWorktree?.head ?? null const outcome = runtimeRepo - ? await callRuntimeRpc( + ? await callRuntimeRpc( runtimeRepo.target, 'github.prForBranch', { @@ -3215,11 +3236,7 @@ export const createGitHubSlice: StateCreator = (s : {}) }, { timeoutMs: 30_000 } - ).then((pr) => - pr - ? ({ kind: 'found', pr, fetchedAt: Date.now() } as const) - : ({ kind: 'no-pr', fetchedAt: Date.now() } as const) - ) + ).then((result) => normalizeRuntimePRForBranchOutcome(result)) : await (async () => { const candidate: GitHubPRRefreshCandidate = { repoId: repoId ?? '', @@ -3263,6 +3280,27 @@ export const createGitHubSlice: StateCreator = (s const pr: PRInfo | null = outcome.kind === 'found' ? outcome.pr : outcome.kind === 'no-pr' ? null : null if (outcome.kind === 'upstream-error') { + // Why: the runtime RPC path does not flow through the coordinator + // broadcast that populates prRefreshStates on native execution, so a + // runtime/SSH gh failure would otherwise show generic "checking" copy. + // Record the classified error + schedule here so the Checks panel shows + // the same classified failure copy as native (design criterion 2). + if (runtimeRepo && prRequestGenerations.get(cacheKey) === generation) { + set((s) => { + const nextStates = { ...s.prRefreshStates } + delete nextStates[cacheKey] + nextStates[cacheKey] = { + status: 'error', + reason: 'swr', + updatedAt: Date.now(), + message: outcome.message, + errorType: outcome.errorType, + nextAutoRetryAt: outcome.nextAutoRetryAt, + retryDisabledUntil: outcome.retryDisabledUntil + } + return { prRefreshStates: nextStates } + }) + } return cached?.data ?? null } if (prRequestGenerations.get(cacheKey) === generation) { @@ -4155,7 +4193,9 @@ export const createGitHubSlice: StateCreator = (s reason: event.reason, updatedAt: Date.now(), message: event.outcome.message, - errorType: event.outcome.errorType + errorType: event.outcome.errorType, + nextAutoRetryAt: event.outcome.nextAutoRetryAt, + retryDisabledUntil: event.outcome.retryDisabledUntil } continue } @@ -4347,11 +4387,18 @@ export const createGitHubSlice: StateCreator = (s // Why: delete-then-set moves this key to the end of insertion order so // capRecordByInsertionOrder evicts genuinely idle keys, not active ones. delete nextStates[alias.cacheKey] + const isPaused = event.status === 'paused' nextStates[alias.cacheKey] = { status: event.status, reason: event.reason, updatedAt: Date.now(), - pausedUntil: event.pausedUntil + pausedUntil: event.pausedUntil, + skippedReason: event.skippedReason, + // Why: a paused refresh is a rate-limit gate. Map its pausedUntil into + // the unified schedule so the panel shows the auto-retry time and + // disables manual Retry until the limit resets. + nextAutoRetryAt: isPaused ? event.pausedUntil : undefined, + retryDisabledUntil: isPaused ? event.pausedUntil : undefined } } } diff --git a/src/shared/hosted-review.ts b/src/shared/hosted-review.ts index 04094cf0774..23cfa655865 100644 --- a/src/shared/hosted-review.ts +++ b/src/shared/hosted-review.ts @@ -119,12 +119,21 @@ export type HostedReviewCreationNextAction = | 'open_existing_review' | null +/** + * Records whether the eligibility result observed an authoritative existing-review + * lookup. `found` / `not_found` come only from an accepted provider lookup; + * `unavailable` marks a local-blocker fallback returned after a swallowed or + * skipped lookup, so it can never masquerade as authoritative no-review evidence. + */ +export type HostedReviewLookupOutcome = 'found' | 'not_found' | 'unavailable' + export type HostedReviewCreationEligibility = { provider: HostedReviewProvider review: HostedReviewSummary | null canCreate: boolean blockedReason: HostedReviewCreationBlockedReason nextAction: HostedReviewCreationNextAction + reviewLookupOutcome: HostedReviewLookupOutcome defaultBaseRef?: string | null head?: string | null title?: string | null diff --git a/src/shared/source-control-create-review-intent.ts b/src/shared/source-control-create-review-intent.ts index f3cb5854f4e..880fce17700 100644 --- a/src/shared/source-control-create-review-intent.ts +++ b/src/shared/source-control-create-review-intent.ts @@ -41,6 +41,11 @@ export function resolveCreateReviewIntentEligibility({ !hasCurrentBranch || !hostedReviewCreation || hostedReviewCreation.canCreate || + // Fail closed when the existing-review lookup could not prove there is no + // review: a local blocker (e.g. needs_push) returned after a failed lookup + // must not offer a Create PR intent that would push under a false promise — + // the main preflight would refuse the create anyway (invariant 8). + hostedReviewCreation.reviewLookupOutcome === 'unavailable' || !supportsHostedReviewCreation(hostedReviewCreation.provider) ) { return { eligible: false, kind: null } diff --git a/src/shared/source-control-primary-action-decision.test.ts b/src/shared/source-control-primary-action-decision.test.ts index 1cdcc1193b5..6f153d74bbe 100644 --- a/src/shared/source-control-primary-action-decision.test.ts +++ b/src/shared/source-control-primary-action-decision.test.ts @@ -151,7 +151,8 @@ describe('source-control primary action decision', () => { review: null, canCreate: true, blockedReason: null, - nextAction: null + nextAction: null, + reviewLookupOutcome: 'not_found' } }) expect(resolveSourceControlPrimaryActionDecision(input).kind).toBe('create_pr') @@ -167,7 +168,8 @@ describe('source-control primary action decision', () => { review: null, canCreate: false, blockedReason: null, - nextAction: null + nextAction: null, + reviewLookupOutcome: 'not_found' }, isHostedReviewCreationLoading: true }) diff --git a/src/shared/types.ts b/src/shared/types.ts index 405d9ed5ba6..ec80f68b690 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1202,9 +1202,13 @@ export type PRInfo = { conflictSummary?: PRConflictSummary } -// server_error covers GitHub-side HTTP 5xx outages (githubstatus.com incidents), -// distinct from a transport-level `network` failure or a `rate_limited` budget. -export type PRRefreshUpstreamErrorType = +/** + * Discriminates a classified GitHub PR-refresh failure. The renderer maps these + * to stable, non-destructive empty-state copy; a `hard` subset (auth, permission, + * repo_unavailable, gh_unavailable) means the existing-review lookup is currently + * impossible and must hide the Create composer. + */ +export type PRRefreshErrorType = | 'rate_limited' | 'auth' | 'network' @@ -1214,14 +1218,23 @@ export type PRRefreshUpstreamErrorType = | 'server_error' | 'unknown' +// Backward-compatible name used by outage-copy consumers added on main. +export type PRRefreshUpstreamErrorType = PRRefreshErrorType + export type PRRefreshOutcome = | { kind: 'found'; pr: PRInfo; fetchedAt: number } | { kind: 'no-pr'; fetchedAt: number } | { kind: 'upstream-error' - errorType: PRRefreshUpstreamErrorType + errorType: PRRefreshErrorType message: string fetchedAt: number + // Unified retry schedule (see docs/reference/pr-panel-refresh-guidance.md). + // `nextAutoRetryAt`: earliest time main expects to auto-retry this key. + // `retryDisabledUntil`: earliest time a manual Retry / refreshPRNow is + // accepted (rate-limit gates only, never ordinary network/auth backoff). + nextAutoRetryAt?: number + retryDisabledUntil?: number } export type GitHubPRRefreshReason = 'visible' | 'active' | 'post-push' | 'manual' | 'swr' From 098db336383875bea7a6a9f3c970f3fcba05c78b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:35:12 -0700 Subject: [PATCH 02/45] Keep lineage children with their parent when dragging to another status lane (#9472) Dragging a parent worktree card to a different status lane in the sidebar moved only the parent, orphaning its visible lineage children in the old lane even though the drag preview showed them moving as one unit. All status-lane and board drop commit paths (plus their hover previews) now use the same lineage-expanded dragged set the reorder path already uses. Fixes #9083 --- ...treeList.status-lane-lineage-drop.test.tsx | 438 ++++++++++++++++++ .../src/components/sidebar/WorktreeList.tsx | 56 ++- 2 files changed, 474 insertions(+), 20 deletions(-) create mode 100644 src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx diff --git a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx new file mode 100644 index 00000000000..0311879c5b7 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx @@ -0,0 +1,438 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + Repo, + WorkspaceStatus, + Worktree, + WorktreeCardProperty, + WorktreeLineage, + WorktreeMeta +} from '../../../../shared/types' +import { DEFAULT_WORKSPACE_STATUSES } from '../../../../shared/workspace-status-defaults' +import { + WORKSPACE_STATUS_DRAG_IDS_TYPE, + WORKSPACE_STATUS_DRAG_TYPE +} from './workspace-status-drag-data' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +const mockStore = vi.hoisted(() => ({ + state: {} as Record, + activateWorktreeFromSidebar: vi.fn(), + openModal: vi.fn(), + updateWorktreeMeta: vi.fn(), + updateWorktreesMeta: vi.fn(), + setSortBy: vi.fn(), + fetchHostedReviewForBranch: vi.fn(), + fetchIssue: vi.fn(), + fetchLinearIssue: vi.fn(), + openTaskPage: vi.fn() +})) + +type WorktreeListComponent = React.ComponentType<{ + scrollOffsetRef: React.RefObject + scrollAnchorRef: React.RefObject +}> + +let WorktreeList: WorktreeListComponent + +function makeFolderWorkspacePathStatusMockState(): Record { + return { + fetchFolderWorkspacePathStatus: vi.fn(), + folderWorkspaces: [], + folderWorkspacePathStatuses: {}, + getFolderWorkspacePathStatusCacheKey: (request: unknown) => JSON.stringify(request), + getFreshFolderWorkspacePathStatus: () => null + } +} + +vi.mock('@/store', () => { + const useAppStore = ((selector: (state: Record) => unknown) => + selector(mockStore.state)) as (( + selector: (state: Record) => unknown + ) => unknown) & { + getState: () => Record + } + useAppStore.getState = () => mockStore.state + return { useAppStore } +}) + +vi.mock('@tanstack/react-virtual', () => ({ + defaultRangeExtractor: ({ startIndex, endIndex }: { startIndex: number; endIndex: number }) => + Array.from({ length: endIndex - startIndex + 1 }, (_, index) => startIndex + index), + measureElement: () => 32, + useVirtualizer: ({ count }: { count: number }) => ({ + elementsCache: new Map(), + getTotalSize: () => count * 96, + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ + index, + key: `row-${index}`, + start: index * 96 + })), + measureElement: vi.fn(), + scrollToIndex: vi.fn() + }) +})) + +vi.mock('@/hooks/useVirtualizedScrollAnchor', () => ({ + VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT: 'orca:test-record-scroll-anchor', + useVirtualizedScrollAnchor: vi.fn() +})) + +vi.mock('./project-header-drag', () => ({ + useRepoHeaderDrag: () => ({ + state: { draggingRepoId: null, dropIndicatorY: null }, + onHandlePointerDown: vi.fn() + }), + isRepoHeaderActionTarget: () => false +})) + +vi.mock('@/components/ui/hover-card', () => ({ + HoverCard: ({ children }: { children: ReactNode }) => <>{children}, + HoverCardContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + HoverCardTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => ( + + ), + DropdownMenuSeparator: () =>
, + DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}, + DropdownMenuSubContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children} +})) + +vi.mock('@/lib/sidebar-worktree-activation', () => ({ + activateWorktreeFromSidebar: mockStore.activateWorktreeFromSidebar +})) + +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorktree: vi.fn() +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + getActiveRuntimeTarget: () => ({ kind: 'local' }), + callRuntimeRpc: vi.fn() +})) + +vi.mock('./CacheTimer', () => ({ + default: () => null, + usePromptCacheCountdownStartedAt: () => null +})) + +vi.mock('./WorktreeCardAgents', () => ({ + default: ({ worktreeId }: { worktreeId: string }) => ( +
Agent row
+ ), + SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT: 'orca:test-suppress-scroll-adjustment' +})) + +vi.mock('./SshDisconnectedDialog', () => ({ + SshDisconnectedDialog: () => null +})) + +vi.mock('./WorktreeContextMenu', () => ({ + default: ({ children }: { children: ReactNode }) => <>{children}, + CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', + WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope', + WORKTREE_NATIVE_CONTEXT_MENU_ATTR: 'data-worktree-native-context-menu' +})) + +function makeRepo(): Repo { + return { + id: 'repo-1', + path: '/tmp/status-lane-lineage', + displayName: 'status-lane-lineage', + badgeColor: '#999999', + addedAt: 1 + } +} + +function makeWorktree(args: { + id: string + displayName: string + branch: string + sortOrder: number + instanceId: string + workspaceStatus: WorkspaceStatus +}): Worktree { + return { + id: args.id, + instanceId: args.instanceId, + repoId: 'repo-1', + path: `/tmp/status-lane-lineage/${args.id}`, + displayName: args.displayName, + branch: args.branch, + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: args.sortOrder, + lastActivityAt: args.sortOrder, + workspaceStatus: args.workspaceStatus + } +} + +function makeLineage(worktree: Worktree, parent: Worktree): WorktreeLineage { + return { + worktreeId: worktree.id, + worktreeInstanceId: worktree.instanceId!, + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId!, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'explicit' }, + createdAt: 1 + } +} + +function makeFolderWorkspacePathStatusState(): Record { + return { + fetchFolderWorkspacePathStatus: vi.fn(), + folderWorkspacePathStatuses: {}, + folderWorkspaces: [], + getFolderWorkspacePathStatusCacheKey: (request: unknown) => JSON.stringify(request), + getFreshFolderWorkspacePathStatus: vi.fn(() => null) + } +} + +// Grouped by workspace status: parent + its visible lineage child both live in the +// "in-progress" lane; an unrelated bystander sits in "todo". +function setStatusLaneState(): void { + const repo = makeRepo() + const parent = makeWorktree({ + id: 'parent', + instanceId: 'parent-instance', + displayName: 'lineage parent', + branch: 'parent-branch', + sortOrder: 30, + workspaceStatus: 'in-progress' + }) + const child = makeWorktree({ + id: 'child', + instanceId: 'child-instance', + displayName: 'lineage child', + branch: 'child-branch', + sortOrder: 20, + workspaceStatus: 'in-progress' + }) + const bystander = makeWorktree({ + id: 'bystander', + instanceId: 'bystander-instance', + displayName: 'bystander', + branch: 'bystander-branch', + sortOrder: 10, + workspaceStatus: 'todo' + }) + mockStore.state = { + ...makeFolderWorkspacePathStatusMockState(), + activeModal: '', + activeView: 'terminal', + activeWorktreeId: null, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + browserTabsByWorktree: {}, + clearPendingRevealWorktreeId: vi.fn(), + collapsedGroups: new Set(), + deleteStateByWorktreeId: {}, + detectedWorktreesByRepo: {}, + fetchHostedReviewForBranch: mockStore.fetchHostedReviewForBranch, + fetchIssue: mockStore.fetchIssue, + fetchLinearIssue: mockStore.fetchLinearIssue, + filterRepoIds: [], + ...makeFolderWorkspacePathStatusState(), + gitConflictOperationByWorktree: {}, + groupBy: 'workspace-status', + hideDefaultBranchWorkspace: false, + hostedReviewCache: {}, + issueCache: {}, + linearIssueCache: {}, + linearStatus: null, + migrationUnsupportedByPtyId: {}, + openModal: mockStore.openModal, + openSettingsPage: vi.fn(), + openSettingsTarget: null, + openTaskPage: mockStore.openTaskPage, + pendingRevealWorktree: null, + prCache: {}, + projectGroups: [], + ptyIdsByTabId: {}, + recordFeatureInteraction: vi.fn(), + remoteBranchConflictByWorktreeId: {}, + reorderRepos: vi.fn(), + reportVisibleGitHubPRRefreshCandidates: vi.fn(), + repos: [repo], + retainedAgentsByPaneKey: {}, + revealWorktreeInSidebar: vi.fn(), + runtimePaneTitlesByTabId: {}, + setFilterRepoIds: vi.fn(), + setHideDefaultBranchWorkspace: vi.fn(), + setRenamingWorktreeId: vi.fn(), + setShowSleepingWorkspaces: vi.fn(), + setSortBy: mockStore.setSortBy, + setWorktreesPinnedAndReveal: vi.fn(), + settings: null, + showSleepingWorkspaces: true, + sortBy: 'manual', + sortEpoch: 0, + sshConnectedGeneration: 0, + sshConnectionStates: new Map(), + sshTargetLabels: new Map(), + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + toggleCollapsedGroup: vi.fn(), + updateRepo: vi.fn(), + updateWorktreeMeta: mockStore.updateWorktreeMeta, + updateWorktreesMeta: mockStore.updateWorktreesMeta, + workspaceHostScope: 'all', + workspacePortScan: null, + workspaceStatuses: [...DEFAULT_WORKSPACE_STATUSES], + worktreeCardProperties: [ + 'status', + 'pr', + 'comment', + 'inline-agents' + ] satisfies WorktreeCardProperty[], + worktreeLineageById: { [child.id]: makeLineage(child, parent) }, + worktreesByRepo: { [repo.id]: [parent, child, bystander] } + } +} + +const mountedRoots: Root[] = [] + +async function renderWorktreeList(): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + mountedRoots.push(root) + await act(async () => { + root.render( + + ) + }) + return container +} + +// A minimal DataTransfer stand-in: the native-drop commit path only reads the +// worktree-id payloads and the type list off it. +function makeWorktreeIdDataTransfer(worktreeIds: readonly string[]): DataTransfer { + const [firstId] = worktreeIds + const data: Record = { + [WORKSPACE_STATUS_DRAG_TYPE]: firstId ?? '', + [WORKSPACE_STATUS_DRAG_IDS_TYPE]: JSON.stringify(worktreeIds), + 'text/plain': firstId ?? '' + } + return { + effectAllowed: 'move', + types: Object.keys(data), + getData: (type: string) => data[type] ?? '', + setData: () => {} + } as unknown as DataTransfer +} + +function findStatusLaneHeader(container: HTMLElement, status: WorkspaceStatus): HTMLElement { + const header = container.querySelector( + `[data-workspace-status-drop-target][data-workspace-status="${status}"]` + ) + if (!header) { + throw new Error(`No status lane header rendered for "${status}"`) + } + return header +} + +async function dropWorktreesOnStatusLane( + header: HTMLElement, + worktreeIds: readonly string[] +): Promise { + const event = new Event('drop', { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'dataTransfer', { + value: makeWorktreeIdDataTransfer(worktreeIds) + }) + await act(async () => { + header.dispatchEvent(event) + }) +} + +function committedStatusUpdates(): Map> { + expect(mockStore.updateWorktreesMeta).toHaveBeenCalledTimes(1) + return mockStore.updateWorktreesMeta.mock.calls[0]![0] as Map> +} + +describe('WorktreeList status-lane drop carries visible lineage children (#9083)', () => { + beforeAll(async () => { + WorktreeList = (await import('./WorktreeList')).default as WorktreeListComponent + }, 60_000) + + beforeEach(() => { + vi.clearAllMocks() + setStatusLaneState() + }) + + afterEach(async () => { + await act(async () => { + for (const root of mountedRoots.splice(0)) { + root.unmount() + } + }) + document.body.innerHTML = '' + }) + + it('moves a dragged parent and its visible lineage child to the dropped lane', async () => { + const container = await renderWorktreeList() + const todoHeader = findStatusLaneHeader(container, 'todo') + + // Only the parent id rides on the native drag payload; the wrapper must expand it. + await dropWorktreesOnStatusLane(todoHeader, ['parent']) + + const updates = committedStatusUpdates() + expect(updates.get('parent')).toEqual({ workspaceStatus: 'todo' }) + expect(updates.get('child')).toEqual({ workspaceStatus: 'todo' }) + }) + + it('leaves worktrees in other lanes untouched', async () => { + const container = await renderWorktreeList() + const todoHeader = findStatusLaneHeader(container, 'todo') + + await dropWorktreesOnStatusLane(todoHeader, ['parent']) + + const updates = committedStatusUpdates() + expect(updates.has('bystander')).toBe(false) + expect(updates.size).toBe(2) + }) + + it('commits only itself when a childless worktree is dropped onto another lane', async () => { + const container = await renderWorktreeList() + const inProgressHeader = findStatusLaneHeader(container, 'in-progress') + + await dropWorktreesOnStatusLane(inProgressHeader, ['bystander']) + + const updates = committedStatusUpdates() + expect(updates.get('bystander')).toEqual({ + workspaceStatus: 'in-progress' + }) + expect(updates.has('parent')).toBe(false) + expect(updates.has('child')).toBe(false) + expect(updates.size).toBe(1) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index e73e9142f3e..137f364e084 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -2844,7 +2844,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp y: drag.currentY, shouldShowDropIndicator: (target) => Boolean( - target.status && shouldShowWorkspaceBoardDropIndicator(drag.draggedIds, target.status) + target.status && + shouldShowWorkspaceBoardDropIndicator(drag.reorderDraggedIds, target.status) ) }) drag.latestBoardDropTarget = { @@ -2919,7 +2920,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ? computeWorktreeStatusDrop({ pointerY: drag.currentY, status: preferredStatusTarget.status, - draggedIds: drag.draggedIds + draggedIds: drag.reorderDraggedIds }) : null if (statusDrop) { @@ -2967,7 +2968,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ? computeWorktreeStatusDrop({ pointerY: drag.currentY, status: target.status, - draggedIds: drag.draggedIds + draggedIds: drag.reorderDraggedIds }) : null if (statusDrop) { @@ -3258,7 +3259,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp onPinWorktrees(drag.draggedIds) } else if (boardDropTarget.status) { onDropWorktreesOnWorkspaceBoard({ - worktreeIds: drag.draggedIds, + worktreeIds: drag.reorderDraggedIds, status: boardDropTarget.status, dropIndex: boardDropTarget.dropIndex, groups: getWorkspaceKanbanSidebarDropGroups() @@ -3290,7 +3291,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ? computeWorktreeStatusDrop({ pointerY: event.clientY, status: preferredStatusTarget.status, - draggedIds: drag.draggedIds + draggedIds: drag.reorderDraggedIds }) : null if (preferredStatusTarget.isPinDrop) { @@ -3298,13 +3299,13 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } else if (preferredStatusTarget.status) { if (statusDrop) { onMoveWorktreesToStatusAtIndex({ - worktreeIds: drag.draggedIds, + worktreeIds: drag.reorderDraggedIds, status: preferredStatusTarget.status, dropIndex: statusDrop.dropIndex, groups: worktreeDragGroups }) } else { - onMoveWorktreesToStatus(drag.draggedIds, preferredStatusTarget.status) + onMoveWorktreesToStatus(drag.reorderDraggedIds, preferredStatusTarget.status) } } clearWorktreeDrag() @@ -3332,7 +3333,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ? computeWorktreeStatusDrop({ pointerY: event.clientY, status: currentTarget.status, - draggedIds: drag.draggedIds + draggedIds: drag.reorderDraggedIds }) : null const { target, preview: statusDrop } = resolveWorktreeSidebarStatusDropCommitTarget({ @@ -3349,13 +3350,13 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } else if (target.status) { if (statusDrop) { onMoveWorktreesToStatusAtIndex({ - worktreeIds: drag.draggedIds, + worktreeIds: drag.reorderDraggedIds, status: target.status, dropIndex: statusDrop.dropIndex, groups: worktreeDragGroups }) } else { - onMoveWorktreesToStatus(drag.draggedIds, target.status) + onMoveWorktreesToStatus(drag.reorderDraggedIds, target.status) } } } @@ -3454,7 +3455,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ? computeWorktreeStatusDrop({ pointerY: point.clientY, status: target.status, - draggedIds: session.draggedIds + draggedIds: session.reorderDraggedIds }) : null if (statusDrop) { @@ -3603,7 +3604,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ? computeWorktreeStatusDrop({ pointerY: event.clientY, status: target.status, - draggedIds: session.draggedIds + draggedIds: session.reorderDraggedIds }) : null if (statusDrop) { @@ -3701,14 +3702,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ? computeWorktreeStatusDrop({ pointerY: event.clientY, status: target.status, - draggedIds: session.draggedIds + draggedIds: session.reorderDraggedIds }) : null if (target.status && statusDrop) { event.preventDefault() event.stopPropagation() onMoveWorktreesToStatusAtIndex({ - worktreeIds: session.draggedIds, + worktreeIds: session.reorderDraggedIds, status: target.status, dropIndex: statusDrop.dropIndex, groups: worktreeDragGroups @@ -3896,14 +3897,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ? computeWorktreeStatusDrop({ pointerY: event.clientY, status, - draggedIds: session.draggedIds + draggedIds: session.reorderDraggedIds }) : null setDragOverStatus(null) if (session && statusDrop) { event.stopPropagation() onMoveWorktreesToStatusAtIndex({ - worktreeIds: session.draggedIds, + worktreeIds: session.reorderDraggedIds, status, dropIndex: statusDrop.dropIndex, groups: worktreeDragGroups @@ -3911,11 +3912,17 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp clearWorktreeDrag() return } - onMoveWorktreesToStatus(worktreeIds, status) + // Match the status-drop scope to the drag-preview scope (#9083): with a live session use its + // precomputed expanded set; the session-less foreign-origin case expands the dataTransfer ids live. + onMoveWorktreesToStatus( + session ? session.reorderDraggedIds : getReorderDraggedIds(worktreeIds), + status + ) }, [ clearWorktreeDrag, computeWorktreeStatusDrop, + getReorderDraggedIds, onMoveWorktreesToStatus, onMoveWorktreesToStatusAtIndex, worktreeDragGroups @@ -3956,14 +3963,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ? computeWorktreeStatusDrop({ pointerY: event.clientY, status: target.status, - draggedIds: session.draggedIds + draggedIds: session.reorderDraggedIds }) : null if (target.status && statusDrop) { event.preventDefault() event.stopPropagation() onMoveWorktreesToStatusAtIndex({ - worktreeIds: session.draggedIds, + worktreeIds: session.reorderDraggedIds, status: target.status, dropIndex: statusDrop.dropIndex, groups: worktreeDragGroups @@ -4034,6 +4041,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp return () => document.removeEventListener('visibilitychange', handleVisibilityChange) }, [clearWorktreeDrag]) + // Why: this session-less capture-phase committer is the primary native-drop path in packaged + // Electron. Expand at this call site (not in the shared hook, which WorkspaceKanbanDrawer uses for + // its flat board) so a dropped parent carries its visible lineage children (#9083). + const moveWorktreesToStatusForDocumentDrop = useCallback( + (ids: readonly string[], status: WorkspaceStatus) => + onMoveWorktreesToStatus(getReorderDraggedIds(ids), status), + [getReorderDraggedIds, onMoveWorktreesToStatus] + ) + useWorkspaceStatusDocumentDrop( scrollRef, onMoveWorktreeToStatus, @@ -4041,7 +4057,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp handleWorkspaceStatusDragFinish, hasWorkspaceDropTargets, { - onMoveWorktreesToStatus, + onMoveWorktreesToStatus: moveWorktreesToStatusForDocumentDrop, onPinWorktrees } ) From 85ab72901be1ca4c8dc59eaf00025905ed7bdc8c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:35:26 -0700 Subject: [PATCH 03/45] Persist the AI Vault session parse cache across restarts (#9474) Cold launches re-parsed the entire agent-transcript corpus (measured 6.7 GB / 109 s upstream; 1.79 GB / 3.7 s locally) because the parse cache was an in-memory Map. Persist the reusable portion (mtime+size gated session entries, resume states dropped) to one JSON file under the canonical userData dir: lazy load before the first scan, debounced atomic save after scans that parsed anything. Restart scans now reuse unchanged files (measured 328 ms / 3 MB, reused=1036). --- .../session-parse-cache-persistence.test.ts | 437 ++++++++++++++++++ .../session-parse-cache-persistence.ts | 209 +++++++++ .../ai-vault/session-scanner-parse-cache.ts | 46 ++ src/main/ai-vault/session-scanner.ts | 9 + src/main/index.ts | 8 + 5 files changed, 709 insertions(+) create mode 100644 src/main/ai-vault/session-parse-cache-persistence.test.ts create mode 100644 src/main/ai-vault/session-parse-cache-persistence.ts diff --git a/src/main/ai-vault/session-parse-cache-persistence.test.ts b/src/main/ai-vault/session-parse-cache-persistence.test.ts new file mode 100644 index 00000000000..4d7aa7113d6 --- /dev/null +++ b/src/main/ai-vault/session-parse-cache-persistence.test.ts @@ -0,0 +1,437 @@ +import { existsSync } from 'node:fs' +import { + appendFile, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + stat, + utimes, + writeFile +} from 'node:fs/promises' +import * as fsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + ensureSessionParseCacheLoaded, + flushSessionParseCachePersistForTests, + initSessionParseCachePersistence, + resetSessionParseCachePersistenceForTests, + scheduleSessionParseCachePersist +} from './session-parse-cache-persistence' +import { scanAiVaultSessions } from './session-scanner' +import { + createSessionParseStats, + parseAgentSessionFileCached, + resetSessionParseCacheForTests, + seedSessionParseCache, + type PersistedSessionParseCacheEntry, + type SessionParseStats +} from './session-scanner-parse-cache' +import { isolatedScanRoots } from './session-scanner-test-fixtures' +import { parseClaudeSessionFile } from './session-scanner-primary-parsers' +import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types' + +// Spy-wrap (real implementations still run) so the zero-disk-IO test can +// assert the uninitialized module never touches the filesystem. +vi.mock('node:fs/promises', { spy: true }) + +const APP_VERSION = '1.2.3-test' + +let tempRoots: string[] = [] + +beforeEach(() => { + resetSessionParseCacheForTests() + resetSessionParseCachePersistenceForTests() +}) + +afterEach(async () => { + resetSessionParseCachePersistenceForTests() + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function makeTempDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-parse-cache-persist-')) + tempRoots.push(root) + return root +} + +async function claudeCandidate(path: string): Promise { + const fileStat = await stat(path) + const file: FileWithMtime = { + path, + mtimeMs: fileStat.mtimeMs, + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size + } + return { agent: 'claude', file, codexHome: null } +} + +function userRecord(index: number, text: string): string { + return JSON.stringify({ + type: 'user', + sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + timestamp: new Date(1740000000000 + index * 60_000).toISOString(), + cwd: '/repo/app', + gitBranch: 'main', + message: { role: 'user', content: text } + }) +} + +function assistantRecord(index: number, text: string): string { + return JSON.stringify({ + type: 'assistant', + sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + timestamp: new Date(1740000000000 + index * 60_000).toISOString(), + message: { + role: 'assistant', + model: 'claude-fable-5', + content: [{ type: 'text', text }], + usage: { input_tokens: 100, output_tokens: 40 } + } + }) +} + +async function writeTranscript(root: string): Promise { + const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') + await writeFile(path, `${userRecord(0, 'first question')}\n${assistantRecord(1, 'answer')}\n`) + return path +} + +async function parseAndPersist(path: string): Promise { + const stats = createSessionParseStats() + await ensureSessionParseCacheLoaded() + await parseAgentSessionFileCached(await claudeCandidate(path), process.platform, stats) + scheduleSessionParseCachePersist(stats) + await flushSessionParseCachePersistForTests() + return stats +} + +// Clear the in-memory cache and the persistence module's memoized state, then +// re-enable persistence against the same file — a fresh launch, same profile. +function simulateRestart(cacheFile: string, appVersion = APP_VERSION): void { + resetSessionParseCacheForTests() + resetSessionParseCachePersistenceForTests() + initSessionParseCachePersistence({ filePath: cacheFile, appVersion }) +} + +async function coldParseStats(path: string): Promise { + const stats = createSessionParseStats() + await ensureSessionParseCacheLoaded() + await parseAgentSessionFileCached(await claudeCandidate(path), process.platform, stats) + return stats +} + +describe('session parse cache persistence', () => { + it('round-trips: a persisted entry is a reused hit after a restart, without reading the transcript', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'vault-state', 'session-parse-cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + + const transcript = await writeTranscript(root) + const candidate = await claudeCandidate(transcript) + const stats = createSessionParseStats() + await ensureSessionParseCacheLoaded() + const first = await parseAgentSessionFileCached(candidate, process.platform, stats) + expect(first).not.toBeNull() + expect(stats.fullParses).toBe(1) + + scheduleSessionParseCachePersist(stats) + await flushSessionParseCachePersistForTests() + expect(existsSync(cacheFile)).toBe(true) + + simulateRestart(cacheFile) + await ensureSessionParseCacheLoaded() + + // Deleting the transcript proves the hit needs no transcript read at all. + await rm(transcript) + const reusedStats = createSessionParseStats() + const reused = await parseAgentSessionFileCached(candidate, process.platform, reusedStats) + expect(reusedStats.reused).toBe(1) + expect(reusedStats.fullParses).toBe(0) + expect(reusedStats.incremental).toBe(0) + expect(reusedStats.bytesRead).toBe(0) + expect(reused).toEqual(first) + }) + + it('ignores a corrupt cache file and scans cold', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + await writeFile(cacheFile, 'not json {{{') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + + const transcript = await writeTranscript(root) + const stats = await coldParseStats(transcript) + expect(stats.fullParses).toBe(1) + expect(stats.reused).toBe(0) + }) + + it('ignores a cache file with a mismatched schemaVersion', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + const transcript = await writeTranscript(root) + await parseAndPersist(transcript) + + const persisted = JSON.parse(await readFile(cacheFile, 'utf-8')) + persisted.schemaVersion = 999 + await writeFile(cacheFile, JSON.stringify(persisted)) + + simulateRestart(cacheFile) + const stats = await coldParseStats(transcript) + expect(stats.fullParses).toBe(1) + expect(stats.reused).toBe(0) + }) + + it('ignores a cache file written by a different app version', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + const transcript = await writeTranscript(root) + await parseAndPersist(transcript) + + simulateRestart(cacheFile, '9.9.9-other') + const stats = await coldParseStats(transcript) + expect(stats.fullParses).toBe(1) + expect(stats.reused).toBe(0) + }) + + it('seeding never clobbers a live in-memory entry', async () => { + const root = await makeTempDir() + const transcript = await writeTranscript(root) + const candidate = await claudeCandidate(transcript) + const live = await parseAgentSessionFileCached(candidate, process.platform) + expect(live).not.toBeNull() + + // A stale persisted entry for the same path (session: null marker). + seedSessionParseCache([ + [ + transcript, + { + mtimeMs: candidate.file.mtimeMs, + sizeBytes: candidate.file.sizeBytes ?? null, + platform: process.platform, + session: null + } + ] + ]) + + const stats = createSessionParseStats() + const after = await parseAgentSessionFileCached(candidate, process.platform, stats) + expect(stats.reused).toBe(1) + expect(after).toBe(live) + }) + + it('falls through to a full parse when a seeded file changed while the app was closed', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + const transcript = await writeTranscript(root) + await parseAndPersist(transcript) + + simulateRestart(cacheFile) + await ensureSessionParseCacheLoaded() + + // Grown while "closed": seeded entries have no resume state, so this is a + // full parse (not incremental) whose result matches a cold parse. + await appendFile(transcript, `${userRecord(2, 'follow-up')}\n${assistantRecord(3, 'more')}\n`) + const stats = createSessionParseStats() + const reparsed = await parseAgentSessionFileCached( + await claudeCandidate(transcript), + process.platform, + stats + ) + expect(stats.fullParses).toBe(1) + expect(stats.incremental).toBe(0) + expect(stats.reused).toBe(0) + expect(reparsed).toEqual(await parseClaudeSessionFile((await claudeCandidate(transcript)).file)) + expect(reparsed?.messageCount).toBe(4) + }) + + it('performs zero disk IO when never initialized', async () => { + vi.clearAllMocks() + + await ensureSessionParseCacheLoaded() + scheduleSessionParseCachePersist({ reused: 0, incremental: 2, fullParses: 5, bytesRead: 10 }) + await flushSessionParseCachePersistForTests() + + expect(fsPromises.readFile).not.toHaveBeenCalled() + expect(fsPromises.writeFile).not.toHaveBeenCalled() + expect(fsPromises.mkdir).not.toHaveBeenCalled() + expect(fsPromises.rename).not.toHaveBeenCalled() + expect(fsPromises.rm).not.toHaveBeenCalled() + }) + + it('collapses back-to-back schedules into one write', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + const transcript = await writeTranscript(root) + const stats = await coldParseStats(transcript) + + vi.clearAllMocks() + scheduleSessionParseCachePersist(stats) + scheduleSessionParseCachePersist(stats) + await flushSessionParseCachePersistForTests() + + expect(fsPromises.rename).toHaveBeenCalledTimes(1) + expect(existsSync(cacheFile)).toBe(true) + }) + + it('sweeps orphaned temp files from a prior crashed save on load', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + const orphan = join(root, 'session-parse-cache-12345-99.tmp') + await writeFile(orphan, '{"half":"written') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + + await ensureSessionParseCacheLoaded() + expect(existsSync(orphan)).toBe(false) + }) + + it('scanAiVaultSessions seeds from the persisted cache and persists after parsing', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + const roots = isolatedScanRoots(root) + const transcript = join(roots.claudeProjectsDir, 'project', 'scan-session.jsonl') + await mkdir(join(roots.claudeProjectsDir, 'project'), { recursive: true }) + // Same-length markers so the rewrite below preserves sizeBytes exactly. + await writeFile( + transcript, + `${userRecord(0, 'persisted-scan-marker-AAAA')}\n${assistantRecord(1, 'answer')}\n` + ) + const pinnedMtime = new Date(1740000000000) + await utimes(transcript, pinnedMtime, pinnedMtime) + + const first = await scanAiVaultSessions(roots) + expect(first.sessions).toHaveLength(1) + expect(JSON.stringify(first.sessions[0])).toContain('persisted-scan-marker-AAAA') + // The scan itself (not a manual schedule call) must have queued the save. + await flushSessionParseCachePersistForTests() + expect(existsSync(cacheFile)).toBe(true) + + simulateRestart(cacheFile) + // Rewrite with identical length and mtime: only a seeded cache hit can + // still return the original marker; a cold re-parse would see BBBB. + await writeFile( + transcript, + `${userRecord(0, 'persisted-scan-marker-BBBB')}\n${assistantRecord(1, 'answer')}\n` + ) + await utimes(transcript, pinnedMtime, pinnedMtime) + + const second = await scanAiVaultSessions(roots) + expect(second.sessions).toHaveLength(1) + expect(JSON.stringify(second.sessions[0])).toContain('persisted-scan-marker-AAAA') + expect(second.sessions[0]).toEqual(first.sessions[0]) + }) + + it('an over-cap seed list keeps the newest tail of the snapshot order', async () => { + const root = await makeTempDir() + const transcript = await writeTranscript(root) + const candidate = await claudeCandidate(transcript) + // Snapshot order is oldest→newest, so a foreign over-cap file must keep + // its newest (last) entries; the real transcript rides at the very end. + const fakes: [string, PersistedSessionParseCacheEntry][] = Array.from( + { length: 4100 }, + (_, index): [string, PersistedSessionParseCacheEntry] => [ + `/nonexistent/fake-${index}.jsonl`, + { mtimeMs: index, sizeBytes: 1, platform: process.platform, session: null } + ] + ) + seedSessionParseCache([ + ...fakes, + [ + transcript, + { + mtimeMs: candidate.file.mtimeMs, + sizeBytes: candidate.file.sizeBytes ?? null, + platform: process.platform, + session: null + } + ] + ]) + + const stats = createSessionParseStats() + await parseAgentSessionFileCached(candidate, process.platform, stats) + expect(stats.reused).toBe(1) + expect(stats.fullParses).toBe(0) + }) + + it('a failing rename cleans up its temp file and keeps the previous snapshot usable', async () => { + const root = await makeTempDir() + const cacheFile = join(root, 'session-parse-cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: APP_VERSION }) + const transcript = await writeTranscript(root) + await parseAndPersist(transcript) + const previousSnapshot = await readFile(cacheFile, 'utf-8') + + // A second session parsed after the good save; its save's rename is + // rejected (the Windows EPERM/EBUSY story: target held open elsewhere). + const other = join(root, 'ffffffff-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') + await writeFile(other, `${userRecord(2, 'second session')}\n${assistantRecord(3, 'reply')}\n`) + const stats = createSessionParseStats() + await parseAgentSessionFileCached(await claudeCandidate(other), process.platform, stats) + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}) + vi.mocked(fsPromises.rename).mockRejectedValueOnce( + Object.assign(new Error('EPERM: rename blocked'), { code: 'EPERM' }) + ) + scheduleSessionParseCachePersist(stats) + await expect(flushSessionParseCachePersistForTests()).resolves.toBeUndefined() + expect(debugSpy).toHaveBeenCalled() + + // The temp file was written before the rename failed; it must not linger, + // and the previous snapshot must be byte-identical (never torn). + expect((await readdir(root)).filter((name) => name.endsWith('.tmp'))).toEqual([]) + expect(await readFile(cacheFile, 'utf-8')).toBe(previousSnapshot) + + // That intact previous snapshot still round-trips on the next launch. + simulateRestart(cacheFile) + await ensureSessionParseCacheLoaded() + const reusedStats = createSessionParseStats() + await parseAgentSessionFileCached( + await claudeCandidate(transcript), + process.platform, + reusedStats + ) + expect(reusedStats.reused).toBe(1) + debugSpy.mockRestore() + }) + + it('swallows save failures and leaves scan results unaffected', async () => { + const root = await makeTempDir() + // A regular file where the cache directory should be makes mkdir fail on + // every platform (no chmod tricks, which don't hold on Windows or as root). + const blocker = join(root, 'blocker') + await writeFile(blocker, 'a file, not a directory') + initSessionParseCachePersistence({ + filePath: join(blocker, 'session-parse-cache.json'), + appVersion: APP_VERSION + }) + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}) + + const transcript = await writeTranscript(root) + const candidate = await claudeCandidate(transcript) + const stats = createSessionParseStats() + await ensureSessionParseCacheLoaded() + const session = await parseAgentSessionFileCached(candidate, process.platform, stats) + expect(session).not.toBeNull() + + scheduleSessionParseCachePersist(stats) + await expect(flushSessionParseCachePersistForTests()).resolves.toBeUndefined() + expect(debugSpy).toHaveBeenCalled() + + // The in-memory cache still serves hits and no partial files were left behind. + const reusedStats = createSessionParseStats() + expect(await parseAgentSessionFileCached(candidate, process.platform, reusedStats)).toBe( + session + ) + expect(reusedStats.reused).toBe(1) + expect(await readdir(root)).toEqual(expect.arrayContaining(['blocker'])) + expect((await readdir(root)).filter((name) => name.endsWith('.tmp'))).toEqual([]) + debugSpy.mockRestore() + }) +}) diff --git a/src/main/ai-vault/session-parse-cache-persistence.ts b/src/main/ai-vault/session-parse-cache-persistence.ts new file mode 100644 index 00000000000..f4a924bd82f --- /dev/null +++ b/src/main/ai-vault/session-parse-cache-persistence.ts @@ -0,0 +1,209 @@ +// Persists the reusable portion of the AI Vault session parse cache to one +// JSON file under userData so a fresh launch reuses prior parse work instead +// of re-reading the whole transcript corpus (issue #9210: 6.7 GB / 109 s cold +// scans). Disabled unless the composition root calls init; every failure mode +// degrades to today's cold-scan behavior. +import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { + seedSessionParseCache, + snapshotSessionParseCacheForPersistence, + type PersistedSessionParseCacheEntry, + type SessionParseStats +} from './session-scanner-parse-cache' + +// Bump when the persisted entry layout changes; a mismatched file is discarded whole. +const SCHEMA_VERSION = 1 +// Debounce so back-to-back scans (desktop IPC + runtime RPC) collapse into one write. +const SAVE_DEBOUNCE_MS = 1_500 +// The payload contains transcript-derived preview text; keep it user-only +// (mode bits are inert on Windows — the userData ACL grant is the boundary there). +const PRIVATE_DIRECTORY_MODE = 0o700 +const PRIVATE_FILE_MODE = 0o600 + +type SessionParseCachePersistenceOptions = { + filePath: string + appVersion: string +} + +let options: SessionParseCachePersistenceOptions | null = null +let loadPromise: Promise | null = null +let saveTimer: NodeJS.Timeout | null = null +let lastSave: Promise = Promise.resolve() + +/** Enable persistence. Called only from the composition root; every export is a no-op until then. */ +export function initSessionParseCachePersistence(next: SessionParseCachePersistenceOptions): void { + options = next +} + +export function resetSessionParseCachePersistenceForTests(): void { + options = null + loadPromise = null + if (saveTimer) { + clearTimeout(saveTimer) + saveTimer = null + } + lastSave = Promise.resolve() +} + +/** + * Seed the in-memory parse cache from disk. Memoized: concurrent scans at + * startup all await the same load. Resolves immediately when uninitialized. + */ +export function ensureSessionParseCacheLoaded(): Promise { + if (options === null) { + return Promise.resolve() + } + loadPromise ??= loadPersistedEntries(options) + return loadPromise +} + +/** + * Schedule a debounced snapshot write after a scan that parsed something. + * Reused-only scans schedule no write (the file already reflects the cache). + */ +export function scheduleSessionParseCachePersist(stats: SessionParseStats): void { + if (options === null || stats.incremental + stats.fullParses <= 0) { + return + } + const current = options + if (saveTimer) { + clearTimeout(saveTimer) + } + saveTimer = setTimeout(() => { + saveTimer = null + // Chained so a slow write and a rescheduled save can't rename out of order + // (an older snapshot landing last); persistSnapshot never rejects. + lastSave = lastSave.then(() => persistSnapshot(current)) + }, SAVE_DEBOUNCE_MS) + // Why: a pending cache save must not keep a quitting process alive. + if (typeof saveTimer.unref === 'function') { + saveTimer.unref() + } +} + +/** Run any pending debounced save immediately and wait for it. Test-only. */ +export async function flushSessionParseCachePersistForTests(): Promise { + if (saveTimer) { + clearTimeout(saveTimer) + saveTimer = null + if (options !== null) { + const current = options + lastSave = lastSave.then(() => persistSnapshot(current)) + } + } + await lastSave +} + +async function loadPersistedEntries(current: SessionParseCachePersistenceOptions): Promise { + await sweepOrphanedTempFiles(current.filePath) + try { + const raw = await readFile(current.filePath, 'utf-8') + const entries = parsePersistedFile(JSON.parse(raw), current.appVersion) + if (entries) { + seedSessionParseCache(entries) + } + } catch { + // Why: a missing/corrupt/foreign cache file must never fail the scan; + // worst case is exactly today's cold scan. + } +} + +// A death between temp-write and rename orphans a uniquely named .tmp forever; +// sweep once per launch so they can't accumulate. Racing another instance's +// in-flight save at worst loses that save — the already-accepted rename trade. +async function sweepOrphanedTempFiles(filePath: string): Promise { + const directory = dirname(filePath) + try { + const names = await readdir(directory) + await Promise.all( + names + .filter((name) => name.startsWith('session-parse-cache-') && name.endsWith('.tmp')) + .map((name) => rm(join(directory, name), { force: true }).catch(() => {})) + ) + } catch { + // Directory missing or unreadable — nothing to sweep. + } +} + +function parsePersistedFile( + parsed: unknown, + appVersion: string +): [string, PersistedSessionParseCacheEntry][] | null { + if (typeof parsed !== 'object' || parsed === null) { + return null + } + const file = parsed as Record + // Why: parser output shape/semantics may change between app versions, so a + // cross-version file is discarded — one cold scan per update is the price. + if (file.schemaVersion !== SCHEMA_VERSION || file.appVersion !== appVersion) { + return null + } + if (!Array.isArray(file.entries)) { + return null + } + const entries: [string, PersistedSessionParseCacheEntry][] = [] + for (const item of file.entries) { + const entry = parsePersistedEntry(item) + if (entry === null) { + // One malformed entry means the file can't be trusted; discard it whole. + return null + } + entries.push(entry) + } + return entries +} + +function parsePersistedEntry(item: unknown): [string, PersistedSessionParseCacheEntry] | null { + if (!Array.isArray(item) || item.length !== 2) { + return null + } + const [path, value] = item as [unknown, unknown] + if (typeof path !== 'string' || typeof value !== 'object' || value === null) { + return null + } + const entry = value as Record + if (typeof entry.mtimeMs !== 'number') { + return null + } + if (entry.sizeBytes !== null && typeof entry.sizeBytes !== 'number') { + return null + } + if (typeof entry.platform !== 'string') { + return null + } + if (entry.session !== null && typeof entry.session !== 'object') { + return null + } + return [ + path, + { + mtimeMs: entry.mtimeMs, + sizeBytes: entry.sizeBytes, + platform: entry.platform as NodeJS.Platform, + session: entry.session as PersistedSessionParseCacheEntry['session'] + } + ] +} + +async function persistSnapshot(current: SessionParseCachePersistenceOptions): Promise { + const directory = dirname(current.filePath) + const tempPath = join(directory, `session-parse-cache-${process.pid}-${Date.now()}.tmp`) + try { + const payload = JSON.stringify({ + schemaVersion: SCHEMA_VERSION, + appVersion: current.appVersion, + entries: snapshotSessionParseCacheForPersistence() + }) + await mkdir(directory, { recursive: true, mode: PRIVATE_DIRECTORY_MODE }) + await writeFile(tempPath, payload, { mode: PRIVATE_FILE_MODE }) + // Atomic on POSIX; on Windows a rename racing an open handle fails and is + // caught below (save lost, never a torn file). + await rename(tempPath, current.filePath) + } catch (err) { + // Why: the save runs from a timer — every error must be swallowed here or + // it becomes an unhandled rejection. Worst case is the no-file case. + await rm(tempPath, { force: true }).catch(() => {}) + console.debug('[ai-vault] session parse cache save failed', err) + } +} diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index d42da123437..b7aab770432 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -97,6 +97,52 @@ export function resetSessionParseCacheForTests(): void { cache.clear() } +// Persisted subset of a cache entry: the non-serializable `resume` parser +// state is dropped (see session-parse-cache-persistence.ts). +export type PersistedSessionParseCacheEntry = Omit + +export function snapshotSessionParseCacheForPersistence(): [ + string, + PersistedSessionParseCacheEntry +][] { + return [...cache].map(([path, entry]): [string, PersistedSessionParseCacheEntry] => [ + path, + { + mtimeMs: entry.mtimeMs, + sizeBytes: entry.sizeBytes, + platform: entry.platform, + session: entry.session + } + ]) +} + +// Seeded entries carry `resume: null`: after a restart an unchanged file is a +// cache hit; a file that changed while the app was closed pays one full +// (not incremental) re-parse. +export function seedSessionParseCache( + entries: Iterable<[string, PersistedSessionParseCacheEntry]> +): void { + const list = [...entries] + // Snapshot order is oldest→newest (LRU); an over-cap list keeps the newest + // tail rather than seeding the oldest entries and dropping the tail. + for (const [path, entry] of list.slice(Math.max(0, list.length - MAX_CACHE_ENTRIES))) { + if (cache.size >= MAX_CACHE_ENTRIES) { + return + } + // In-process entries are always fresher than persisted ones; never clobber. + if (cache.has(path)) { + continue + } + cache.set(path, { + mtimeMs: entry.mtimeMs, + sizeBytes: entry.sizeBytes, + platform: entry.platform, + session: entry.session, + resume: null + }) + } +} + function storeEntry(path: string, entry: SessionParseCacheEntry): void { cache.delete(path) cache.set(path, entry) diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index ddb9ba7a861..795257c7408 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -13,6 +13,10 @@ import { } from './session-scanner-antigravity-history' import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths' import { codexHomeForSessionsDir } from './session-scanner-codex-paths' +import { + ensureSessionParseCacheLoaded, + scheduleSessionParseCachePersist +} from './session-parse-cache-persistence' import { createSessionParseStats, parseAgentSessionFileCached, @@ -61,6 +65,9 @@ export async function scanAiVaultSessions( const issues: AiVaultScanIssue[] = [] const parseStats = createSessionParseStats() const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(readOptionalTextFile) + // Why: persisted entries must be seeded before any candidate is parsed, or + // the cold scan gains nothing from the cache file (#9210). + await ensureSessionParseCacheLoaded() const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) const candidates = discoveries @@ -113,6 +120,8 @@ export async function scanAiVaultSessions( span.setAttribute('bytesRead', parseStats.bytesRead) span.setAttribute('issues', issues.length) + scheduleSessionParseCachePersist(parseStats) + return { sessions: mergeSessions(cappedSessions, scopeSessions), issues: issues.map((issue) => ({ executionHostId, ...issue })), diff --git a/src/main/index.ts b/src/main/index.ts index cc9fba24144..1b289f4102d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -14,6 +14,7 @@ import { getCanonicalUserDataPath, migrateMobilePairingDataToCanonicalUserDataPath } from './persistence' +import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence' import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store' import { getOrcaCloudAuthConfig } from './orca-profiles/profile-cloud-auth-config' import { getProfileUserDataPath } from './orca-profiles/profile-storage-paths' @@ -650,6 +651,13 @@ if (hasSingleInstanceLock) { // orca-dev in dev mode) but before app.setName('Orca') inside whenReady // (which would change the resolved path on case-sensitive filesystems). initDataPath() + // Why: the parse cache file must live under the canonical userData path + // captured above — late app.getPath('userData') can resolve differently + // across restarts, silently defeating persistence (reused=0 forever). + initSessionParseCachePersistence({ + filePath: join(getCanonicalUserDataPath(), 'ai-vault', 'session-parse-cache.json'), + appVersion: app.getVersion() + }) initOrcaProfilePaths() // Why: same timing constraint as initDataPath — capture the userData path // before app.setName changes it. See persistence.ts:20-28. From 245d79cc8c0aab4c5d3823a16e94f3091802c8c6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:36:00 -0700 Subject: [PATCH 04/45] Preserve surviving workspace state when removing a runtime host (#9468) * fix(runtime): preserve surviving workspace state on host removal Avoid purging worktree-scoped tabs and editor state when an exact worktree id still exists on another host. Also retire legacy unhosted rows when the removed runtime was their repo's sole unambiguous owner. * fix(runtime): purge rows when all owners are removed * fix(runtime): include host setups in purge ownership * fix(runtime): purge session-only state on host removal * fix(runtime): respect restored session ownership on host purge * fix(runtime): preserve surviving restored sessions on host purge --- ...purge-stale-runtime-host-ownership.test.ts | 147 +++++++++++ .../purge-stale-runtime-host-state.test.ts | 247 +++++++++++++++++- .../slices/stale-runtime-host-rows.test.ts | 16 ++ .../store/slices/stale-runtime-host-rows.ts | 20 +- src/renderer/src/store/slices/worktrees.ts | 149 ++++++++++- 5 files changed, 552 insertions(+), 27 deletions(-) create mode 100644 src/renderer/src/store/slices/purge-stale-runtime-host-ownership.test.ts diff --git a/src/renderer/src/store/slices/purge-stale-runtime-host-ownership.test.ts b/src/renderer/src/store/slices/purge-stale-runtime-host-ownership.test.ts new file mode 100644 index 00000000000..8cab720923e --- /dev/null +++ b/src/renderer/src/store/slices/purge-stale-runtime-host-ownership.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from 'vitest' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import { worktreeWorkspaceKey } from '../../../../shared/workspace-scope' + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: vi.fn(), + unregisterPtyDataHandlers: vi.fn() +})) + +// @ts-expect-error -- minimal window.api stub for the store under test +globalThis.window = { api: {} } + +import { createTestStore, makeTab, makeWorktree, seedStore } from './store-test-helpers' + +const RUNTIME_A = toRuntimeExecutionHostId('env-a') +const RUNTIME_B = toRuntimeExecutionHostId('env-b') + +describe('purgeStaleRuntimeHostState ownership evidence', () => { + it('uses an explicit removed-host worktree as owner evidence before catalogs load', () => { + const store = createTestStore() + const removedWorktreeId = 'repoA::/hosted' + const legacyWorktreeId = 'repoA::/legacy' + seedStore(store, { + repos: [], + worktreesByRepo: { + repoA: [ + makeWorktree({ id: removedWorktreeId, repoId: 'repoA', hostId: RUNTIME_A }), + makeWorktree({ id: legacyWorktreeId, repoId: 'repoA', hostId: undefined }) + ] + }, + tabsByWorktree: { + [legacyWorktreeId]: [makeTab({ id: 'legacy-tab', worktreeId: legacyWorktreeId })] + } + }) + + store.getState().purgeStaleRuntimeHostState(['env-a']) + + expect(store.getState().worktreesByRepo.repoA).toEqual([]) + expect(store.getState().tabsByWorktree[legacyWorktreeId]).toBeUndefined() + }) + + it('purges restored session state owned by the removed host despite an exact-id survivor', () => { + const store = createTestStore() + const worktreeId = 'shared::/same/path' + seedStore(store, { + repos: [ + { + id: 'shared', + path: '/shared-a', + displayName: 'A', + executionHostId: RUNTIME_A + } as never, + { + id: 'shared', + path: '/shared-b', + displayName: 'B', + executionHostId: RUNTIME_B + } as never + ], + worktreesByRepo: { + shared: [ + makeWorktree({ id: worktreeId, repoId: 'shared', hostId: RUNTIME_A }), + makeWorktree({ id: worktreeId, repoId: 'shared', hostId: RUNTIME_B }) + ] + }, + tabsByWorktree: { + [worktreeId]: [ + makeTab({ id: 'removed-host-tab', worktreeId, ptyId: 'remote:env-a@@terminal-a' }) + ] + }, + restoredRuntimeHostIdByWorkspaceSessionKey: { [worktreeId]: RUNTIME_A }, + activeWorktreeId: worktreeId, + activeWorkspaceKey: worktreeWorkspaceKey(worktreeId) + }) + + store.getState().purgeStaleRuntimeHostState(['env-a']) + + const state = store.getState() + expect(state.worktreesByRepo.shared).toHaveLength(1) + expect(state.worktreesByRepo.shared[0]?.hostId).toBe(RUNTIME_B) + expect(state.tabsByWorktree[worktreeId]).toBeUndefined() + expect(state.restoredRuntimeHostIdByWorkspaceSessionKey[worktreeId]).toBeUndefined() + expect(state.activeWorktreeId).toBeNull() + expect(state.activeWorkspaceKey).toBeNull() + }) + + it('purges a removed host session after its catalog rows have already disappeared', () => { + const store = createTestStore() + const worktreeId = 'repoA::/session-only' + seedStore(store, { + repos: [], + worktreesByRepo: {}, + tabsByWorktree: { + [worktreeId]: [makeTab({ id: 'removed-host-tab', worktreeId })] + }, + restoredRuntimeHostIdByWorkspaceSessionKey: { [worktreeId]: RUNTIME_A } + }) + + store.getState().purgeStaleRuntimeHostState(['env-a']) + + const state = store.getState() + expect(state.tabsByWorktree[worktreeId]).toBeUndefined() + expect(state.restoredRuntimeHostIdByWorkspaceSessionKey[worktreeId]).toBeUndefined() + }) + + it('preserves a surviving restored session before its catalog rows load', () => { + const store = createTestStore() + const removedWorktreeId = 'shared::/removed-host' + const survivingWorktreeId = 'shared::/surviving-host' + const survivingTabs = [ + makeTab({ + id: 'surviving-host-tab', + worktreeId: survivingWorktreeId, + ptyId: 'remote:env-b@@terminal-b' + }) + ] + seedStore(store, { + repos: [], + worktreesByRepo: {}, + tabsByWorktree: { + [removedWorktreeId]: [makeTab({ id: 'removed-host-tab', worktreeId: removedWorktreeId })], + [survivingWorktreeId]: survivingTabs + }, + restoredRuntimeHostIdByWorkspaceSessionKey: { + [removedWorktreeId]: RUNTIME_A, + [survivingWorktreeId]: RUNTIME_B + }, + activeWorktreeId: survivingWorktreeId, + activeWorkspaceKey: worktreeWorkspaceKey(survivingWorktreeId) + }) + + store.getState().purgeStaleRuntimeHostState(['env-a']) + + const state = store.getState() + expect(state.tabsByWorktree[removedWorktreeId]).toBeUndefined() + expect(state.tabsByWorktree[survivingWorktreeId]).toBe(survivingTabs) + expect(state.restoredRuntimeHostIdByWorkspaceSessionKey).toEqual({ + [survivingWorktreeId]: RUNTIME_B + }) + expect(state.activeWorktreeId).toBe(survivingWorktreeId) + expect(state.activeWorkspaceKey).toBe(worktreeWorkspaceKey(survivingWorktreeId)) + }) +}) diff --git a/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts b/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts index e6926e7669d..0d05535ed2d 100644 --- a/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts +++ b/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts @@ -9,6 +9,7 @@ */ import { describe, it, expect, vi } from 'vitest' import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import { worktreeWorkspaceKey } from '../../../../shared/workspace-scope' import type { ProjectHostSetup, DetectedWorktreeListResult } from '../../../../shared/types' vi.mock('sonner', () => ({ @@ -53,7 +54,7 @@ function detectedResult( function detected( id: string, repoId: string, - hostId: ReturnType | 'local' + hostId: ReturnType | 'local' | undefined ): DetectedWorktreeListResult['worktrees'][number] { return { ...makeWorktree({ id, repoId, hostId }), @@ -171,6 +172,7 @@ describe('purgeStaleRuntimeHostState', () => { const store = createTestStore() seedStore(store, { repos: [TEST_REPO], + projectHostSetups: [setup({ id: 's-local', repoId: 'repo1', hostId: 'local' })], worktreesByRepo: { repo1: [makeWorktree({ id: 'repo1::/wt', repoId: 'repo1', hostId: 'local' })] } @@ -179,6 +181,7 @@ describe('purgeStaleRuntimeHostState', () => { const epochBefore = before.sortEpoch const worktreesRef = before.worktreesByRepo const reposRef = before.repos + const setupsRef = before.projectHostSetups store.getState().purgeStaleRuntimeHostState([]) store.getState().purgeStaleRuntimeHostState(['env-does-not-exist']) @@ -188,16 +191,17 @@ describe('purgeStaleRuntimeHostState', () => { // Nothing stale => the reducer returns state untouched (same references). expect(s.worktreesByRepo).toBe(worktreesRef) expect(s.repos).toBe(reposRef) + expect(s.projectHostSetups).toBe(setupsRef) }) - it('same-repoId-on-two-hosts: keeps the local row and the repo key after purge', () => { + it('same-repoId-on-two-hosts: keeps an ambiguous unhosted row and the repo key', () => { const store = createTestStore() // repoId 'shared' exists under both local and runtime:env-a in worktreesByRepo. seedStore(store, { repos: [ { id: 'shared', path: '/shared', displayName: 'shared', badgeColor: '#000', addedAt: 0 }, { - id: 'sharedRuntime', + id: 'shared', path: '/shared', displayName: 'shared', badgeColor: '#000', @@ -207,7 +211,7 @@ describe('purgeStaleRuntimeHostState', () => { ], worktreesByRepo: { shared: [ - makeWorktree({ id: 'shared::/wt-local', repoId: 'shared', hostId: 'local' }), + makeWorktree({ id: 'shared::/wt-local', repoId: 'shared', hostId: undefined }), makeWorktree({ id: 'shared::/wt-a', repoId: 'shared', hostId: RUNTIME_A }) ] } @@ -218,6 +222,241 @@ describe('purgeStaleRuntimeHostState', () => { const s = store.getState() expect(s.worktreesByRepo).toHaveProperty('shared') expect(s.worktreesByRepo.shared.map((w) => w.id)).toEqual(['shared::/wt-local']) + expect(s.worktreesByRepo.shared[0]?.hostId).toBeUndefined() + }) + + it('preserves worktree-scoped state when the exact same id survives on another host', () => { + const store = createTestStore() + const RUNTIME_B = toRuntimeExecutionHostId('env-b') + const worktreeId = 'shared::/same/path' + const tabs = [makeTab({ id: 'tab-surviving', worktreeId })] + seedStore(store, { + repos: [ + { + id: 'shared', + path: '/shared', + displayName: 'shared', + badgeColor: '#000', + addedAt: 0, + executionHostId: RUNTIME_A + }, + { + id: 'shared', + path: '/shared', + displayName: 'shared', + badgeColor: '#000', + addedAt: 0, + executionHostId: RUNTIME_B + } + ], + worktreesByRepo: { + shared: [ + makeWorktree({ id: worktreeId, repoId: 'shared', hostId: RUNTIME_A }), + makeWorktree({ id: worktreeId, repoId: 'shared', hostId: RUNTIME_B }) + ] + }, + tabsByWorktree: { [worktreeId]: tabs }, + restoredRuntimeHostIdByWorkspaceSessionKey: { [worktreeId]: RUNTIME_B }, + activeWorktreeId: worktreeId, + activeWorkspaceKey: worktreeWorkspaceKey(worktreeId) + }) + const restoredOwners = store.getState().restoredRuntimeHostIdByWorkspaceSessionKey + + store.getState().purgeStaleRuntimeHostState(['env-a']) + + const s = store.getState() + expect(s.worktreesByRepo.shared).toHaveLength(1) + expect(s.worktreesByRepo.shared[0]?.hostId).toBe(RUNTIME_B) + expect(s.tabsByWorktree[worktreeId]).toBe(tabs) + expect(s.restoredRuntimeHostIdByWorkspaceSessionKey).toBe(restoredOwners) + expect(s.restoredRuntimeHostIdByWorkspaceSessionKey[worktreeId]).toBe(RUNTIME_B) + expect(s.activeWorktreeId).toBe(worktreeId) + expect(s.activeWorkspaceKey).toBe(worktreeWorkspaceKey(worktreeId)) + }) + + it('purges a legacy unhosted row when its sole repo owner was removed', () => { + const store = createTestStore() + const worktreeId = 'repoA::/legacy' + seedStore(store, { + repos: [ + { + id: 'repoA', + path: '/repoA', + displayName: 'A', + badgeColor: '#000', + addedAt: 0, + executionHostId: RUNTIME_A + } + ], + worktreesByRepo: { + repoA: [makeWorktree({ id: worktreeId, repoId: 'repoA', hostId: undefined })] + }, + tabsByWorktree: { [worktreeId]: [makeTab({ id: 'legacy-tab', worktreeId })] } + }) + + store.getState().purgeStaleRuntimeHostState(['env-a']) + + const s = store.getState() + expect(s.worktreesByRepo.repoA).toEqual([]) + expect(s.tabsByWorktree[worktreeId]).toBeUndefined() + }) + + it('uses a removed host setup to purge an unhosted row before its repo loads', () => { + const store = createTestStore() + const worktreeId = 'repoA::/legacy' + seedStore(store, { + repos: [], + projectHostSetups: [setup({ id: 's-a', repoId: 'repoA', hostId: RUNTIME_A })], + worktreesByRepo: { + repoA: [makeWorktree({ id: worktreeId, repoId: 'repoA', hostId: undefined })] + }, + tabsByWorktree: { [worktreeId]: [makeTab({ id: 'legacy-tab', worktreeId })] } + }) + + store.getState().purgeStaleRuntimeHostState(['env-a']) + + const s = store.getState() + expect(s.projectHostSetups).toEqual([]) + expect(s.worktreesByRepo.repoA).toEqual([]) + expect(s.tabsByWorktree[worktreeId]).toBeUndefined() + }) + + it('purges hydrated worktree state before its worktree row loads', () => { + const store = createTestStore() + const worktreeId = 'repoA::/session-only' + seedStore(store, { + repos: [ + { + id: 'repoA', + path: '/repoA', + displayName: 'A', + badgeColor: '#000', + addedAt: 0, + executionHostId: RUNTIME_A + } + ], + worktreesByRepo: {}, + tabsByWorktree: { [worktreeId]: [makeTab({ id: 'hydrated-tab', worktreeId })] }, + activeWorktreeId: worktreeId, + activeWorkspaceKey: worktreeWorkspaceKey(worktreeId) + }) + + store.getState().purgeStaleRuntimeHostState(['env-a']) + + const s = store.getState() + expect(s.tabsByWorktree[worktreeId]).toBeUndefined() + expect(s.activeWorktreeId).toBeNull() + expect(s.activeWorkspaceKey).toBeNull() + }) + + it('uses an explicit surviving worktree row as owner evidence during catalog loading', () => { + const store = createTestStore() + const RUNTIME_B = toRuntimeExecutionHostId('env-b') + const legacyWorktreeId = 'shared::/legacy' + const survivingWorktreeId = 'shared::/host-b' + const tabs = [makeTab({ id: 'surviving-tab', worktreeId: legacyWorktreeId })] + seedStore(store, { + repos: [ + { + id: 'shared', + path: '/shared-a', + displayName: 'A', + badgeColor: '#000', + addedAt: 0, + executionHostId: RUNTIME_A + } + ], + worktreesByRepo: { + shared: [makeWorktree({ id: legacyWorktreeId, repoId: 'shared', hostId: undefined })] + }, + detectedWorktreesByRepo: { + shared: detectedResult('shared', [detected(survivingWorktreeId, 'shared', RUNTIME_B)]) + }, + tabsByWorktree: { [legacyWorktreeId]: tabs } + }) + + store.getState().purgeStaleRuntimeHostState(['env-a']) + + const s = store.getState() + expect(s.repos).toEqual([]) + expect(s.worktreesByRepo.shared.map((worktree) => worktree.id)).toEqual([legacyWorktreeId]) + expect(s.detectedWorktreesByRepo.shared.worktrees.map((worktree) => worktree.id)).toEqual([ + survivingWorktreeId + ]) + expect(s.tabsByWorktree[legacyWorktreeId]).toBe(tabs) + }) + + it('preserves an unhosted row when a setup proves another host still owns it', () => { + const store = createTestStore() + const RUNTIME_B = toRuntimeExecutionHostId('env-b') + const worktreeId = 'shared::/legacy' + const tabs = [makeTab({ id: 'surviving-tab', worktreeId })] + seedStore(store, { + repos: [ + { + id: 'shared', + path: '/shared-a', + displayName: 'A', + badgeColor: '#000', + addedAt: 0, + executionHostId: RUNTIME_A + } + ], + projectHostSetups: [setup({ id: 's-b', repoId: 'shared', hostId: RUNTIME_B })], + worktreesByRepo: { + shared: [makeWorktree({ id: worktreeId, repoId: 'shared', hostId: undefined })] + }, + tabsByWorktree: { [worktreeId]: tabs } + }) + + store.getState().purgeStaleRuntimeHostState(['env-a']) + + const s = store.getState() + expect(s.repos).toEqual([]) + expect(s.projectHostSetups).toHaveLength(1) + expect(s.worktreesByRepo.shared).toHaveLength(1) + expect(s.tabsByWorktree[worktreeId]).toBe(tabs) + }) + + it('purges a legacy unhosted row when every repo owner is removed together', () => { + const store = createTestStore() + const RUNTIME_B = toRuntimeExecutionHostId('env-b') + const worktreeId = 'shared::/legacy' + seedStore(store, { + repos: [ + { + id: 'shared', + path: '/shared-a', + displayName: 'A', + badgeColor: '#000', + addedAt: 0, + executionHostId: RUNTIME_A + }, + { + id: 'shared', + path: '/shared-b', + displayName: 'B', + badgeColor: '#000', + addedAt: 0, + executionHostId: RUNTIME_B + } + ], + worktreesByRepo: { + shared: [makeWorktree({ id: worktreeId, repoId: 'shared', hostId: undefined })] + }, + detectedWorktreesByRepo: { + shared: detectedResult('shared', [detected(worktreeId, 'shared', undefined)]) + }, + tabsByWorktree: { [worktreeId]: [makeTab({ id: 'legacy-tab', worktreeId })] } + }) + + store.getState().purgeStaleRuntimeHostState(['env-a', 'env-b']) + + const s = store.getState() + expect(s.repos).toEqual([]) + expect(s.worktreesByRepo.shared).toEqual([]) + expect(s.detectedWorktreesByRepo.shared.worktrees).toEqual([]) + expect(s.tabsByWorktree[worktreeId]).toBeUndefined() }) it('clears activeRepoId and drops the purged id from filterRepoIds', () => { diff --git a/src/renderer/src/store/slices/stale-runtime-host-rows.test.ts b/src/renderer/src/store/slices/stale-runtime-host-rows.test.ts index 6d0e2a5dfff..767763a9b1a 100644 --- a/src/renderer/src/store/slices/stale-runtime-host-rows.test.ts +++ b/src/renderer/src/store/slices/stale-runtime-host-rows.test.ts @@ -97,4 +97,20 @@ describe('dropWorktreeRowsForRemovedRuntimeEnvironments', () => { expect(result.rowsByRepo.repoChanging).not.toBe(changing) expect(result.rowsByRepo.repoChanging).toEqual([row('w-keep', 'local')]) }) + + it('drops unhosted rows only for an unambiguously removed sole-owner repo', () => { + const rowsByRepo = { + removedRepo: [row('w-legacy')], + ambiguousRepo: [row('w-ambiguous')] + } + const result = dropWorktreeRowsForRemovedRuntimeEnvironments( + rowsByRepo, + new Set(['env-a']), + new Set(['removedRepo']) + ) + + expect(result.rowsByRepo.removedRepo).toEqual([]) + expect(result.rowsByRepo.ambiguousRepo).toEqual([row('w-ambiguous')]) + expect(result.removedWorktreeIds).toEqual(['w-legacy']) + }) }) diff --git a/src/renderer/src/store/slices/stale-runtime-host-rows.ts b/src/renderer/src/store/slices/stale-runtime-host-rows.ts index 896873be8ed..11b8e0b7530 100644 --- a/src/renderer/src/store/slices/stale-runtime-host-rows.ts +++ b/src/renderer/src/store/slices/stale-runtime-host-rows.ts @@ -21,19 +21,18 @@ export type DropRuntimeRowsResult = { } /** - * Drops rows whose `hostId` is a removed-env runtime host from a - * `worktreesByRepo`-shaped map. Returns the SAME reference when nothing changed - * (render-churn guard). Repo keys are kept even when their row list empties — the - * repo itself is purged from `repos`, so grouping never references an empty entry, - * and keeping keys avoids threading a cross-slice "does this repo survive" callback - * into a pure helper. Generic so it serves both `Worktree[]` and the detected - * worktrees' `.worktrees` arrays. + * Drops rows owned by a removed-env runtime host from a `worktreesByRepo`-shaped + * map. Legacy unhosted rows are dropped only when their repo had removed owners + * and none survive. Returns the SAME reference when nothing changed (render-churn + * guard). Repo keys are kept even when their row list empties. Generic so it serves + * both `Worktree[]` and the detected worktrees' `.worktrees` arrays. */ export function dropWorktreeRowsForRemovedRuntimeEnvironments< T extends { id: string; hostId?: ExecutionHostId } >( rowsByRepo: Record, - removedEnvironmentIds: ReadonlySet + removedEnvironmentIds: ReadonlySet, + repoIdsWithoutSurvivingOwners?: ReadonlySet ): DropRuntimeRowsResult { if (removedEnvironmentIds.size === 0) { return { rowsByRepo, removedWorktreeIds: [] } @@ -43,7 +42,10 @@ export function dropWorktreeRowsForRemovedRuntimeEnvironments< const next: Record = {} for (const [repoId, rows] of Object.entries(rowsByRepo)) { const survivors = rows.filter((row) => { - if (isRemovedRuntimeHostId(row.hostId, removedEnvironmentIds)) { + if ( + isRemovedRuntimeHostId(row.hostId, removedEnvironmentIds) || + (row.hostId === undefined && repoIdsWithoutSurvivingOwners?.has(repoId) === true) + ) { removedWorktreeIds.push(row.id) return false } diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 1845663c7d7..49c9cdce963 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -4983,20 +4983,38 @@ export const createWorktreeSlice: StateCreator return } set((s) => { - const survivingRepos = s.repos.filter( - (repo) => !isRemovedRuntimeHostId(getRepoExecutionHostId(repo), removed) - ) + const repoIdsWithRemovedOwners = new Set() + const survivingRepoIds = new Set() + const repoIdsWithSurvivingOwners = new Set() + const survivingRepos: AppState['repos'] = [] + for (const repo of s.repos) { + if (isRemovedRuntimeHostId(getRepoExecutionHostId(repo), removed)) { + repoIdsWithRemovedOwners.add(repo.id) + } else { + survivingRepos.push(repo) + survivingRepoIds.add(repo.id) + repoIdsWithSurvivingOwners.add(repo.id) + } + } const reposChanged = survivingRepos.length !== s.repos.length // Why: broader than filterSetupsForPrunedRepoRows — a repoId-less setup on // the removed host can still flip projectIdsRequiringSetupGroups and split a // surviving project group, so drop every setup owned by the removed host. - const survivingSetups = s.projectHostSetups.filter( - (setup) => !isRemovedRuntimeHostId(setup.hostId, removed) - ) + const survivingSetups: AppState['projectHostSetups'] = [] + for (const setup of s.projectHostSetups) { + if (isRemovedRuntimeHostId(setup.hostId, removed)) { + if (setup.repoId) { + repoIdsWithRemovedOwners.add(setup.repoId) + } + } else { + survivingSetups.push(setup) + if (setup.repoId) { + repoIdsWithSurvivingOwners.add(setup.repoId) + } + } + } const setupsChanged = survivingSetups.length !== s.projectHostSetups.length - - const worktreeDrop = dropWorktreeRowsForRemovedRuntimeEnvironments(s.worktreesByRepo, removed) const detectedRows: Record = Object.fromEntries( Object.entries(s.detectedWorktreesByRepo).map(([repoId, result]) => [ @@ -5004,21 +5022,122 @@ export const createWorktreeSlice: StateCreator result.worktrees ]) ) - const detectedDrop = dropWorktreeRowsForRemovedRuntimeEnvironments(detectedRows, removed) + // Why: repo/setup catalogs can lag session hydration; hosted worktree rows + // are ownership evidence during that loading gap too. + const recordWorktreeOwners = ( + rowsByRepo: Record + ): void => { + for (const [repoId, rows] of Object.entries(rowsByRepo)) { + for (const row of rows) { + if (!row.hostId) { + continue + } + const ownerSet = isRemovedRuntimeHostId(row.hostId, removed) + ? repoIdsWithRemovedOwners + : repoIdsWithSurvivingOwners + ownerSet.add(repoId) + } + } + } + recordWorktreeOwners(s.worktreesByRepo) + recordWorktreeOwners(detectedRows) + + const sessionWorktreeIdsOwnedByRemovedHosts = new Set() + let survivingRestoredSessionOwners = s.restoredRuntimeHostIdByWorkspaceSessionKey + for (const [workspaceKey, hostId] of Object.entries( + s.restoredRuntimeHostIdByWorkspaceSessionKey + )) { + const scope = parseWorkspaceKey(workspaceKey) + if (scope?.type === 'folder') { + continue + } + const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceKey + const repoId = getRepoIdFromWorktreeId(worktreeId) + if (!isRemovedRuntimeHostId(hostId, removed)) { + // Why: restored sessions can be the only surviving-owner evidence before catalogs load. + repoIdsWithSurvivingOwners.add(repoId) + continue + } + sessionWorktreeIdsOwnedByRemovedHosts.add(worktreeId) + repoIdsWithRemovedOwners.add(repoId) + if (survivingRestoredSessionOwners === s.restoredRuntimeHostIdByWorkspaceSessionKey) { + survivingRestoredSessionOwners = { ...survivingRestoredSessionOwners } + } + delete survivingRestoredSessionOwners[workspaceKey] + } + + // Why: legacy rows predate host stamps; every available owner record must + // agree that no other host survives before an unhosted row can be retired. + const repoIdsWithoutSurvivingOwners = new Set(repoIdsWithRemovedOwners) + for (const repoId of repoIdsWithSurvivingOwners) { + repoIdsWithoutSurvivingOwners.delete(repoId) + } + + const worktreeDrop = dropWorktreeRowsForRemovedRuntimeEnvironments( + s.worktreesByRepo, + removed, + repoIdsWithoutSurvivingOwners + ) + const detectedDrop = dropWorktreeRowsForRemovedRuntimeEnvironments( + detectedRows, + removed, + repoIdsWithoutSurvivingOwners + ) const worktreesChanged = worktreeDrop.rowsByRepo !== s.worktreesByRepo const detectedChanged = detectedDrop.rowsByRepo !== detectedRows - if (!reposChanged && !setupsChanged && !worktreesChanged && !detectedChanged) { - return s - } const removedWorktreeIds = new Set([ ...worktreeDrop.removedWorktreeIds, - ...detectedDrop.removedWorktreeIds + ...detectedDrop.removedWorktreeIds, + ...sessionWorktreeIdsOwnedByRemovedHosts ]) + // Why: terminal tabs hydrate before worktree metadata; session-only ids for + // repos whose owners are all gone still need the full worktree-state purge. + if (repoIdsWithoutSurvivingOwners.size > 0) { + for (const worktreeId of Object.keys(s.tabsByWorktree)) { + const scope = parseWorkspaceKey(worktreeId) + const rawWorktreeId = scope?.type === 'worktree' ? scope.worktreeId : worktreeId + if ( + scope?.type !== 'folder' && + repoIdsWithoutSurvivingOwners.has(getRepoIdFromWorktreeId(rawWorktreeId)) + ) { + removedWorktreeIds.add(rawWorktreeId) + } + } + } + // Why: bare-id state follows an exact survivor unless the restored session + // partition proves that state belonged to the removed host. + for (const rows of Object.values(worktreeDrop.rowsByRepo)) { + for (const row of rows) { + if (!sessionWorktreeIdsOwnedByRemovedHosts.has(row.id)) { + removedWorktreeIds.delete(row.id) + } + } + } + for (const rows of Object.values(detectedDrop.rowsByRepo)) { + for (const row of rows) { + if (!sessionWorktreeIdsOwnedByRemovedHosts.has(row.id)) { + removedWorktreeIds.delete(row.id) + } + } + } const purgeState = removedWorktreeIds.size > 0 ? buildWorktreePurgeState(s, [...removedWorktreeIds]) : {} + const restoredSessionOwnersChanged = + survivingRestoredSessionOwners !== s.restoredRuntimeHostIdByWorkspaceSessionKey + if ( + !reposChanged && + !setupsChanged && + !worktreesChanged && + !detectedChanged && + !restoredSessionOwnersChanged && + removedWorktreeIds.size === 0 + ) { + return s + } + const detectedWorktreesByRepo = detectedChanged ? Object.fromEntries( Object.entries(s.detectedWorktreesByRepo).map(([repoId, result]) => [ @@ -5029,13 +5148,15 @@ export const createWorktreeSlice: StateCreator : s.detectedWorktreesByRepo const rowsChanged = worktreesChanged || detectedChanged - const survivingRepoIds = new Set(survivingRepos.map((repo) => repo.id)) return { ...purgeState, ...(reposChanged ? { repos: survivingRepos } : {}), ...(setupsChanged ? { projectHostSetups: survivingSetups } : {}), ...(worktreesChanged ? { worktreesByRepo: worktreeDrop.rowsByRepo } : {}), ...(detectedChanged ? { detectedWorktreesByRepo } : {}), + ...(restoredSessionOwnersChanged + ? { restoredRuntimeHostIdByWorkspaceSessionKey: survivingRestoredSessionOwners } + : {}), ...(rowsChanged ? { sortEpoch: s.sortEpoch + 1 } : {}), // Why: mirror validateRepoScopedUi's repo-scoped UI subset so a filtered or // active sidebar can't be left referencing a purged repo id. From b320bcb374c032e88eb4512db57d2c79785601be Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 19 Jul 2026 16:54:31 -0700 Subject: [PATCH 05/45] fix(gitlab): bound and coalesce auth probes (#9476) --- src/main/git/runner-wsl-gh-fallback.test.ts | 91 ++++++++++++ src/main/git/runner.ts | 132 +++++++++++++----- src/main/gitlab/client.ts | 22 ++- src/main/gitlab/gitlab-known-host-probe.ts | 110 +++++++++++++++ .../gitlab/gitlab-project-ref-resolution.ts | 91 ++---------- src/main/gitlab/gl-utils.test.ts | 97 +++++++++++++ src/main/gitlab/issues.test.ts | 2 +- src/main/gitlab/issues.ts | 14 +- src/main/gitlab/work-item-details.ts | 2 +- src/main/runtime/orca-runtime.ts | 4 +- 10 files changed, 427 insertions(+), 138 deletions(-) create mode 100644 src/main/gitlab/gitlab-known-host-probe.ts diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index c7710858838..8b802c2f0d6 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -1,3 +1,4 @@ +import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as WslModule from '../wsl' @@ -21,11 +22,26 @@ vi.mock('../wsl', async (importOriginal) => ({ import { ghExecFileAsync, glabExecFileAsync } from './runner' +type MockChildProcess = EventEmitter & { + pid: number + kill: ReturnType + unref: ReturnType +} + +function createMockChildProcess(pid: number): MockChildProcess { + const child = new EventEmitter() as MockChildProcess + child.pid = pid + child.kill = vi.fn() + child.unref = vi.fn() + return child +} + describe('ghExecFileAsync WSL fallback', () => { const originalPlatform = process.platform beforeEach(() => { execFileMock.mockReset() + spawnMock.mockReset() getDefaultWslDistroMock.mockReset() getDefaultWslDistroMock.mockReturnValue(null) Object.defineProperty(process, 'platform', { @@ -35,6 +51,7 @@ describe('ghExecFileAsync WSL fallback', () => { }) afterEach(() => { + vi.useRealTimers() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform @@ -352,6 +369,80 @@ describe('ghExecFileAsync WSL fallback', () => { ) }) + it('times out the default-WSL glab fallback and waits for full tree cleanup', async () => { + vi.useFakeTimers() + getDefaultWslDistroMock.mockReturnValue('Ubuntu') + const nativeChild = createMockChildProcess(1200) + const wslChild = createMockChildProcess(2400) + const taskkill = createMockChildProcess(3600) + execFileMock + .mockImplementationOnce((_binary, _args, _options, callback) => { + callback(Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' })) + return nativeChild + }) + .mockReturnValueOnce(wslChild) + spawnMock.mockReturnValue(taskkill) + + const promise = glabExecFileAsync(['auth', 'status'], { timeout: 1000 }) + const rejection = expect(promise).rejects.toThrow('wsl.exe timed out.') + let rejected = false + void promise.catch(() => { + rejected = true + }) + + await vi.advanceTimersByTimeAsync(999) + expect(spawnMock).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(spawnMock).toHaveBeenCalledWith( + 'taskkill', + ['/pid', '2400', '/t', '/f'], + expect.objectContaining({ stdio: 'ignore', windowsHide: true }) + ) + await Promise.resolve() + expect(rejected).toBe(false) + + taskkill.emit('close', 0) + await rejection + expect(wslChild.kill).not.toHaveBeenCalled() + }) + + it('aborts the default-WSL glab fallback with full process-tree cleanup', async () => { + getDefaultWslDistroMock.mockReturnValue('Ubuntu') + const nativeChild = createMockChildProcess(1200) + const wslChild = createMockChildProcess(2400) + const taskkill = createMockChildProcess(3600) + execFileMock + .mockImplementationOnce((_binary, _args, _options, callback) => { + callback(Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' })) + return nativeChild + }) + .mockReturnValueOnce(wslChild) + spawnMock.mockReturnValue(taskkill) + const controller = new AbortController() + + const promise = glabExecFileAsync(['auth', 'status'], { signal: controller.signal }) + const rejection = expect(promise).rejects.toMatchObject({ name: 'AbortError' }) + await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledTimes(2)) + controller.abort() + + expect(execFileMock).toHaveBeenNthCalledWith( + 2, + 'wsl.exe', + ['-d', 'Ubuntu', '--', 'bash', '-c', "'glab' 'auth' 'status'"], + expect.not.objectContaining({ signal: controller.signal }), + expect.any(Function) + ) + expect(spawnMock).toHaveBeenCalledWith( + 'taskkill', + ['/pid', '2400', '/t', '/f'], + expect.objectContaining({ stdio: 'ignore', windowsHide: true }) + ) + taskkill.emit('close', 0) + + await rejection + expect(wslChild.kill).not.toHaveBeenCalled() + }) + it('does not wake the default WSL distro for host-only GitLab diagnostics', async () => { getDefaultWslDistroMock.mockReturnValue('Ubuntu') execFileMock diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 8c70421e3ef..598ed2a9781 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -323,24 +323,57 @@ function createAbortError(): Error { return error } -function killSpawnedCommandTree(child: ChildProcess): void { +const WINDOWS_TREE_KILL_WAIT_MS = 2_000 + +function killSpawnedCommandTree(child: ChildProcess): Promise { const pid = child.pid if (!pid || process.platform !== 'win32') { child.kill() - return + return Promise.resolve() } - try { - // Why: Windows package-manager CLIs are often .cmd shims. Killing only - // cmd.exe leaves the underlying node/npm/pnpm child running. - const killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { - stdio: 'ignore', - windowsHide: true - }) - killer.on('error', () => child.kill()) + return new Promise((resolve) => { + let killer: ChildProcess + try { + // Why: Windows shims and wsl.exe can own descendants; wait for /t tree + // cleanup before settling so a timed-out command cannot outlive its probe. + killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true + }) + if (!killer || typeof killer.unref !== 'function') { + child.kill() + resolve() + return + } + } catch { + child.kill() + resolve() + return + } + let settled = false + let timer: NodeJS.Timeout | null = null + const finish = (fallbackToChildKill: boolean): void => { + if (settled) { + return + } + settled = true + if (timer) { + clearTimeout(timer) + } + killer.removeAllListeners() + if (fallbackToChildKill) { + child.kill() + } + resolve() + } + killer.once('error', () => finish(true)) + killer.once('close', (code) => finish(code !== 0)) + timer = setTimeout(() => { + killer.kill() + finish(true) + }, WINDOWS_TREE_KILL_WAIT_MS) killer.unref() - } catch { - child.kill() - } + }) } type ExecFileCaptureOptions = Omit & { @@ -376,6 +409,7 @@ function execFileCapture( } let settled = false + let terminating = false let child: ChildProcess | null = null let timer: NodeJS.Timeout | null = null const cleanup = (): void => { @@ -405,14 +439,26 @@ function execFileCapture( resolve({ stdout, stderr }) } const onAbort = (): void => { - if (child) { - killSpawnedCommandTree(child) + if (settled || terminating) { + return } - finish(createAbortError()) + terminating = true + const abortError = createAbortError() + if (!child) { + terminating = false + finish(abortError) + return + } + void killSpawnedCommandTree(child).then(() => { + terminating = false + finish(abortError) + }) } try { const spawnStartedAt = performance.now() + // Why: the abort listener below owns tree-aware cleanup. Node's + // signal handler could kill wsl.exe before taskkill sees its children. child = execFile( command, args, @@ -420,10 +466,12 @@ function execFileCapture( cwd: options.cwd, encoding: options.encoding, maxBuffer: options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER, - env: options.env, - signal: options.signal + env: options.env }, (error, stdout, stderr) => { + if (terminating) { + return + } if (!error && stderr === undefined && isExecFileResultObject(stdout)) { finish(null, stdout.stdout, stdout.stderr) return @@ -437,21 +485,34 @@ function execFileCapture( return } - child.once('error', (error) => finish(error)) + child.once('error', (error) => { + if (!terminating) { + finish(error) + } + }) if (options.stdin !== undefined) { endSubprocessStdin(child.stdin, options.stdin) } - // Why: Node's native execFile timeout waits for the child to exit after - // signaling it. Some CLIs ignore that signal, so reject the UI operation - // on our own timer and kill the child only as best effort. + // Why: Node's native timeout can wait forever for signal-ignoring CLIs; + // enforce our own deadline and settle after bounded process-tree cleanup. if (options.timeout && options.timeout > 0) { timer = setTimeout(() => { - if (child) { - killSpawnedCommandTree(child) + if (settled || terminating) { + return } - finish(new Error(`${command} timed out.`)) + terminating = true + const timeoutError = new Error(`${command} timed out.`) + if (!child) { + terminating = false + finish(timeoutError) + return + } + void killSpawnedCommandTree(child).then(() => { + terminating = false + finish(timeoutError) + }) }, options.timeout) } options.signal?.addEventListener('abort', onAbort, { once: true }) @@ -484,7 +545,7 @@ async function spawnCommandCapture( recordSubprocessSpawn(spawnCmd, spawnArgs, performance.now() - spawnStartedAt) let timer: NodeJS.Timeout | null = null const onAbort = (): void => { - killSpawnedCommandTree(child) + void killSpawnedCommandTree(child) finish(createAbortError()) } const cleanupListeners = (): void => { @@ -512,7 +573,7 @@ async function spawnCommandCapture( } timer = options.timeout ? setTimeout(() => { - killSpawnedCommandTree(child) + void killSpawnedCommandTree(child) finish(new Error(`${command} timed out.`)) }, options.timeout) : null @@ -520,7 +581,7 @@ async function spawnCommandCapture( function onStdoutData(chunk: Buffer): void { stdoutBytes += chunk.byteLength if (options.maxBuffer && stdoutBytes > options.maxBuffer) { - killSpawnedCommandTree(child) + void killSpawnedCommandTree(child) finish(new Error(`${command} stdout exceeded maxBuffer.`)) return } @@ -529,7 +590,7 @@ async function spawnCommandCapture( function onStderrData(chunk: Buffer): void { stderrBytes += chunk.byteLength if (options.maxBuffer && stderrBytes > options.maxBuffer) { - killSpawnedCommandTree(child) + void killSpawnedCommandTree(child) finish(new Error(`${command} stderr exceeded maxBuffer.`)) return } @@ -997,7 +1058,7 @@ export async function gitStreamStdout( function onStdoutData(chunk: Buffer): void { stdoutBytes += chunk.byteLength if (stdoutBytes > maxBuffer) { - killSpawnedCommandTree(child) + void killSpawnedCommandTree(child) finish(new Error('git stdout exceeded maxBuffer.')) return } @@ -1012,7 +1073,7 @@ export async function gitStreamStdout( try { shouldStop = options.onStdout(decoded) } catch (error) { - killSpawnedCommandTree(child) + void killSpawnedCommandTree(child) finish(error instanceof Error ? error : new Error(String(error))) return } @@ -1020,14 +1081,14 @@ export async function gitStreamStdout( // Why: parser hit its limit. Kill git and resolve cleanly — the // partial output we already parsed is the intended result. stoppedEarly = true - killSpawnedCommandTree(child) + void killSpawnedCommandTree(child) finish(null) } } function onStderrData(chunk: Buffer): void { stderrBytes += chunk.byteLength if (stderrBytes > maxBuffer) { - killSpawnedCommandTree(child) + void killSpawnedCommandTree(child) finish(new Error('git stderr exceeded maxBuffer.')) return } @@ -1044,7 +1105,7 @@ export async function gitStreamStdout( finish(new Error(`git exited with ${code}: ${stderr}`)) } function onAbort(): void { - killSpawnedCommandTree(child) + void killSpawnedCommandTree(child) finish(createAbortError()) } @@ -1468,7 +1529,8 @@ export async function glabExecFileAsync( encoding: (options.encoding ?? 'utf-8') as BufferEncoding, maxBuffer: options.maxBuffer, timeout: options.timeout, - env: options.env + env: options.env, + signal: options.signal }) return { stdout: stdout as string, stderr: stderr as string } } catch (err) { diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts index 370553db9e1..eef57b3aaba 100644 --- a/src/main/gitlab/client.ts +++ b/src/main/gitlab/client.ts @@ -248,13 +248,9 @@ export async function getProjectSlug( connectionId?: string | null, options: HostedReviewExecutionOptions = {} ): Promise { - const knownHosts = await getGlabKnownHosts(connectionId) - return getProjectRef( - repoPath, - knownHosts, - connectionId, - ...hostedReviewLocalGitOptionArgs(options) - ) + const localGitArgs = hostedReviewLocalGitOptionArgs(options) + const knownHosts = await getGlabKnownHosts(connectionId, localGitArgs[0]) + return getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) } /** @@ -268,9 +264,9 @@ export async function getMergeRequest( connectionId?: string | null, options: HostedReviewExecutionOptions = {} ): Promise { - const knownHosts = await getGlabKnownHosts(connectionId) const localGitArgs = hostedReviewLocalGitOptionArgs(options) const localGitOptions = localGitArgs[0] ?? {} + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) await acquire() try { @@ -323,9 +319,9 @@ export async function getMergeRequestForBranch( if (!branchName && linkedMRIid == null) { return null } - const knownHosts = await getGlabKnownHosts(connectionId) const localGitArgs = hostedReviewLocalGitOptionArgs(options) const localGitOptions = localGitArgs[0] ?? {} + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs) if (!projectRef) { return null @@ -425,7 +421,7 @@ export async function listMergeRequests( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const knownHosts = await getGlabKnownHosts(connectionId) + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) // Why: MRs sit on `origin` in the fork model (the user's fork is where // they push branches and submit MRs). Mirror github's `getOwnerRepo` // call site by going through the upstream/origin preference resolver @@ -623,7 +619,7 @@ export async function listWorkItems( localGitOptions: LocalGitExecOptions = {} ): Promise> { const issueState = mrStateToIssueState(state) - const knownHosts = await getGlabKnownHosts(connectionId) + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const { source: projectRef } = await resolveIssueSource( repoPath, preference, @@ -759,7 +755,7 @@ export async function listTodos( ): Promise { const projectRef = await getProjectRef( repoPath, - await getGlabKnownHosts(connectionId), + await getGlabKnownHosts(connectionId, localGitOptions), connectionId, localGitOptions ) @@ -838,7 +834,7 @@ async function withProjectRef( await resolveIssueSource( repoPath, preference, - await getGlabKnownHosts(connectionId), + await getGlabKnownHosts(connectionId, localGitOptions), connectionId, localGitOptions ) diff --git a/src/main/gitlab/gitlab-known-host-probe.ts b/src/main/gitlab/gitlab-known-host-probe.ts new file mode 100644 index 00000000000..1756df4ce96 --- /dev/null +++ b/src/main/gitlab/gitlab-known-host-probe.ts @@ -0,0 +1,110 @@ +import { glabExecFileAsync } from '../git/runner' +import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch' +import { DEFAULT_GITLAB_HOSTS, normalizeGitLabHost } from './project-ref-parser' + +export type LocalGitExecOptions = { + wslDistro?: string +} + +const GLAB_KNOWN_HOSTS_TIMEOUT_MS = 10_000 +const knownHostsCacheByExecutionContext = new Map() +const knownHostsInFlightByExecutionContext = new Map>() + +function knownHostsExecutionKey( + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): string { + if (connectionId) { + // Why: reconnecting can replace the SSH/relay execution host under the same id. + return `connection:${connectionId}:${getSshGitProviderGeneration(connectionId)}` + } + return localGitOptions.wslDistro ? `wsl:${localGitOptions.wslDistro}` : 'native' +} + +/** @internal - exposed for tests only */ +export function _resetKnownHostsCache(): void { + knownHostsCacheByExecutionContext.clear() + knownHostsInFlightByExecutionContext.clear() +} + +export function rememberGlabKnownHost( + host: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): void { + const normalizedHost = normalizeGitLabHost(host) + const key = knownHostsExecutionKey(connectionId, localGitOptions) + const cached = knownHostsCacheByExecutionContext.get(key) + if (!cached || cached.map(normalizeGitLabHost).includes(normalizedHost)) { + return + } + knownHostsCacheByExecutionContext.set(key, [...cached, normalizedHost]) +} + +export async function getGlabKnownHosts( + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const key = knownHostsExecutionKey(connectionId, localGitOptions) + const cached = knownHostsCacheByExecutionContext.get(key) + if (cached) { + return cached + } + const inFlight = knownHostsInFlightByExecutionContext.get(key) + if (inFlight) { + return inFlight + } + const probe = probeGlabKnownHosts(key, connectionId, localGitOptions) + knownHostsInFlightByExecutionContext.set(key, probe) + try { + return await probe + } finally { + if (knownHostsInFlightByExecutionContext.get(key) === probe) { + knownHostsInFlightByExecutionContext.delete(key) + } + } +} + +async function probeGlabKnownHosts( + key: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + try { + // Why: auth config belongs to the executing host; do not share native, WSL, + // or reconnected SSH/relay results, and bound an otherwise global probe. + const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], { + timeout: GLAB_KNOWN_HOSTS_TIMEOUT_MS, + ...(!connectionId && localGitOptions.wslDistro + ? { wslDistro: localGitOptions.wslDistro } + : {}) + }) + const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`) + const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts])) + knownHostsCacheByExecutionContext.set(key, merged) + return merged + } catch { + // Keep failures uncached so auth or tunnel recovery is discovered later. + return [...DEFAULT_GITLAB_HOSTS] + } +} + +export function parseGlabAuthStatusHosts(output: string): string[] { + const hosts = new Set() + // Why: self-hosted GitLab can run on a non-default port; preserve it so + // services on the same hostname remain distinct downstream. + for (const match of output.matchAll(/logged in to ([a-zA-Z0-9.-]+(?::\d+)?)/gi)) { + hosts.add(match[1].toLowerCase()) + } + for (const line of output.split('\n')) { + const bareLine = line.trim() + const hostLine = bareLine.endsWith(':') ? bareLine.slice(0, -1) : bareLine + if ( + line === bareLine && + /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?(?::\d+)?$/.test(hostLine) + ) { + hosts.add(hostLine.toLowerCase()) + } + } + return Array.from(hosts) +} diff --git a/src/main/gitlab/gitlab-project-ref-resolution.ts b/src/main/gitlab/gitlab-project-ref-resolution.ts index 1dba771d05e..218a41869c9 100644 --- a/src/main/gitlab/gitlab-project-ref-resolution.ts +++ b/src/main/gitlab/gitlab-project-ref-resolution.ts @@ -2,6 +2,11 @@ import { gitExecFileAsync, glabExecFileAsync } from '../git/runner' import type { IssueSourcePreference } from '../../shared/types' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { clearProjectRefInFlight, runProjectRefProbeOnce } from './project-ref-inflight' +import { + parseGlabAuthStatusHosts, + rememberGlabKnownHost, + type LocalGitExecOptions +} from './gitlab-known-host-probe' import { DEFAULT_GITLAB_HOSTS, normalizeGitLabHost, @@ -12,26 +17,16 @@ import { export { DEFAULT_GITLAB_HOSTS, parseGitLabProjectRef } export type { ProjectRef } - -export type LocalGitExecOptions = { - wslDistro?: string -} +export { + _resetKnownHostsCache, + getGlabKnownHosts, + parseGlabAuthStatusHosts +} from './gitlab-known-host-probe' +export type { LocalGitExecOptions } from './gitlab-known-host-probe' const PROJECT_REF_CACHE_MAX_ENTRIES = 512 const projectRefCache = new Map() -// Why: known hosts are cached PER connection. A repo on an SSH connection -// authenticates against a different glab context than the local one, so a -// process-global cache would leak one connection's hosts into another (and -// poison a connection that probes before its tunnel is ready). The local -// context uses the `'local'` key. -const LOCAL_CONNECTION_KEY = 'local' -const knownHostsCacheByConnection = new Map() - -function connectionCacheKey(connectionId?: string | null): string { - return connectionId ?? LOCAL_CONNECTION_KEY -} - /** @internal - exposed for tests only */ export function _resetProjectRefCache(): void { projectRefCache.clear() @@ -43,11 +38,6 @@ export function _getProjectRefCacheSize(): number { return projectRefCache.size } -/** @internal - exposed for tests only */ -export function _resetKnownHostsCache(): void { - knownHostsCacheByConnection.clear() -} - function rememberProjectRefCacheEntry(cacheKey: string, value: ProjectRef | null): void { projectRefCache.set(cacheKey, value) while (projectRefCache.size > PROJECT_REF_CACHE_MAX_ENTRIES) { @@ -118,7 +108,7 @@ async function resolveProjectRefForRemote( localGitOptions )) ) { - rememberGlabKnownHost(remoteCandidate.host, connectionId) + rememberGlabKnownHost(remoteCandidate.host, connectionId, localGitOptions) rememberProjectRefCacheEntry(cacheKey, remoteCandidate) return remoteCandidate } @@ -230,16 +220,6 @@ export function glabHostnameArgs( return connectionId && projectRef?.host ? ['--hostname', projectRef.host] : [] } -function rememberGlabKnownHost(host: string, connectionId?: string | null): void { - const normalizedHost = normalizeGitLabHost(host) - const key = connectionCacheKey(connectionId) - const cached = knownHostsCacheByConnection.get(key) - if (!cached || cached.map(normalizeGitLabHost).includes(normalizedHost)) { - return - } - knownHostsCacheByConnection.set(key, [...cached, normalizedHost]) -} - async function isGlabConfiguredForRemoteHost( repoPath: string, projectRef: Pick, @@ -262,50 +242,3 @@ async function isGlabConfiguredForRemoteHost( return hosts.includes(normalizeGitLabHost(projectRef.host)) } } - -export async function getGlabKnownHosts(connectionId?: string | null): Promise { - const key = connectionCacheKey(connectionId) - const cached = knownHostsCacheByConnection.get(key) - if (cached) { - return cached - } - try { - // Why: `glab auth status` is host-scoped, not cwd-scoped — glab reads its - // own config to list authenticated hosts. The connectionId is threaded so - // the RESULT is cached per connection (a connected repo can have a - // different set of authenticated self-hosted hosts than the local one), - // mirroring how project-ref resolution caches per connection. - const { stdout, stderr } = await glabExecFileAsync(['auth', 'status']) - const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`) - const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts])) - knownHostsCacheByConnection.set(key, merged) - return merged - } catch { - // Auth check failed (glab not installed, no auth, tunnel not ready, - // etc.) — fall back to the canonical default for THIS call, but do NOT - // cache the fallback. A later probe (e.g. after the SSH tunnel comes - // up) must be able to discover the real self-hosted host. - return [...DEFAULT_GITLAB_HOSTS] - } -} - -export function parseGlabAuthStatusHosts(output: string): string[] { - const hosts = new Set() - // Why: self-hosted GitLab can run on a non-default port (e.g. - // `gitlab.example.com:8443`); capture the optional `:port` so two services - // on the same hostname but different ports stay distinct downstream. - for (const m of output.matchAll(/logged in to ([a-zA-Z0-9.-]+(?::\d+)?)/gi)) { - hosts.add(m[1].toLowerCase()) - } - for (const line of output.split('\n')) { - const bareLine = line.trim() - const hostLine = bareLine.endsWith(':') ? bareLine.slice(0, -1) : bareLine - if ( - line === bareLine && - /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?(?::\d+)?$/.test(hostLine) - ) { - hosts.add(hostLine.toLowerCase()) - } - } - return Array.from(hosts) -} diff --git a/src/main/gitlab/gl-utils.test.ts b/src/main/gitlab/gl-utils.test.ts index 7306c5b7fd4..9ab99ddc441 100644 --- a/src/main/gitlab/gl-utils.test.ts +++ b/src/main/gitlab/gl-utils.test.ts @@ -434,6 +434,7 @@ describe('getGlabKnownHosts', () => { }) await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.example.com']) + expect(glabExecFileAsyncMock).toHaveBeenCalledWith(['auth', 'status'], { timeout: 10_000 }) }) it('falls back to default when glab auth status fails', async () => { @@ -453,6 +454,55 @@ describe('getGlabKnownHosts', () => { expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1) }) + it('coalesces many simultaneous callers in one execution context', async () => { + let resolveProbe!: (value: { stdout: string; stderr: string }) => void + glabExecFileAsyncMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveProbe = resolve + }) + ) + + const probes = Array.from({ length: 64 }, () => getGlabKnownHosts()) + + expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1) + resolveProbe({ stdout: 'Logged in to gitlab.concurrent.test as user\n', stderr: '' }) + const results = await Promise.all(probes) + expect(results.every((result) => result === results[0])).toBe(true) + expect(results[0]).toEqual(['gitlab.com', 'gitlab.concurrent.test']) + }) + + it('keeps simultaneous native, WSL distro, and connection probes isolated', async () => { + glabExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'Logged in to ubuntu.test as user\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'Logged in to debian.test as user\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'Logged in to native.test as user\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'Logged in to ssh.test as user\n', stderr: '' }) + + const [ubuntu, ubuntuAgain, debian, native, ssh] = await Promise.all([ + getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }), + getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }), + getGlabKnownHosts(undefined, { wslDistro: 'Debian' }), + getGlabKnownHosts(), + getGlabKnownHosts('conn-1') + ]) + + expect(ubuntuAgain).toBe(ubuntu) + expect(ubuntu).toEqual(['gitlab.com', 'ubuntu.test']) + expect(debian).toEqual(['gitlab.com', 'debian.test']) + expect(native).toEqual(['gitlab.com', 'native.test']) + expect(ssh).toEqual(['gitlab.com', 'ssh.test']) + expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(4) + expect(glabExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['auth', 'status'], { + timeout: 10_000, + wslDistro: 'Ubuntu' + }) + expect(glabExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['auth', 'status'], { + timeout: 10_000, + wslDistro: 'Debian' + }) + }) + it('recognizes a self-hosted host on a non-default port', async () => { glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '✓ Logged in to gitlab.example.com:8080 as user\n', @@ -500,4 +550,51 @@ describe('getGlabKnownHosts', () => { ]) expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2) }) + + it('removes a timed-out probe from in-flight state so a later call retries', async () => { + let rejectProbe!: (error: Error) => void + glabExecFileAsyncMock + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectProbe = reject + }) + ) + .mockResolvedValueOnce({ stdout: 'Logged in to recovered.test as user\n', stderr: '' }) + + const first = getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }) + const concurrent = getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' }) + expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1) + rejectProbe(new Error('wsl.exe timed out.')) + + await expect(Promise.all([first, concurrent])).resolves.toEqual([ + ['gitlab.com'], + ['gitlab.com'] + ]) + await expect(getGlabKnownHosts(undefined, { wslDistro: 'Ubuntu' })).resolves.toEqual([ + 'gitlab.com', + 'recovered.test' + ]) + expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('does not reuse a successful result after an SSH provider reconnects', async () => { + const connectionId = 'conn-reconnected' + registerSshGitProvider(connectionId, {} as never) + glabExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'Logged in to old-tunnel.test as user\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'Logged in to new-tunnel.test as user\n', stderr: '' }) + + await expect(getGlabKnownHosts(connectionId)).resolves.toEqual([ + 'gitlab.com', + 'old-tunnel.test' + ]) + registerSshGitProvider(connectionId, {} as never) + await expect(getGlabKnownHosts(connectionId)).resolves.toEqual([ + 'gitlab.com', + 'new-tunnel.test' + ]) + expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2) + unregisterSshGitProvider(connectionId) + }) }) diff --git a/src/main/gitlab/issues.test.ts b/src/main/gitlab/issues.test.ts index edc6d245733..cde9d4f9e01 100644 --- a/src/main/gitlab/issues.test.ts +++ b/src/main/gitlab/issues.test.ts @@ -214,7 +214,7 @@ describe('gitlab issue operations', () => { await listIssues('/repo-root', 5, undefined, 'opened', undefined, 'conn-7') - expect(getGlabKnownHostsMock).toHaveBeenCalledWith('conn-7') + expect(getGlabKnownHostsMock).toHaveBeenCalledWith('conn-7', {}) }) it('creates an issue and returns its iid + web_url', async () => { diff --git a/src/main/gitlab/issues.ts b/src/main/gitlab/issues.ts index ec36f68a0f8..970f9ff1f03 100644 --- a/src/main/gitlab/issues.ts +++ b/src/main/gitlab/issues.ts @@ -44,7 +44,7 @@ export async function getIssue( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const knownHosts = await getGlabKnownHosts(connectionId) + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const projectRef = await getIssueProjectRef(repoPath, knownHosts, connectionId, localGitOptions) // Why: don't fall back to a cwd-inferred `glab issue view` when the project // can't be resolved — on an SSH connection cwd is not the repo dir, so glab @@ -92,7 +92,7 @@ export async function listIssues( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const knownHosts = await getGlabKnownHosts(connectionId) + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const { source: projectRef } = await resolveIssueSource( repoPath, preference, @@ -162,7 +162,7 @@ export async function createIssue( if (!trimmedTitle) { return { ok: false, error: 'Title is required' } } - const knownHosts = await getGlabKnownHosts(connectionId) + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const { source: projectRef } = await resolveIssueSource( repoPath, preference, @@ -232,7 +232,7 @@ export async function updateIssue( await resolveIssueSource( repoPath, preference, - await getGlabKnownHosts(connectionId), + await getGlabKnownHosts(connectionId, localGitOptions), connectionId, localGitOptions ) @@ -369,7 +369,7 @@ export async function addIssueComment( await resolveIssueSource( repoPath, preference, - await getGlabKnownHosts(connectionId), + await getGlabKnownHosts(connectionId, localGitOptions), connectionId, localGitOptions ) @@ -427,7 +427,7 @@ export async function listLabels( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const knownHosts = await getGlabKnownHosts(connectionId) + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const { source: projectRef } = await resolveIssueSource( repoPath, preference, @@ -468,7 +468,7 @@ export async function listAssignableUsers( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const knownHosts = await getGlabKnownHosts(connectionId) + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const { source: projectRef } = await resolveIssueSource( repoPath, preference, diff --git a/src/main/gitlab/work-item-details.ts b/src/main/gitlab/work-item-details.ts index d9f744c138f..0b37206d6bb 100644 --- a/src/main/gitlab/work-item-details.ts +++ b/src/main/gitlab/work-item-details.ts @@ -361,7 +361,7 @@ export async function getWorkItemDetails( await resolveIssueSource( repoPath, preference, - await getGlabKnownHosts(connectionId), + await getGlabKnownHosts(connectionId, localGitOptions), connectionId, localGitOptions ) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c255a65863c..17fcec9ba87 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -17895,7 +17895,7 @@ export class OrcaRuntimeService { } catch (error) { return { error: error instanceof Error ? error.message : 'Could not resolve git remote.' } } - const knownHosts = await getGlabKnownHosts(repo.connectionId ?? null) + const knownHosts = await getGlabKnownHosts(repo.connectionId ?? null, localWorktreeGitOptions) const projectRef = await getGitLabProjectRefForRemote( repo.path, remote, @@ -18020,7 +18020,7 @@ export class OrcaRuntimeService { connectionId?: string | null, localGitOptions: { wslDistro?: string } = {} ): Promise { - const knownHosts = await getGlabKnownHosts(connectionId) + const knownHosts = await getGlabKnownHosts(connectionId, localGitOptions) const localGitOptionArgs = Object.keys(localGitOptions).length > 0 ? ([localGitOptions] as const) : [] if (preference === 'origin') { From daf22f0720b0730911c9e56efa399dd82f1cecbe Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:13:19 -0700 Subject: [PATCH 06/45] Make Create PR handle sync by fast-forwarding behind-only branches (#9481) - Create PR now fast-forwards behind-only branches before committing, using git pull --ff-only. This prevents the dirty-then-ahead+behind stall that occurred after commit without prior sync. - Refactor runRemoteAction to return explicit status ('ok', 'failed', 'superseded', 'skipped') instead of boolean ok + nullable error. Allows callers to distinguish real failures from action supersession or skips without stale-cache issues. - Remove isCreatePrIntentSyncConflictError function and sync-conflict-specific copy since --ff-only fails cleanly if branch diverged; no merge conflicts to resolve. - Extract isBehindOnlyUpstream predicate to shared module so eligibility checks and the one-click flow always agree. --- src/main/keybindings/keybinding-file.test.ts | 10 +- src/main/keybindings/keybinding-file.ts | 39 +++--- .../right-sidebar/SourceControl.tsx | 110 +++++++++++------ ...urce-control-create-pr-intent-flow.test.ts | 88 +------------- .../source-control-create-pr-intent-flow.ts | 40 ++----- ...ol-primary-action.create-pr-intent.test.ts | 2 +- .../terminal-pane/pty-connection.test.ts | 113 +++++++++--------- src/renderer/src/i18n/locales/en.json | 5 +- src/renderer/src/i18n/locales/es.json | 5 +- src/renderer/src/i18n/locales/ja.json | 5 +- src/renderer/src/i18n/locales/ko.json | 5 +- src/renderer/src/i18n/locales/zh.json | 5 +- .../src/lib/source-control-remote-error.ts | 16 +-- src/shared/git-upstream-status.test.ts | 35 ++++++ src/shared/git-upstream-status.ts | 8 ++ .../source-control-create-review-intent.ts | 17 +-- 16 files changed, 242 insertions(+), 261 deletions(-) diff --git a/src/main/keybindings/keybinding-file.test.ts b/src/main/keybindings/keybinding-file.test.ts index 57a000c3217..4fd295d4c50 100644 --- a/src/main/keybindings/keybinding-file.test.ts +++ b/src/main/keybindings/keybinding-file.test.ts @@ -370,13 +370,17 @@ describe('keybinding-file', () => { it('throws instead of freezing the seed when a legacy binding fails normalization', () => { // A dropped pin must not be silent: the service catches the throw and keeps - // the cohort pending so a corrected build can retry the seed. + // the cohort pending so a corrected build can retry the seed. Valid pins + // from the same batch are still written so users keep the good shortcuts. expect(() => seedLegacyTabSwitchBindings(filePath, 'darwin', { - 'tab.nextSameType': ['J'] + 'tab.nextSameType': ['J'], + 'tab.previousSameType': ['Mod+Shift+BracketLeft'] }) ).toThrow(/normalize/i) - expect(readKeybindingFile(filePath, 'darwin').platformOverrides.darwin).toEqual({}) + expect(readKeybindingFile(filePath, 'darwin').platformOverrides.darwin).toEqual({ + 'tab.previousSameType': ['Mod+Shift+BracketLeft'] + }) }) it('does not replace an unreadable keybindings file while seeding', () => { diff --git a/src/main/keybindings/keybinding-file.ts b/src/main/keybindings/keybinding-file.ts index b07e8759e72..4d41a42ba87 100644 --- a/src/main/keybindings/keybinding-file.ts +++ b/src/main/keybindings/keybinding-file.ts @@ -353,26 +353,31 @@ export function seedLegacyTabSwitchBindings( return { seeded: false, snapshot: current } } - // Why: never freeze the one-shot after dropping a pin — a normalization - // failure must throw so the cohort stays pending and a fixed build retries. - const pins = toSeed.map((actionId) => { + // Why: seed every pin that normalizes, but never freeze the one-shot if any + // pin was dropped — throw after writing good pins so the cohort stays pending + // and a fixed build retries the failed action without wiping the others. + const pins: (readonly [KeybindingActionId, string[]])[] = [] + const failedActionIds: KeybindingActionId[] = [] + for (const actionId of toSeed) { const normalized = normalizeKeybindingArrayForAction(actionId, legacyBindings[actionId] ?? []) if (!Array.isArray(normalized)) { - throw new Error(`Could not normalize legacy binding for "${actionId}".`) + failedActionIds.push(actionId) + continue } - return [actionId, normalized] as const - }) - const snapshot = writeActivePlatformSection( - path, - platform, - current.commonOverrides, - (activePlatform) => { - for (const [actionId, normalized] of pins) { - activePlatform[actionId] = normalized - } - } - ) - return { seeded: true, snapshot } + pins.push([actionId, normalized]) + } + const snapshot = + pins.length > 0 + ? writeActivePlatformSection(path, platform, current.commonOverrides, (activePlatform) => { + for (const [actionId, normalized] of pins) { + activePlatform[actionId] = normalized + } + }) + : current + if (failedActionIds.length > 0) { + throw new Error(`Could not normalize legacy binding for "${failedActionIds.join('", "')}".`) + } + return { seeded: pins.length > 0, snapshot } } // Why: the one-shot seed migration and Settings writes must produce the same diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index a4eaf4250bc..d9cb51a24a3 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -195,7 +195,10 @@ import { resolveCreateReviewDraftTitle } from './create-review-draft-title' import { GitHistoryPanel, type GitHistoryPanelState } from './GitHistoryPanel' import { useGitHistoryCommitActions } from './useGitHistoryCommitActions' import { normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs' -import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status' +import { + isBehindOnlyUpstream, + shouldForcePushWithLeaseForUpstream +} from '../../../../shared/git-upstream-status' import type { DiffComment, GitBranchChangeEntry, @@ -258,7 +261,6 @@ import { createPrIntentRunTokenMatches, getCreatePrIntentCommitFailureNoticeMessage, getCreatePrIntentStagePaths, - isCreatePrIntentSyncConflictError, resolveCreatePrIntentReviewBase, resolveCreatePrIntentRemoteStep, type CreatePrIntentRunToken @@ -2515,6 +2517,16 @@ function SourceControlInner(): React.JSX.Element { // chevron dropdown can trigger. Keeps the error-swallow pattern in one // place — store slices already surface actionable toasts, so additional // try/catch here would duplicate the notification. + // Why: callers must distinguish real failures from "a newer remote action won + // the sequence" and "this call was a no-op (missing target/base)". Collapsing + // those into `{ ok: false, error: null }` made Create PR treat supersession as + // a destructive remote failure. + type RunRemoteActionResult = + | { status: 'ok' } + | { status: 'failed'; error: SourceControlActionError } + | { status: 'superseded' } + | { status: 'skipped' } + const runRemoteAction = useCallback( async ( kind: @@ -2530,10 +2542,7 @@ function SourceControlInner(): React.JSX.Element { target?: SourceControlOperationTarget baseRef?: string | null } - // Why: return the failure inline (null error = success or superseded) so - // callers like the Create PR intent flow can classify it without a - // stale-prone mirror of remoteActionErrors. - ): Promise<{ ok: boolean; error: SourceControlActionError | null }> => { + ): Promise => { const target = options?.target ?? (activeWorktreeId && worktreePath @@ -2546,7 +2555,7 @@ function SourceControlInner(): React.JSX.Element { } : null) if (!target) { - return { ok: false, error: null } + return { status: 'skipped' } } const sequence = (remoteActionErrorSequenceByWorktreeRef.current[target.worktreeId] ?? 0) + 1 remoteActionErrorSequenceByWorktreeRef.current[target.worktreeId] = sequence @@ -2572,7 +2581,7 @@ function SourceControlInner(): React.JSX.Element { target.pushTarget, { runtimeTargetSettings: target.settings } ) - return { ok: true, error: null } + return { status: 'ok' } } if (kind === 'push') { // Why: kind 'push' must stay a regular push. Force-with-lease is only @@ -2587,7 +2596,7 @@ function SourceControlInner(): React.JSX.Element { target.pushTarget, { runtimeTargetSettings: target.settings } ) - return { ok: true, error: null } + return { status: 'ok' } } if (kind === 'force_push') { await pushBranch( @@ -2598,7 +2607,7 @@ function SourceControlInner(): React.JSX.Element { target.pushTarget, { forceWithLease: true, runtimeTargetSettings: target.settings } ) - return { ok: true, error: null } + return { status: 'ok' } } if (kind === 'pull') { await pullBranch( @@ -2610,7 +2619,7 @@ function SourceControlInner(): React.JSX.Element { runtimeTargetSettings: target.settings } ) - return { ok: true, error: null } + return { status: 'ok' } } if (kind === 'fast_forward') { await fastForwardBranch( @@ -2620,7 +2629,7 @@ function SourceControlInner(): React.JSX.Element { target.pushTarget, { runtimeTargetSettings: target.settings } ) - return { ok: true, error: null } + return { status: 'ok' } } if (kind === 'fetch') { await fetchBranch( @@ -2632,12 +2641,12 @@ function SourceControlInner(): React.JSX.Element { runtimeTargetSettings: target.settings } ) - return { ok: true, error: null } + return { status: 'ok' } } if (kind === 'rebase') { const baseRef = options?.baseRef ?? effectiveBaseRef if (!baseRef) { - return { ok: false, error: null } + return { status: 'skipped' } } await rebaseFromBase( target.worktreeId, @@ -2647,7 +2656,7 @@ function SourceControlInner(): React.JSX.Element { target.pushTarget, { runtimeTargetSettings: target.settings } ) - return { ok: true, error: null } + return { status: 'ok' } } await syncBranch( target.worktreeId, @@ -2661,14 +2670,14 @@ function SourceControlInner(): React.JSX.Element { if (remoteActionErrorSequenceByWorktreeRef.current[target.worktreeId] === sequence) { setRemoteActionErrors((prev) => ({ ...prev, [target.worktreeId]: null })) } - return { ok: true, error: null } + return { status: 'ok' } } catch (error) { // Why: remote action failures are surfaced by editor-slice actions to keep // one consistent toast path and avoid duplicate notifications in the UI. // Keep the latest failure inline too: dropdown-only actions like Fetch can // otherwise look like nothing happened once the menu closes. if (remoteActionErrorSequenceByWorktreeRef.current[target.worktreeId] !== sequence) { - return { ok: false, error: null } + return { status: 'superseded' } } const actionError: SourceControlActionError = { kind, @@ -2682,7 +2691,7 @@ function SourceControlInner(): React.JSX.Element { sequence } setRemoteActionErrors((prev) => ({ ...prev, [target.worktreeId]: actionError })) - return { ok: false, error: actionError } + return { status: 'failed', error: actionError } } finally { if (!options?.target) { refreshSourceControlAfterRemoteAction({ @@ -3965,6 +3974,42 @@ function SourceControlInner(): React.JSX.Element { return } + // Why: fast-forward behind-only *before* commit so a dirty worktree does + // not become ahead+behind after Create PR commits, then dead-end at the + // explicit sync-first stop. --ff-only refuses if the branch diverged mid + // flight or local edits would be overwritten — never auto-merges. + if (isBehindOnlyUpstream(latestUpstreamStatus)) { + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'muted', + message: translate( + 'auto.components.right.sidebar.SourceControl.createPrIntentFastForwarding', + 'Updating branch…' + ) + }) + const earlyFfResult = await runRemoteAction('fast_forward', { + target: operationTarget + }) + if (abortIfStale()) { + return + } + if (earlyFfResult.status === 'superseded') { + return + } + if (earlyFfResult.status !== 'ok') { + setCreatePrIntentNoticeForWorktree(token.worktreeId, { + tone: 'destructive', + message: translate( + 'auto.components.right.sidebar.SourceControl.createPrIntentRemoteFailed', + 'Could not update the remote branch. Retry Create PR.' + ) + }) + return + } + if (!(await refreshIntentSnapshot())) { + return + } + } + if (!(await stageLatestIntentPaths())) { return } @@ -4098,7 +4143,7 @@ function SourceControlInner(): React.JSX.Element { if (remoteStep === 'blocked' || remoteStep === 'none') { setCreatePrIntentNoticeForWorktree(token.worktreeId, { tone: 'muted', - // Why: a diverged branch is deliberately not auto-synced (that would + // Why: a diverged branch is deliberately not auto-prepared (that would // merge without consent), so keep the explicit sync-first guidance. message: eligibility.blockedReason === 'needs_sync' @@ -4129,10 +4174,10 @@ function SourceControlInner(): React.JSX.Element { 'auto.components.right.sidebar.SourceControl.createPrIntentForcePushing', 'Force pushing with lease…' ) - : remoteStep === 'sync' + : remoteStep === 'fast_forward' ? translate( - 'auto.components.right.sidebar.SourceControl.createPrIntentSyncing', - 'Syncing branch…' + 'auto.components.right.sidebar.SourceControl.createPrIntentFastForwarding', + 'Updating branch…' ) : translate( 'auto.components.right.sidebar.SourceControl.createPrIntentPushing', @@ -4146,20 +4191,17 @@ function SourceControlInner(): React.JSX.Element { if (abortIfStale()) { return } - if (!remoteResult.ok) { - // A sync push-stage rejection is not a conflict — it falls to the generic - // copy so the push-recovery panel drives the fix. + // Superseded by a newer remote action — drop quietly, same as target drift. + if (remoteResult.status === 'superseded') { + return + } + if (remoteResult.status !== 'ok') { setCreatePrIntentNoticeForWorktree(token.worktreeId, { tone: 'destructive', - message: isCreatePrIntentSyncConflictError(remoteResult.error) - ? translate( - 'auto.components.right.sidebar.SourceControl.createPrIntentSyncConflicts', - 'Sync hit conflicts. Resolve them, then retry Create PR.' - ) - : translate( - 'auto.components.right.sidebar.SourceControl.createPrIntentRemoteFailed', - 'Could not update the remote branch. Retry Create PR.' - ) + message: translate( + 'auto.components.right.sidebar.SourceControl.createPrIntentRemoteFailed', + 'Could not update the remote branch. Retry Create PR.' + ) }) return } diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts index a1f6459dce2..753ebaaa750 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.test.ts @@ -7,7 +7,6 @@ import { createPrIntentRunTokenMatches, getCreatePrIntentCommitFailureNoticeMessage, getCreatePrIntentStagePaths, - isCreatePrIntentSyncConflictError, resolveCreatePrIntentReviewBase, resolveCreatePrIntentRemoteStep } from './source-control-create-pr-intent-flow' @@ -233,9 +232,9 @@ describe('source-control Create PR intent flow helpers', () => { ).toBe('force_push') }) - it('syncs behind-only branches, blocks diverged and unpublished-without-commits branches', () => { - // Genuinely diverged (local + non-equivalent remote commits): auto-syncing - // would merge without consent, so the intent flow keeps the explicit stop. + it('fast-forwards behind-only branches, blocks diverged and unpublished-without-commits branches', () => { + // Genuinely diverged (local + non-equivalent remote commits): auto-merging + // would reconcile without consent, so the intent flow keeps the explicit stop. expect( resolveCreatePrIntentRemoteStep({ upstreamStatus: { hasUpstream: true, ahead: 1, behind: 1 }, @@ -251,7 +250,7 @@ describe('source-control Create PR intent flow helpers', () => { }) ).toBe('blocked') - // Behind with no local commits (pure fast-forward case) auto-syncs. + // Behind with no local commits: pure --ff-only (never plain merge sync). expect( resolveCreatePrIntentRemoteStep({ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 3 }, @@ -265,7 +264,7 @@ describe('source-control Create PR intent flow helpers', () => { reviewLookupOutcome: 'not_found' } }) - ).toBe('sync') + ).toBe('fast_forward') expect( resolveCreatePrIntentRemoteStep({ @@ -302,81 +301,4 @@ describe('source-control Create PR intent flow helpers', () => { }) ).toBe('localized Pre-commit hook failed.') }) - - it('treats only a genuine merge-conflict sync failure as a conflict for notice copy', () => { - // Pull/merge conflict from this sync: the user must resolve conflicts. - expect( - isCreatePrIntentSyncConflictError({ - kind: 'sync', - syncPushStage: false, - rawError: 'CONFLICT (content): Merge conflict in src/app.ts' - }) - ).toBe(true) - // An unconcluded prior merge also counts as a conflict to resolve. - expect( - isCreatePrIntentSyncConflictError({ - kind: 'sync', - rawError: 'error: you have not concluded your merge (MERGE_HEAD exists).' - }) - ).toBe(true) - - // A fetch/network/auth failure during sync is NOT a conflict, even though it - // is not marked as the push stage — it must fall to the generic copy. - expect( - isCreatePrIntentSyncConflictError({ - kind: 'sync', - syncPushStage: false, - rawError: 'fatal: Authentication failed for remote' - }) - ).toBe(false) - // No raw error at all is not a conflict. - expect(isCreatePrIntentSyncConflictError({ kind: 'sync' })).toBe(false) - - // Push-stage rejection during sync is a push failure, not a conflict. - expect( - isCreatePrIntentSyncConflictError({ - kind: 'sync', - syncPushStage: true, - rawError: 'CONFLICT (content): Merge conflict in src/app.ts' - }) - ).toBe(false) - - // Non-sync remote failures never use the conflict copy. - expect(isCreatePrIntentSyncConflictError({ kind: 'push' })).toBe(false) - expect( - isCreatePrIntentSyncConflictError({ - kind: 'force_push', - syncPushStage: false, - rawError: 'CONFLICT (content): Merge conflict in src/app.ts' - }) - ).toBe(false) - expect(isCreatePrIntentSyncConflictError(null)).toBe(false) - expect(isCreatePrIntentSyncConflictError(undefined)).toBe(false) - }) - - it('recognizes every merge-conflict wording git can emit during sync', () => { - // The fresh-conflict pattern's other alternatives: "Automatic merge failed" - // and "fix conflicts" are the most common real pull output. - expect( - isCreatePrIntentSyncConflictError({ - kind: 'sync', - syncPushStage: false, - rawError: 'Automatic merge failed; fix conflicts and then commit the result.' - }) - ).toBe(true) - // The unconcluded-merge pattern's other alternatives: "unmerged files" and - // "needs merge". - expect( - isCreatePrIntentSyncConflictError({ - kind: 'sync', - rawError: 'error: Pulling is not possible because you have unmerged files.' - }) - ).toBe(true) - expect( - isCreatePrIntentSyncConflictError({ - kind: 'sync', - rawError: "error: path 'src/app.ts' needs merge" - }) - ).toBe(true) - }) }) diff --git a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts index 000fc19db21..bafba4c8504 100644 --- a/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts +++ b/src/renderer/src/components/right-sidebar/source-control-create-pr-intent-flow.ts @@ -1,12 +1,13 @@ -import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status' +import { + isBehindOnlyUpstream, + shouldForcePushWithLeaseForUpstream +} from '../../../../shared/git-upstream-status' import type { HostedReviewCreationEligibility } from '../../../../shared/hosted-review' import { normalizeHostedReviewBaseRef, normalizeHostedReviewHeadRef } from '../../../../shared/hosted-review-refs' import type { GitStatusEntry, GitUpstreamStatus } from '../../../../shared/types' -import { isMergeConflictErrorMessage } from '../../lib/source-control-remote-error' -import type { SourceControlActionError } from './source-control-action-error' import { summarizeCommitFailure } from './commit-failure-summary' import { getStageAllPaths } from './discard-all-sequence' @@ -14,7 +15,7 @@ export type CreatePrIntentRemoteStep = | 'publish' | 'push' | 'force_push' - | 'sync' + | 'fast_forward' | 'blocked' | 'none' @@ -150,36 +151,17 @@ export function resolveCreatePrIntentRemoteStep({ if (shouldForcePushWithLeaseForUpstream(upstreamStatus)) { return 'force_push' } - // Why: auto-sync only a behind-only branch, where the pull is a fast-forward - // that cannot create a merge commit or strand the worktree mid-merge. A - // genuinely diverged branch keeps the explicit sync-first stop so Create PR - // never merges without consent. - return upstreamStatus?.hasUpstream && upstreamStatus.ahead === 0 && upstreamStatus.behind > 0 - ? 'sync' - : 'blocked' + // Why: auto-prepare only a behind-only branch, and only via `--ff-only`. + // Plain sync/merge could create a merge commit if the branch diverges mid + // flight or the user has pull.ff=no; --ff-only enforces the no-consent- + // merge invariant at execution time. Genuinely diverged branches keep the + // explicit sync-first stop. + return isBehindOnlyUpstream(upstreamStatus) ? 'fast_forward' : 'blocked' } return 'none' } -export type CreatePrIntentRemoteFailure = - | (Pick & - Partial>) - | null - | undefined - -export function isCreatePrIntentSyncConflictError(error: CreatePrIntentRemoteFailure): boolean { - // Why: only a genuine merge conflict earns the "resolve conflicts" copy. A - // sync push-stage rejection is a push failure, and a fetch/network/auth - // failure during sync is neither a conflict nor push-stage — both must fall - // to the generic remote-failed notice, so match the raw git output rather - // than assuming every non-push-stage sync failure is a conflict. - if (error?.kind !== 'sync' || error.syncPushStage === true) { - return false - } - return isMergeConflictErrorMessage(error.rawError ?? '') -} - export function getCreatePrIntentCommitFailureNoticeMessage( commitError: string | null | undefined, copy: { diff --git a/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts b/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts index 5d5f249a3bd..c56892b71ec 100644 --- a/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-primary-action.create-pr-intent.test.ts @@ -73,7 +73,7 @@ describe('resolvePrimaryAction Create PR intent', () => { expect(result.disabled).toBe(false) }) - it('returns Create PR intent for a behind-only branch (fast-forward sync)', () => { + it('returns Create PR intent for a behind-only branch (fast-forward prepare)', () => { const input = inputs({ upstreamStatus: { hasUpstream: true, diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 70a79b26d31..7b119905d47 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -17731,64 +17731,69 @@ describe('connectPanePty', () => { // land in the first advance window, confirming exit before the replacement // hook owner is set and racing the very veto this test asserts. const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.5) - const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) - getForegroundProcess.mockResolvedValue('codex') - const paneKey = makePaneKey('tab-1', LEAF_1) - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps() + try { + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const paneKey = makePaneKey('tab-1', LEAF_1) + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks() - const titleHandler = createdTransportOptions[0]?.onTitleChange as - | ((title: string, rawTitle: string) => void) - | undefined - const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as - | ((title: string) => void) - | undefined - if (!titleHandler || !idleHandler) { - throw new Error('Expected title and idle handlers to be registered') - } - - titleHandler('Codex working', 'Codex working') - await vi.advanceTimersByTimeAsync(2_500) - const inspectionsBeforeIdle = getForegroundProcess.mock.calls.length - getForegroundProcess.mockResolvedValue(null) - // Why: active process polling is jittered, so a fixed window can consume - // both null samples before the replacement hook row is installed. - for ( - let attempts = 0; - getForegroundProcess.mock.calls.length === inspectionsBeforeIdle; - attempts += 1 - ) { - if (attempts >= 10) { - throw new Error('Expected the first idle process inspection') + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + const idleHandler = createdTransportOptions[0]?.onAgentBecameIdle as + | ((title: string) => void) + | undefined + if (!titleHandler || !idleHandler) { + throw new Error('Expected title and idle handlers to be registered') } - await vi.advanceTimersToNextTimerAsync() - } - expect(getForegroundProcess).toHaveBeenCalledTimes(inspectionsBeforeIdle + 1) - mockStoreState.agentStatusByPaneKey[paneKey] = { - state: 'working', - prompt: 'replacement agent turn', - updatedAt: Date.now(), - stateStartedAt: Date.now(), - agentType: 'claude', - paneKey, - stateHistory: [] - } - idleHandler('Claude done') - await vi.advanceTimersByTimeAsync(800) - await vi.advanceTimersByTimeAsync(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + titleHandler('Codex working', 'Codex working') + await vi.advanceTimersByTimeAsync(2_500) + const inspectionsBeforeIdle = getForegroundProcess.mock.calls.length + getForegroundProcess.mockResolvedValue(null) + // Why: active process polling is jittered, so a fixed window can consume + // both null samples before the replacement hook row is installed. + for ( + let attempts = 0; + getForegroundProcess.mock.calls.length === inspectionsBeforeIdle; + attempts += 1 + ) { + if (attempts >= 10) { + throw new Error('Expected the first idle process inspection') + } + await vi.advanceTimersToNextTimerAsync() + } + expect(getForegroundProcess).toHaveBeenCalledTimes(inspectionsBeforeIdle + 1) - expect(deps.dispatchNotification).not.toHaveBeenCalledWith( - expect.objectContaining({ source: 'agent-task-complete' }) - ) - expect(pane.terminal.write).not.toHaveBeenCalledWith( - RESET_TERMINAL_CURSOR_STYLE, - expect.any(Function) - ) - randomSpy.mockRestore() + mockStoreState.agentStatusByPaneKey[paneKey] = { + state: 'working', + prompt: 'replacement agent turn', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'claude', + paneKey, + stateHistory: [] + } + idleHandler('Claude done') + await vi.advanceTimersByTimeAsync(800) + await vi.advanceTimersByTimeAsync(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + + expect(deps.dispatchNotification).not.toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + RESET_TERMINAL_CURSOR_STYLE, + expect.any(Function) + ) + } finally { + // Why: any assertion failure above must not pin Math.random for later tests + // in this large suite. + randomSpy.mockRestore() + } }) it('preserves replacement-agent title side effects through the process replacement veto', async () => { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 23cfccec709..427fa58d0e5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9709,6 +9709,7 @@ "createPrIntentPublishing": "Publishing branch…", "createPrIntentForcePushing": "Force pushing with lease…", "createPrIntentPushing": "Pushing commits…", + "createPrIntentFastForwarding": "Updating branch…", "createPrIntentRemoteFailed": "Could not update the remote branch. Retry Create PR.", "createPrIntentGeneratingDetails": "Generating review details…", "createPrIntentBranchChangedDuringDetails": "Branch changed while generating review details. Retry Create PR.", @@ -9741,9 +9742,7 @@ "a9bf7c171a": "Push Failed", "834cb3f23d": "Fix with AI", "783a808870": "Close" - }, - "createPrIntentSyncConflicts": "Sync hit conflicts. Resolve them, then retry Create PR.", - "createPrIntentSyncing": "Syncing branch…" + } }, "SourceControlAgentActionDialog": { "8e856842d1": "Could not start the selected agent.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index fe3ac1620a8..c7e2e7ccc1c 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9686,6 +9686,7 @@ "createPrIntentPublishing": "Publicando rama…", "createPrIntentForcePushing": "Haciendo force push con lease…", "createPrIntentPushing": "Haciendo push de commits…", + "createPrIntentFastForwarding": "Actualizando rama…", "createPrIntentRemoteFailed": "No se pudo actualizar la rama remota. Vuelve a intentar Crear PR.", "createPrIntentGeneratingDetails": "Generando detalles de revisión…", "createPrIntentBranchChangedDuringDetails": "La rama cambió mientras se generaban los detalles de la revisión. Vuelve a intentar Crear PR.", @@ -9718,9 +9719,7 @@ "a9bf7c171a": "Push fallido", "834cb3f23d": "Corregir con AI", "783a808870": "Cerrar" - }, - "createPrIntentSyncConflicts": "La sincronización encontró conflictos. Resuélvelos y vuelve a intentar Crear PR.", - "createPrIntentSyncing": "Sincronizando rama…" + } }, "SourceControlAgentActionDialog": { "8e856842d1": "No se pudo iniciar el agente seleccionado.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index eaf4518ccbb..6c4390633c4 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9686,6 +9686,7 @@ "createPrIntentPublishing": "ブランチを公開中…", "createPrIntentForcePushing": "lease 付きで強制プッシュ中…", "createPrIntentPushing": "commits をプッシュ中…", + "createPrIntentFastForwarding": "ブランチを更新中…", "createPrIntentRemoteFailed": "リモートブランチを更新できませんでした。Create PR を再試行してください。", "createPrIntentGeneratingDetails": "レビューの詳細を生成中…", "createPrIntentBranchChangedDuringDetails": "レビューの詳細を生成中にブランチが変更されました。Create PR を再試行してください。", @@ -9718,9 +9719,7 @@ "a9bf7c171a": "プッシュ失敗", "834cb3f23d": "AIで修正", "783a808870": "閉じる" - }, - "createPrIntentSyncConflicts": "同期で競合が発生しました。解決してから Create PR を再試行してください。", - "createPrIntentSyncing": "ブランチを同期中…" + } }, "SourceControlAgentActionDialog": { "8e856842d1": "選択した agent を開始できませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 742d8531e4c..5da5129544d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9686,6 +9686,7 @@ "createPrIntentPublishing": "브랜치를 게시하는 중…", "createPrIntentForcePushing": "lease로 강제 푸시하는 중…", "createPrIntentPushing": "commits을 푸시하는 중…", + "createPrIntentFastForwarding": "브랜치를 업데이트하는 중…", "createPrIntentRemoteFailed": "원격 브랜치를 업데이트할 수 없습니다. Create PR을 다시 시도하세요.", "createPrIntentGeneratingDetails": "리뷰 세부 정보 생성 중…", "createPrIntentBranchChangedDuringDetails": "리뷰 세부 정보를 생성하는 동안 브랜치가 변경되었습니다. Create PR을 다시 시도하세요.", @@ -9718,9 +9719,7 @@ "a9bf7c171a": "푸시 실패", "834cb3f23d": "AI로 수정", "783a808870": "닫기" - }, - "createPrIntentSyncConflicts": "동기화 중 충돌이 발생했습니다. 해결한 다음 Create PR을 다시 시도하세요.", - "createPrIntentSyncing": "브랜치를 동기화하는 중…" + } }, "SourceControlAgentActionDialog": { "8e856842d1": "선택한 agent를 시작할 수 없습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 657a353bd8c..be2ed642cc5 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9686,6 +9686,7 @@ "createPrIntentPublishing": "正在发布分支…", "createPrIntentForcePushing": "正在使用 lease 强制推送…", "createPrIntentPushing": "正在推送提交…", + "createPrIntentFastForwarding": "正在更新分支…", "createPrIntentRemoteFailed": "无法更新远程分支。请重试创建 PR。", "createPrIntentGeneratingDetails": "正在生成审查详情…", "createPrIntentBranchChangedDuringDetails": "生成审查详情时分支已更改。请重试创建 PR。", @@ -9718,9 +9719,7 @@ "a9bf7c171a": "推送失败", "834cb3f23d": "使用 AI 修复", "783a808870": "关闭" - }, - "createPrIntentSyncConflicts": "同步遇到冲突。请解决后重试创建 PR。", - "createPrIntentSyncing": "正在同步分支…" + } }, "SourceControlAgentActionDialog": { "8e856842d1": "无法启动选定的智能体。", diff --git a/src/renderer/src/lib/source-control-remote-error.ts b/src/renderer/src/lib/source-control-remote-error.ts index 57edcd211be..286f2e21531 100644 --- a/src/renderer/src/lib/source-control-remote-error.ts +++ b/src/renderer/src/lib/source-control-remote-error.ts @@ -110,24 +110,12 @@ export function isSyncPushStageError(error: unknown): boolean { ) } -// Why: shared patterns so conflict classification and toast copy cannot drift. -// Unconcluded prior merge vs. a fresh conflict from this operation produce -// different user-facing messages, but Create PR treats both as "sync conflict". +// Why: shared patterns so unconcluded-merge vs fresh-conflict toast copy cannot +// drift between the two branches below. const UNCONCLUDED_MERGE_ERROR_PATTERN = /unmerged files|needs merge|you have not concluded your merge/i const FRESH_MERGE_CONFLICT_ERROR_PATTERN = /automatic merge failed|CONFLICT \(|fix conflicts/i -// Why: detects "the working tree has merge conflicts the user must resolve" — -// both an unconcluded prior merge and a fresh conflict from this operation. -// Lets the Create PR flow tell a real sync conflict apart from a -// fetch/network/auth/push failure that only looks similar. -export function isMergeConflictErrorMessage(message: string): boolean { - return ( - UNCONCLUDED_MERGE_ERROR_PATTERN.test(message) || - FRESH_MERGE_CONFLICT_ERROR_PATTERN.test(message) - ) -} - export function resolveRemoteOperationErrorMessage( error: unknown, options?: RemoteOperationErrorOptions diff --git a/src/shared/git-upstream-status.test.ts b/src/shared/git-upstream-status.test.ts index 62e9e57cb4f..32cf16ab92e 100644 --- a/src/shared/git-upstream-status.test.ts +++ b/src/shared/git-upstream-status.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { + isBehindOnlyUpstream, shouldForcePushWithLeaseForUpstream, upstreamOnlyCommitsArePatchEquivalent } from './git-upstream-status' @@ -53,3 +54,37 @@ describe('shouldForcePushWithLeaseForUpstream', () => { ).toBe(false) }) }) + +describe('isBehindOnlyUpstream', () => { + it('is true only when the branch tracks upstream and is purely behind', () => { + expect( + isBehindOnlyUpstream({ + hasUpstream: true, + ahead: 0, + behind: 3 + }) + ).toBe(true) + expect( + isBehindOnlyUpstream({ + hasUpstream: true, + ahead: 1, + behind: 2 + }) + ).toBe(false) + expect( + isBehindOnlyUpstream({ + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).toBe(false) + expect( + isBehindOnlyUpstream({ + hasUpstream: false, + ahead: 0, + behind: 3 + }) + ).toBe(false) + expect(isBehindOnlyUpstream(undefined)).toBe(false) + }) +}) diff --git a/src/shared/git-upstream-status.ts b/src/shared/git-upstream-status.ts index 45152f408f6..6d4ed338dab 100644 --- a/src/shared/git-upstream-status.ts +++ b/src/shared/git-upstream-status.ts @@ -46,3 +46,11 @@ export function shouldForcePushWithLeaseForUpstream( status.behindCommitsArePatchEquivalent === true ) } + +// Why: behind-only is the only auto-prepare case Create PR can safely handle +// with a pure fast-forward (no local unique commits to reconcile). Eligibility +// and the intent remote-step resolver must share this predicate so the button +// and the one-click flow never disagree on what "behind-only" means. +export function isBehindOnlyUpstream(status: GitUpstreamStatus | undefined): boolean { + return status?.hasUpstream === true && status.ahead === 0 && status.behind > 0 +} diff --git a/src/shared/source-control-create-review-intent.ts b/src/shared/source-control-create-review-intent.ts index 880fce17700..5d1f517280b 100644 --- a/src/shared/source-control-create-review-intent.ts +++ b/src/shared/source-control-create-review-intent.ts @@ -1,4 +1,4 @@ -import { shouldForcePushWithLeaseForUpstream } from './git-upstream-status' +import { isBehindOnlyUpstream, shouldForcePushWithLeaseForUpstream } from './git-upstream-status' import type { HostedReviewCreationEligibility } from './hosted-review' import { supportsHostedReviewCreation } from './hosted-review-creation-providers' import type { GitUpstreamStatus } from './git-status-types' @@ -77,16 +77,11 @@ export function resolveCreateReviewIntentEligibility({ return { eligible: true, kind: 'force_push' } } - // Why: a behind-only branch (no local commits) is safe to prepare with a - // fast-forward sync, so the intent flow can handle it in one click. A - // genuinely diverged branch stays ineligible — syncing it would merge - // without consent, so the user keeps the explicit sync-first step. - if ( - hostedReviewCreation.blockedReason === 'needs_sync' && - upstreamStatus?.hasUpstream === true && - upstreamStatus.ahead === 0 && - upstreamStatus.behind > 0 - ) { + // Why: a behind-only branch is safe to prepare with `git pull --ff-only`, so + // the intent flow can handle it in one click. A genuinely diverged branch + // stays ineligible — auto-merging would reconcile without consent, so the + // user keeps the explicit sync-first step. + if (hostedReviewCreation.blockedReason === 'needs_sync' && isBehindOnlyUpstream(upstreamStatus)) { return { eligible: true, kind: 'needs_sync' } } From a6df23c7624f5827b24f6142d9ba102d80fc1279 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:17:40 -0700 Subject: [PATCH 07/45] Tighten orchestration worktree isolation policy (#9482) * docs(orchestration): require true isolation for worker worktrees * docs(orchestration): deduplicate parallel worker guidance * docs(orchestration): clarify isolation exceptions * docs(orchestration): clarify checkout preference * Tighten orchestration worktree isolation policy guidance - Clarify that same-worktree workers remain orchestration children despite appearing as peers in the sidebar - Define new-worktree creation as required only for explicit requests or concrete filesystem/checkout conflicts, not convenience - Distinguish stacked worktrees from independent ones via --no-parent - Rename agent-first guidance to reflect conditional requirement * Release orchestration skill v25 with tightened worktree policy --- .../orchestration-skill-guidance.test.mjs | 21 +++++++++---------- resources/skills/current-manifest.json | 14 ++++++------- resources/skills/snapshot-registry.json | 16 ++++++++++++++ skill-guides/orchestration.md | 11 +++++----- skills/orchestration/SKILL.md | 11 +++++----- src/cli/bundled-skill-guides.ts | 2 +- 6 files changed, 46 insertions(+), 29 deletions(-) diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs index 16747b1ad3d..21324f11672 100644 --- a/config/scripts/orchestration-skill-guidance.test.mjs +++ b/config/scripts/orchestration-skill-guidance.test.mjs @@ -144,23 +144,22 @@ describe('orchestration skill guidance', () => { 'Sidebar lineage and orchestration lifecycle are related but not identical.' ) expect(workerTerminals).toContain( - 'A same-worktree worker created with `orca terminal create --worktree active` may appear as a peer terminal/agent' + 'A same-worktree worker may appear as a peer under that worktree in the sidebar' + ) + expect(workerTerminals).toContain('while remaining a child dispatch in orchestration state') + expect(workerTerminals).toContain( + 'only an actual child worktree creates visible parent/child worktree lineage' ) expect(workerTerminals).toContain( - 'even though it is a child dispatch in Orca orchestration state' + 'Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible' ) expect(workerTerminals).toContain( - 'A visible parent/child worktree relationship requires creating a child worktree' + 'Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.' ) expect(workerTerminals).toContain( - 'only when the task can safely run from an isolated checkout and does not need uncommitted artifacts from the current working tree' - ) - expect(workerTerminals).toContain( - 'For supervised new-worktree workers, decide the desired Orca lineage before creation' - ) - expect(workerTerminals).toContain( - 'use `--no-parent` for independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks' + 'When a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree' ) + expect(workerTerminals).toContain('use `--no-parent` when it is not stacked') }) it('keeps review-only completions and named next-owner fixes in their lanes', () => { @@ -213,7 +212,7 @@ describe('orchestration skill guidance', () => { /```bash\norca worktree create --name --agent codex --json\n[\s\S]*?```/ )?.[0] - expect(workerTerminals).toContain('Agent-first (required for ordinary agent workers)') + expect(workerTerminals).toContain('For an allowed new worktree, use agent-first:') expect(workerTerminals).toContain('fallback shell + agent pair') expect(workerTerminals).toContain( 'Repo setup or default-terminal settings may still add tabs or splits' diff --git a/resources/skills/current-manifest.json b/resources/skills/current-manifest.json index b0053b7b118..1b489f39c0f 100644 --- a/resources/skills/current-manifest.json +++ b/resources/skills/current-manifest.json @@ -130,18 +130,18 @@ { "name": "orchestration", "sourcePath": "skills/orchestration", - "releaseRevision": 24, - "packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9", - "gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982", + "releaseRevision": 25, + "packageDigest": "c19171d213e827bdf5364b733b67889566aaa2fb3667ebe029db87044d08f908", + "gitTreeSha": "da346803bccae7fb1fdade31bbe9b4d851b25e38", "files": [ { "path": "SKILL.md", - "size": 22861, + "size": 22676, "executable": false, "classification": "text", - "exactSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", - "textNormalizedSha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5", - "identitySha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5" + "exactSha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305", + "textNormalizedSha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305", + "identitySha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305" } ] } diff --git a/resources/skills/snapshot-registry.json b/resources/skills/snapshot-registry.json index 58d6c7f6c0e..f522706ff6c 100644 --- a/resources/skills/snapshot-registry.json +++ b/resources/skills/snapshot-registry.json @@ -899,6 +899,22 @@ "identitySha256": "0cb898d37560ab7ac4cb477a2d7e8ba3e593d45affd6d7e9f99151879aaeb1a5" } ] + }, + { + "releaseRevision": 25, + "packageDigest": "c19171d213e827bdf5364b733b67889566aaa2fb3667ebe029db87044d08f908", + "gitTreeSha": "da346803bccae7fb1fdade31bbe9b4d851b25e38", + "files": [ + { + "path": "SKILL.md", + "size": 22676, + "executable": false, + "classification": "text", + "exactSha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305", + "textNormalizedSha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305", + "identitySha256": "0cfb6a082625edc0d474bae430eb22c28bbe484e54fbfebedb4ff89d96e36305" + } + ] } ], "mobile-fit-debug": [ diff --git a/skill-guides/orchestration.md b/skill-guides/orchestration.md index 583fc2ad661..c7d15250e00 100644 --- a/skill-guides/orchestration.md +++ b/skill-guides/orchestration.md @@ -183,7 +183,7 @@ Wait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor ## Worker Terminals -Choose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, create the worker in the active worktree: +Choose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree: ```bash orca terminal create --worktree active --title --command "codex" --json @@ -191,7 +191,9 @@ orca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json orca orchestration dispatch --task --to --inject --json ``` -Reuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Use a new worktree only when explicitly requested or when independent isolated checkout state is intended. For supervised new-worktree workers, decide the desired Orca lineage before creation: use child lineage only when the work is conceptually stacked under or dependent on the active worktree, and use `--no-parent` for independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks. Decide the Git base separately from lineage: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base. +Reuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements. + +When a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base. ```bash orca worktree create --name --agent codex --json @@ -203,11 +205,11 @@ orca orchestration dispatch --task --to --inject --json For new-worktree workers, read the id and `startupTerminal.handle` from `worktree create`. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `. -**Agent-first (required for ordinary agent workers):** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree. +**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree. Use `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble. -Sidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker created with `orca terminal create --worktree active` may appear as a peer terminal/agent under the same worktree in the sidebar even though it is a child dispatch in Orca orchestration state. A visible parent/child worktree relationship requires creating a child worktree, but do that only when the task can safely run from an isolated checkout and does not need uncommitted artifacts from the current working tree. +Sidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage. Other terminal commands coordinators often need: @@ -234,7 +236,6 @@ Wait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding - If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene. - Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop. - Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps. -- Prefer inter-worktree workers only for independent work that does not need current uncommitted state. When same-worktree work is required, create fresh terminals in that worktree and keep edit ownership clear. ## Example diff --git a/skills/orchestration/SKILL.md b/skills/orchestration/SKILL.md index 583fc2ad661..c7d15250e00 100644 --- a/skills/orchestration/SKILL.md +++ b/skills/orchestration/SKILL.md @@ -183,7 +183,7 @@ Wait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor ## Worker Terminals -Choose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, create the worker in the active worktree: +Choose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree: ```bash orca terminal create --worktree active --title --command "codex" --json @@ -191,7 +191,9 @@ orca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json orca orchestration dispatch --task --to --inject --json ``` -Reuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Use a new worktree only when explicitly requested or when independent isolated checkout state is intended. For supervised new-worktree workers, decide the desired Orca lineage before creation: use child lineage only when the work is conceptually stacked under or dependent on the active worktree, and use `--no-parent` for independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks. Decide the Git base separately from lineage: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base. +Reuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements. + +When a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base. ```bash orca worktree create --name --agent codex --json @@ -203,11 +205,11 @@ orca orchestration dispatch --task --to --inject --json For new-worktree workers, read the id and `startupTerminal.handle` from `worktree create`. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `. -**Agent-first (required for ordinary agent workers):** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree. +**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree. Use `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble. -Sidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker created with `orca terminal create --worktree active` may appear as a peer terminal/agent under the same worktree in the sidebar even though it is a child dispatch in Orca orchestration state. A visible parent/child worktree relationship requires creating a child worktree, but do that only when the task can safely run from an isolated checkout and does not need uncommitted artifacts from the current working tree. +Sidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage. Other terminal commands coordinators often need: @@ -234,7 +236,6 @@ Wait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding - If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene. - Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop. - Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps. -- Prefer inter-worktree workers only for independent work that does not need current uncommitted state. When same-worktree work is required, create fresh terminals in that worktree and keep edit ownership clear. ## Example diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index 8f9a747993a..ed49d565cd4 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -30,7 +30,7 @@ const ORCA_LINEAR_MARKDOWN = "---\nname: orca-linear\ndescription: >-\n Use Orc const ORCA_PER_WORKSPACE_ENV_MARKDOWN = "---\nname: orca-per-workspace-env\ndescription: >-\n Set up, review, debug, or validate Orca per-workspace environment recipes —\n on-demand, disposable runtimes (cloud sandboxes, VMs, or local) created fresh\n for each workspace. Covers first-time setup (provider prerequisites, the\n reusable base snapshot, the coding-agent auth snapshot, credentials, and\n state), not just the per-workspace lifecycle scripts. Use to stand up\n per-workspace environments, fix an `environmentRecipes` entry in `orca.yaml`, scaffold\n provider lifecycle scripts, or resolve an `orca vm recipe doctor` failure.\n---\n\n# Per-Workspace Environments\n\nHelp a user stand up and maintain a repo-owned per-workspace environment recipe end to end. Each\nworkspace gets its own on-demand, disposable runtime (a cloud sandbox, a VM, or a local one),\ncreated fresh and torn down after.\n\nOrca is a **thin wrapper**: you guide, detect, and scaffold; you never own the user's cloud account,\nbilling, images, or credentials.\n\n- **You DO:** sequence the setup, detect what's detectable (provider CLI present/logged-in? recipe\n present? `doctor` passing?), scaffold provider-templated scripts the user fills in, drive the slow\n snapshot/auth phases with the user, and always show the next action.\n- **You DO NOT:** create accounts, choose plans/regions, invent org/project/scope ids, store or print\n secrets, or run anything that spends money without an explicit user OK.\n\nFirst-time setup has **four phases before the per-workspace recipe runs** — easy to miss, so walk\nthem in order:\n\n1. **Prerequisites** — cloud account, provider CLI, scope/project, plan limits, git token (§2).\n2. **Base snapshot** — reusable image: tools + repo + headless build, snapshotted once (§3).\n3. **Agent-auth snapshot** — boot the base, run interactive device-auth, re-snapshot (§4).\n4. **State** — thread snapshot id / scope / project / port between phases via a state file (§6).\n\nThen the **per-workspace contract** (create/suspend/resume/destroy) runs fast (§8).\n\n**The one branch that shapes everything — connection mode:** **Orca-server** (`create` runs `orca serve`\nin the env and emits a `pairingCode`; §7c/§7f) vs **SSH** (`create` runs no server and emits a\n`connection.type:\"ssh\"` block Orca dials into; §7g/§7h). Settle this first — it changes the `create`\noutput shape and half the templates.\n\n**Quick-start (happy path):** interview the user (connection mode Orca-server vs SSH, provider, agent CLI,\ngit auth — §1.2) + read the provider's CLI docs → scaffold `scripts/orca-vm/` from §7 → run the\nbase-snapshot script, then the auth script (you invoke these by hand; not via `orca.yaml`) → wire\n`environmentRecipes` in `orca.yaml` → `orca vm recipe doctor --json` (free) → then the `--provision`\nself-test loop (§9) until it passes.\n\n---\n\n## 1. Setup workflow\n\nDrive these with the user. **[CHECKPOINT]** steps need explicit confirmation — they spend money, take\na long time, or need the user at the keyboard. Never create an Orca workspace or commit unless asked.\n\n1. **Inspect the repo** for an existing `environmentRecipes` entry, `scripts/orca-vm/`, a state file, or setup\n notes. If a working recipe exists, jump to Doctor (§9) instead of rebuilding.\n2. **Interview the user up front** — gather these choices and confirm them back before scaffolding\n anything. Don't pick for them (§11); don't guess.\n - **Connection mode:** how Orca attaches to the environment — an **Orca server** (the VM runs\n `orca serve` and Orca pairs over its pairing URL; worked example §7f) or **SSH** (Orca connects to\n the host over SSH; §7g). This decides the recipe's connection shape, so settle it first.\n - **Provider:** Vercel Sandbox, Fly, Modal, an existing SSH host, … For non-obvious providers, also\n ask scope/project/region and plan limits (§2). Then **read that provider's CLI/SDK docs** (or\n ` --help`) before scaffolding — you need its exact create/exec/snapshot/remove verbs.\n If a provider advertises `ssh`, verify whether it exposes a real dialable SSH target\n (host/port/user/key or proxy command) or only a provider-mediated interactive shell; Orca SSH mode\n needs the former.\n - **Coding-agent CLI + account:** which agent runs in the VM (`codex`, `claude`, …) and that the user\n has an account for it — it gets logged in during the Phase-3 auth snapshot (§4).\n - **Git auth:** the token source for cloning a private repo (`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth\n token`; §5).\n3. **Check prerequisites (§2)** — detect the provider CLI + auth and confirm the items above are in\n place before any paid step.\n4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:\n §7h; Windows: §7i), filling in the provider's real commands. Make them executable.\n5. **[CHECKPOINT] Build the base snapshot (§3)** — paid, slow.\n6. **[CHECKPOINT] Authenticate the agent (§4)** — interactive; the user follows a URL/code. **You cannot\n drive this step** — you run commands non-interactively, so there's no TTY for `docker exec -it` /\n `ssh -t` to prompt against. The **user** runs the Phase-3 login in their own terminal (or via the\n Claude Code harness bang-prefix — `! `, with the required space after `!`); you scaffold and drive\n the non-interactive phases around it. After kicking it off, **ask the user to report back once the login\n finishes** — you can't observe it completing, and you need that confirmation before resuming the\n non-interactive steps (base/auth commit, doctor, provision).\n7. **Wire the recipe** so `orca.yaml` points create/suspend/resume/destroy at the scripts (§8). The\n workspace composer reads `environmentRecipes` from the project's primary checkout of `orca.yaml`, **not** from\n a feature branch or worktree. So a recipe added only on a branch won't appear as a \"Run on\" option\n until that `orca.yaml` change is committed and merged to the project's primary branch. Tell the user\n this up front: `doctor`/`--provision` validate the scripts from the working copy on any branch, but\n creating a workspace from the recipe in the picker needs it on primary.\n8. **Dry-run doctor** — `orca vm recipe doctor --repo-path --json` (free, static; §9).\n Fix every failure before going live.\n9. **[CHECKPOINT] Live self-test** — get the user's OK once, then run\n `orca vm recipe doctor --provision --json` as a loop: it runs create → validates →\n destroys, and on failure returns a full transcript. Read it, fix the scripts, and re-run yourself until\n it passes (§9). Spends cloud money; the one approval covers the loop.\n10. **[CHECKPOINT] Optional workspace test** — only if asked: create a workspace via the picker, then\n verify sleep/wake/delete.\n\n---\n\n## 2. Phase 1 — Prerequisites\n\nThe user's responsibility; verify what's verifiable, ask for the rest, invent nothing. State which\nitems you verified vs. which the user asserted.\n\n- **Connection mode** (Orca server vs SSH) confirmed with the user — see §1 step 2; it shapes the recipe.\n- **Cloud account + plan** that allows sandboxes/VMs. Ask.\n- **Provider CLI installed + authenticated** — detect (`command -v `), check auth (e.g.\n `vercel whoami`). If missing, point at the provider's docs; don't log them in.\n- **Scope / project / region** the sandboxes live under. Ask; flows into every script via state.\n- **Plan / timeout / RAM caps.** Record them — e.g. Vercel Hobby caps sandbox timeout at **45m**,\n which limits both the base build and per-workspace runtime (see §10).\n- **Git token for private repos** (`GH_TOKEN`/`GITHUB_TOKEN`, or the provider's git auth; can fall back\n to `gh auth token`). See §5.\n- **Coding-agent CLI choice** (`codex`, `claude`…) and that the user has an account — it gets\n authenticated into the VM in Phase 3.\n\n---\n\n## 3. Phase 2 — Base snapshot (the reusable image)\n\nBuild **once**, snapshot, and every workspace boots from it in seconds instead of rebuilding.\nProvisioning + building takes a while (often ~20–30 min), so it runs behind a checkpoint. The script\nshape is §7a; key points:\n\n- Build the **headless Electron main only** (not the renderer) so it fits in plan RAM.\n- Use the VM image's package manager (`apt`/`dnf`/`apk`, per the base distro — not the provider brand).\n- Clone with the git token via `GIT_ASKPASS` (§5).\n- **Trap errors and remove the half-built sandbox** so a crash doesn't leave a paid resource running.\n- Snapshot the stopped sandbox, parse the snapshot id, and write it + scope/project/port/repo to state.\n\n---\n\n## 4. Phase 3 — Agent-auth snapshot (interactive)\n\nThe base snapshot has the agent CLI installed but **not logged in**, and per-workspace VMs are\nephemeral — so authenticate once and bake it into a second snapshot layer. Script shape is §7b:\n\n1. Boot a sandbox from the base `snapshotId` (from state).\n2. Run the agent's login **interactively** (`--interactive --tty`); the user completes the URL/code in\n their browser. On a **headless VM this must be the device-auth flow** (e.g. `codex login --device-auth`),\n **not** plain `codex login`: the default OAuth login starts a loopback callback server on a container\n port the host browser can't reach, so it hangs. Device-auth instead prints a URL + code the user opens\n on the **host**.\n3. Verify login; **refuse to snapshot an unauthenticated VM.** Prefer the status command's **exit code**\n (most agent CLIs exit non-zero when unauthenticated). If you grep instead, agent status often goes to\n **stderr** (e.g. `codex login status` prints \"Logged in using ChatGPT\" there), so **fold stderr first**\n (`... 2>&1 | grep …`) and match the agent's **exact success line** — never `grep -qi 'logged in'`, which\n also matches \"**not** logged in\" and would commit an unauthenticated image.\n4. Re-snapshot, parse the new id, and overwrite `snapshotId` in state to the authenticated image\n (recording `authSourceSnapshotId`). Remove the auth sandbox.\n\n**You can't drive step 2 yourself** (you run commands non-interactively — no TTY). The **user** runs it in\ntheir own terminal, or via the Claude Code harness bang-prefix (`! `, with the required space after\n`!`). You scaffold/boot the sandbox and run steps 3–4, but **you cannot observe the interactive login\nfinishing** — so **ask the user to tell you when it's done** before you verify and re-snapshot.\n\nIf the agent's credentials are short-lived, warn that the snapshot may need periodic re-auth (§10).\n\nFor disposable runtimes, do **not** treat a host agent config directory (for example `~/.codex`) as the\nauth snapshot by bind-mounting or copying it wholesale. Agent homes often contain sqlite state, hook\napproval state, caches, logs, and host-specific env/config. Instead, authenticate/configure the agent\ninside the disposable runtime and snapshot/commit that runtime layer.\n\n---\n\n## 5. Credentials\n\n- **Never** commit secrets or put them in `userData`, recipe JSON, comments, docs, or the state file.\n- **Git token:** read from env (`GH_TOKEN`/`GITHUB_TOKEN`), falling back to `gh auth token`. Pass to the\n VM only via the provider's ephemeral `--env`. Inside the VM, use a `GIT_ASKPASS` helper with\n `x-access-token` (not the token in the clone URL) and `GIT_TERMINAL_PROMPT=0` so a missing token fails\n fast instead of hanging. When you write the helper from inside `bash -lc` under `set -u`, escape the\n positional arg and the token (`\\$1`, `\\$GH_TOKEN`) so they land **literally** and resolve at git-runtime\n — an unescaped `$1` aborts with \"unbound variable\", and a literal `$GH_TOKEN` keeps the real token out of\n the written file. `rm -f` the helper after the clone/fetch.\n- **Provider auth:** rely on the provider CLI's logged-in session, not checked-in keys.\n- **Agent auth:** lives in the authenticated snapshot (Phase 3) — never a file you write or commit.\n- State holds only **non-secret** wiring (snapshot ids, scope, project, port, repo url/ref).\n\n---\n\n## 6. State file\n\nA repo-local JSON file (e.g. `scripts/orca-vm/-state.json`) threads non-secret values between\nphases. Each script resolves values as **env var → state → built-in fallback**, and merges its outputs\nback. Phase 2 writes the base `snapshotId`; Phase 3 overwrites it with the authenticated snapshot;\nper-workspace `create` boots from `snapshotId`.\n\n```json\n{\n \"baseName\": \"orca-base\",\n \"snapshotId\": \"snap_authenticated_image_id\",\n \"authSourceSnapshotId\": \"snap_base_image_id\",\n \"scope\": \"\",\n \"project\": \"\",\n \"port\": 7331,\n \"repoUrl\": \"https://host/org/repo.git\",\n \"repoRef\": \"main\",\n \"projectRoot\": \"/abs/path/on/remote/repo\"\n}\n```\n\n---\n\n## 7. Script templates (provider-agnostic shapes)\n\nScaffold under `scripts/orca-vm/`. These are **shapes** — fill in the provider's real commands. All\nreserve stdout for the final JSON and log progress to stderr. Include a shared `json_value ` /\n`env_value ` reader (env → state → fallback) in each.\n\n**Where each script runs:**\n\n- **Local-side** (`create`/`suspend`/`resume`/`destroy` + the base-snapshot/auth scripts the user\n invokes) runs **on the user's desktop**, so it must run on their OS. macOS/Linux: `#!/usr/bin/env\n bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`\n or require WSL/Git-Bash and point `orca.yaml` at the right launcher.\n- **Remote-side** (commands you `exec` *inside* the Linux VM) always runs in the VM's Linux shell, so\n bash is fine there regardless of the user's OS.\n\n### 7a. Base-snapshot (`-base-snapshot.sh`) — Phase 2\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve base_name/repo_url/repo_ref/project_root/port/scope/project/timeout (env→state→fallback)\n# resolve gh token: GH_TOKEN | GITHUB_TOKEN | `gh auth token`\n# 1. provision a sandbox (timeout/vcpus/published port/snapshot retention); trap: remove on error\n# 2. remote exec (long timeout): install pkgs + gh + corepack/pnpm + agent CLI;\n# clone with GIT_ASKPASS(token); write headless main-only build config;\n# dev setup; pnpm install; build CLI; build headless electron main; smoke-check tools\n# 3. snapshot stopped sandbox; parse snapshot id (fail if unparseable)\n# 4. merge { baseName, snapshotId, projectRoot, repoUrl, repoRef, port, scope, project } into state\n# print only the state JSON to stdout\n```\n\nWorked Vercel commands for this phase are in §7f. You run this script by hand (not via `orca.yaml`),\nafter exporting the first-run inputs the state file doesn't have yet — e.g. provider scope/project, the\nrepo URL/ref, and a git token (`GH_TOKEN`); later runs read them back from state.\n\n### 7b. Auth (`-base-auth.sh`) — Phase 3\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read source snapshot from state.snapshotId (fail if absent); auth_name=\"${base_name}-auth\"\n# 1. boot sandbox from source snapshot; trap: remove on error\n# 2. INTERACTIVE/TTY remote exec: agent login — user completes URL/code. Headless VM: MUST use the\n# device-auth flow (e.g. `codex login --device-auth`) — plain OAuth login binds a loopback callback\n# port the host can't reach and hangs. User runs this themselves (you have no interactive TTY); ask\n# them to report back when it's done before continuing.\n# 3. verify login, then refuse to snapshot if not logged in. Prefer the status command's EXIT CODE (most\n# agent CLIs exit non-zero when unauthenticated) over string-matching. If you must grep, fold stderr\n# first (`status 2>&1 | grep …` — many agents print the success line there) and match the agent's exact\n# success line; never `grep -qi 'logged in'`, which also matches \"not logged in\". Codex example: §7f.\n# 4. snapshot; parse new id\n# 5. merge { snapshotId:, authSourceSnapshotId: } into state; remove auth sandbox\n# print only the state JSON to stdout\n```\n\n### 7c. Create (`-create.sh`) — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read authenticated snapshotId/scope/project/port/repo*/project_root (env→state→fallback)\n# fail clearly if snapshotId is missing (point back to Phases 2–3)\n# name = orca-${ORCA_VM_RECIPE_ID}-${ORCA_VM_INSTANCE_ID} (sanitized, length-capped)\n# 1. boot sandbox from snapshotId with a published port; capture the public URL → pairing address\n# (an externally reachable wss:// URL); trap: remove sandbox on error\n# 2. remote exec: ensure repo at desired commit; rebuild only if commit changed (cache marker)\n# 3. remote exec: start orca serve in the background and read the recipe JSON it writes (see below)\n# 4. print serve's JSON to stdout, optionally enriched with userData:\n# { schemaVersion:1, pairingCode, projectRoot, userData:{ provider, resourceId:name, snapshotId } }\n```\n\n**The exact `orca serve` invocation and its output (verified — do not improvise the flags).** Inside the\nVM, run:\n\n```bash\norca serve \\\n --port \"$PORT\" \\\n --project-root \"$ABS_REPO_PATH_ON_REMOTE\" \\\n --pairing-address \"$EXTERNAL_WSS_URL\" \\\n --recipe-json\n```\n\n**Binary name:** in a VM built from source (the Phase-2 flow), run it as `pnpm exec orca-dev serve …`\nfrom the repo root — `orca-dev` is the in-repo entrypoint and is what the §7f example uses. Plain\n`orca serve …` is the same command when the built CLI is installed on the VM's PATH. The flags/output\nare identical either way.\n\nThere is **no `--host` flag**. `--project-root` must be an absolute directory on the remote. With\n`--recipe-json` the server **stays running** and prints exactly this single object to **stdout**, then\nkeeps serving:\n\n```json\n{ \"schemaVersion\": 1, \"pairingCode\": \"\", \"projectRoot\": \"\" }\n```\n\n`pairingCode` is the pairing URL, already pointing at whatever you passed as `--pairing-address` — so set\n`--pairing-address` to the externally reachable address and **pass `pairingCode` through unchanged; never\nhand-rewrite it**. Because serve runs in the foreground and doesn't exit, redirect its stdout to a file\nand poll until that file parses as JSON (and bail if the process dies — dump its stderr log). Your\n`create` script then prints that JSON (optionally merging `userData`). Concrete pattern: §7f.\n\n### 7d. Suspend / resume / destroy — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\npayload=\"$(cat)\" # Orca passes lifecycle JSON on stdin\nresource_id=\"$(node -e 'const d=JSON.parse(process.argv[1]); process.stdout.write(d.recipeResult?.userData?.resourceId ?? \"\")' \"$payload\")\"\n[ -n \"$resource_id\" ] || { echo \"No resource id in lifecycle payload\" >&2; exit 1; }\n# suspend: provider suspend \"$resource_id\"\n# resume: provider resume \"$resource_id\"; then RE-EMIT fresh recipe JSON (pairing may change)\n# destroy: provider remove \"$resource_id\" (or set destroy: none in orca.yaml)\n```\n\n### 7e. State file — scaffold with scope/project/repo filled in and snapshot ids empty (§6).\n\n### 7f. Worked example — Vercel Sandbox (all three phases)\n\nA real, working shape (the Vercel surface is a CLI: `vercel sandbox create|exec|snapshot|remove`). Adapt\nnames; verify flags against `vercel sandbox --help` for the user's CLI version before relying on them.\nThese ground §7a (base snapshot) and §7b (auth), which are otherwise generic skeletons.\n\n**Phase 2 — base snapshot (§7a):** provision → install tools + clone + headless build → snapshot.\n\n```bash\n# provision a fresh build sandbox (retain a couple of snapshots); trap-remove on error\nvercel sandbox create --name \"$base\" --runtime node24 --timeout 30m --vcpus 4 --publish-port \"$port\" \\\n --snapshot-expiration 30d --keep-last-snapshots 2 \"${vercel_args[@]}\" >&2\n# remote build (long timeout): install pkgs+gh+pnpm+agent CLI, clone with GIT_ASKPASS (write the helper\n# with LITERAL \\$1/\\$GH_TOKEN so they resolve at git-runtime, not write-time — see §5/§7f create — then\n# `rm -f /tmp/askpass.sh`), write the headless main-only build config (drop the renderer), dev setup,\n# build CLI + headless main, smoke-check\nvercel sandbox exec \"$base\" \"${vercel_args[@]}\" --timeout 25m --env \"GH_TOKEN=$gh_token\" … -- bash -lc '…build…' >&2\n# snapshot the STOPPED sandbox and parse the id from CLI output (fail if unparseable)\nout=\"$(vercel sandbox snapshot \"$base\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nsnapshot_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# merge { baseName, snapshotId, scope, project, port, repoUrl, repoRef, projectRoot } into state; print state JSON\n```\n\n**Phase 3 — agent-auth snapshot (§7b):** boot the base, log the agent in interactively, re-snapshot.\n(`codex` below is an example — substitute the user's chosen agent's login/status verbs, e.g. `claude`.)\n\n```bash\nvercel sandbox create --name \"$auth\" --snapshot \"$snapshot_id\" --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" >&2\n# INTERACTIVE — the USER runs this in their own terminal (you have no interactive TTY) and completes the\n# URL/code on the HOST. --device-auth is MANDATORY on a headless VM: plain `codex login` binds a loopback\n# callback port the host browser can't reach and hangs. Ask the user to report back when login finishes.\nvercel sandbox exec --interactive --tty \"$auth\" \"${vercel_args[@]}\" -- bash -lc 'codex login --device-auth'\n# refuse to snapshot an unauthenticated VM — fold stderr, match codex's exact success line (§4)\nvercel sandbox exec \"$auth\" \"${vercel_args[@]}\" --timeout 30s -- bash -lc 'codex login status 2>&1' | grep -Eqi 'Logged in using ChatGPT|Logged in via device' \\\n || { echo \"agent not logged in; not snapshotting\" >&2; exit 1; }\nout=\"$(vercel sandbox snapshot \"$auth\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nnew_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# overwrite state.snapshotId = new_id, record authSourceSnapshotId = snapshot_id; remove the auth sandbox\n```\n\n**Per-workspace `create`** (the fast path):\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback: snapshot_id, scope, project, port, repo_url, repo_ref, project_root\nvercel_args=(); [ -n \"$scope\" ] && vercel_args+=(--scope \"$scope\"); [ -n \"$project\" ] && vercel_args+=(--project \"$project\")\n[ -n \"$snapshot_id\" ] || { echo \"snapshotId missing — run Phases 2–3 first\" >&2; exit 1; }\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nname=\"orca-${ORCA_VM_RECIPE_ID:-vercel-sandbox}-${ORCA_VM_INSTANCE_ID:-$(date +%s)}\" # sanitize+cap to 63 chars\n\n# Arm cleanup BEFORE create so a failing create can't leak a half-built paid sandbox.\ncleanup_on_error() { [ \"$?\" -ne 0 ] && vercel sandbox remove \"$name\" \"${vercel_args[@]}\" >/dev/null 2>&1 || true; }\ntrap cleanup_on_error EXIT\n\n# 1. boot from the authenticated snapshot, publish the serve port\ncreate_output=\"$(vercel sandbox create --name \"$name\" --snapshot \"$snapshot_id\" \\\n --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$create_output\" >&2\n# Vercel prints the published https URL; derive the external wss:// pairing address from it\npublic_url=\"$(printf '%s\\n' \"$create_output\" | sed -nE 's#.*(https://[^[:space:]]+\\.vercel\\.run).*#\\1#p' | head -1)\"\n[ -n \"$public_url\" ] || { echo \"no published URL in create output\" >&2; exit 1; }\npairing_ws=\"${public_url/https:\\/\\//wss://}\"\n\n# 2. (remote) ensure the repo is at the right commit; rebuild only if the commit changed (cache marker)\nvercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 20m \\\n --env \"GH_TOKEN=$gh_token\" --env \"ORCA_PROJECT_ROOT=$project_root\" \\\n --env \"ORCA_REPO_URL=$repo_url\" --env \"ORCA_REPO_REF=$repo_ref\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; \\\n # Re-establish git auth for the private-repo fetch (why + full rationale: §5); else it hangs on a prompt.\n # Load-bearing escaping: \\$1 and \\$GH_TOKEN must land LITERALLY and resolve at git-runtime. Test after\n # any edit here — reformatting the nested printf/node quoting silently breaks the fetch or leaks the token.\n if [ -n \"${GH_TOKEN:-}\" ]; then \\\n printf \"%s\\n\" \"#!/usr/bin/env bash\" \"case \\\"\\$1\\\" in *Username*) echo x-access-token;; *Password*) echo \\\"\\$GH_TOKEN\\\";; esac\" > /tmp/askpass.sh; \\\n chmod 700 /tmp/askpass.sh; export GIT_ASKPASS=/tmp/askpass.sh GIT_TERMINAL_PROMPT=0; fi; \\\n git fetch origin \"$ORCA_REPO_REF\"; \\\n git checkout -B \"$ORCA_REPO_REF\" FETCH_HEAD; \\\n rm -f /tmp/askpass.sh; \\\n c=\"$(git rev-parse HEAD)\"; [ -f .orca-built ] && [ \"$(cat .orca-built)\" = \"$c\" ] || { \\\n pnpm install --prefer-offline && pnpm run build:cli && \\\n node config/scripts/run-electron-vite-build.mjs --config config/electron-vite.vm-serve.config.ts && \\\n printf \"%s\" \"$c\" > .orca-built; }' >&2\n\n# 3. (remote) start orca serve in the background, writing recipe JSON to a file; poll until it parses\nrecipe_json=\"$(vercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 60s \\\n --env \"ORCA_PORT=$port\" --env \"ORCA_PROJECT_ROOT=$project_root\" --env \"ORCA_PAIRING_ADDRESS=$pairing_ws\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; rm -f /tmp/orca-recipe.json /tmp/orca-serve.log; \\\n nohup pnpm exec orca-dev serve --port \"$ORCA_PORT\" --project-root \"$ORCA_PROJECT_ROOT\" \\\n --pairing-address \"$ORCA_PAIRING_ADDRESS\" --recipe-json >/tmp/orca-recipe.json 2>/tmp/orca-serve.log /dev/null 2>&1 && { cat /tmp/orca-recipe.json; exit 0; }; \\\n kill -0 \"$pid\" 2>/dev/null || { cat /tmp/orca-serve.log >&2; exit 1; }; sleep 0.25; \\\n done; cat /tmp/orca-serve.log >&2; echo \"serve recipe JSON timed out\" >&2; exit 1')\"\n\n# 4. print serve's JSON enriched with userData (single object on stdout)\nnode -e 'const p=JSON.parse(process.argv[1]); console.log(JSON.stringify({...p, schemaVersion:1,\n userData:{...p.userData, provider:\"vercel-sandbox\", resourceId:process.argv[2], snapshotId:process.argv[3]}}))' \\\n \"$recipe_json\" \"$name\" \"$snapshot_id\"\ntrap - EXIT\n```\n\n`suspend`/`resume`/`destroy` use `vercel sandbox stop|...|remove \"$resource_id\"` reading\n`userData.resourceId` from stdin (§7d). This is the **Orca-server** connection mode (the recipe emits a\npairing URL). If the user chose **SSH** in the §1 interview, use §7g instead.\n\n### 7g. Worked example — existing SSH host (SSH connection mode)\n\nSSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them:\n\n- **`create` does NOT run `orca serve` and does NOT emit a `pairingCode`.** Orca itself connects to the\n host over its SSH relay, brings up the git + filesystem providers, and imports the repo. The script's\n only job is to make the host ready and **print SSH connection details** Orca will dial.\n- The result uses a `connection` block with `type: \"ssh\"` and a `target`, **not** the flat\n `pairingCode`/`projectRoot` shape. Exact shape (Orca rejects anything else):\n\n```json\n{\n \"schemaVersion\": 1,\n \"connection\": {\n \"type\": \"ssh\",\n \"projectRoot\": \"/abs/path/to/repo/on/host\",\n \"target\": {\n \"label\": \"my-box\",\n \"host\": \"192.0.2.10\",\n \"port\": 22,\n \"username\": \"ubuntu\",\n \"identityFile\": \"~/.ssh/id_ed25519\",\n \"jumpHost\": \"bastion.example.com\",\n \"proxyCommand\": \"cloudflared access ssh --hostname %h\",\n \"relayGracePeriodSeconds\": 0,\n \"portForwards\": []\n }\n }\n}\n```\n\n`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.\n\n**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no\n`orca serve` URL in SSH mode):\n\n- Public IP / DNS, or a Tailscale/VPN address → `host`; SSH port → `port` (usually 22).\n- Key auth → `identityFile` (add `identitiesOnly: true` if the agent has many keys).\n- Through a bastion → `jumpHost` (a `user@host` ProxyJump) **or** a full `proxyCommand` (e.g. an access\n proxy). Use one, not both.\n- A service port the workspace needs → add entries to `portForwards`.\n- `relayGracePeriodSeconds` (optional): how long Orca keeps the SSH relay alive after the workspace\n detaches before tearing it down; `0` = tear down immediately. Leave it off unless the user wants a\n reconnect grace window.\n\n**Toolchain & agent auth on a persistent (no-snapshot) host — do this ONCE, by hand, before wiring the\nrecipe** (there's no base image to bake; the host *is* the base). Run the §7f Phase-2 install steps and\nthe §7f Phase-3 ` login --device-auth` **directly over SSH on the host** (interactive, e.g.\n`ssh -t user@host ' login --device-auth'`). After that the host stays ready across workspaces.\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback (default unset optionals to \"\"): ssh_username, host,\n# ssh_port (default 22), identity_file, jump_host, proxy_command, project_root, repo_url, repo_ref\n: \"${identity_file:=}\"; : \"${jump_host:=}\"; : \"${proxy_command:=}\" # avoid set -u aborts on optionals\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nssh_target=\"${ssh_username}@${host}\"\nssh_opts=(-p \"$ssh_port\"); [ -n \"$identity_file\" ] && ssh_opts+=(-i \"$identity_file\")\n# Why: a fresh host's key isn't in known_hosts; a StrictHostKeyChecking prompt would HANG a\n# non-interactive create. Pre-add the key (or set the option) so it can't block.\nssh-keyscan -p \"$ssh_port\" \"$host\" >> \"$HOME/.ssh/known_hosts\" 2>/dev/null || true\n\n# 1. ensure the repo is present and at the right commit on the host (NO orca serve here)\nssh \"${ssh_opts[@]}\" \"$ssh_target\" \\\n \"GH_TOKEN='$gh_token' GIT_TERMINAL_PROMPT=0 bash -lc '\n set -euo pipefail\n [ -d \\\"$project_root/.git\\\" ] || git clone \\\"$repo_url\\\" \\\"$project_root\\\"\n cd \\\"$project_root\\\" && git fetch origin \\\"$repo_ref\\\" && git checkout -B \\\"$repo_ref\\\" FETCH_HEAD\n '\" >&2\n\n# 2. print the SSH connection block (NO pairingCode, NO orca serve). host/port/username tell Orca's\n# relay how to dial in; identityFile/jumpHost/proxyCommand/portForwards are emitted when set.\nnode -e 'const [host,port,user,idf,jh,pc,root]=process.argv.slice(1);\n const target={ label:\"per-workspace-host\", host, port:Number(port), username:user };\n if(idf) target.identityFile=idf; if(jh) target.jumpHost=jh; if(pc) target.proxyCommand=pc;\n // add target.portForwards=[...] here if the workspace needs forwarded service ports\n console.log(JSON.stringify({ schemaVersion:1, connection:{ type:\"ssh\", projectRoot:root, target } }))' \\\n \"$host\" \"$ssh_port\" \"$ssh_username\" \"$identity_file\" \"$jump_host\" \"$proxy_command\" \"$project_root\"\n```\n\n`suspend`/`resume`/`destroy`: on a persistent host there's usually nothing to tear down — set\n`destroy: none` and omit suspend/resume. (Orca still disconnects/reconnects its own SSH relay on\nsleep/wake/delete — that's separate from these scripts.)\n\nIf the SSH host is instead an **ephemeral/snapshot-capable VM** (your hypervisor, or a cloud VM with\nimage support), keep the §7f Phase-2/3 base-image model for provisioning, but still emit the\n`connection.type:\"ssh\"` block above instead of starting `orca serve`.\n\n### 7h. Worked example — local Docker SSH (SSH connection mode)\n\nLocal Docker can model an ephemeral SSH VM without cloud cost: build a base image with `sshd`, tools,\nrepo prerequisites, and the agent CLI; run an **interactive auth container** once; then `docker commit`\nthat container as the authenticated image used by per-workspace `create`.\n\nKey points:\n\n- Publish container SSH to a random localhost port (`-p 127.0.0.1::22`) and emit\n `connection.type:\"ssh\"` with `host:\"127.0.0.1\"`, that port, `username`, `identityFile`, and\n `identitiesOnly:true`.\n- Generate a repo-local SSH key if needed, but gitignore the private/public key files.\n- **Bake SSH host keys into the base image** (`ssh-keygen -A` at **build** time; at runtime only generate\n if absent). Ephemeral containers all present the **same** host key, so `known_hosts` on `127.0.0.1`\n doesn't churn as the published port rotates across workspaces (otherwise every container's freshly\n generated key collides on `localhost` and trips host-key-changed warnings).\n- The auth image is the Docker equivalent of Phase 3: the **user** runs the agent login **inside** the\n container (you can't drive it — you have no interactive TTY), configures proxy env/config, approves\n hooks, and you commit once they report it's done. On a headless container use the **device-auth** flow\n (§4). Verify login before committing — exit code, or fold stderr and match the exact success line (§4).\n- Do not bind-mount or copy the host's full agent home into the image. Let each container have writable\n agent state; only the committed auth image should carry reusable authenticated state.\n- If committing from an interactive shell, force the runtime entrypoint back to `sshd`:\n `docker commit --change='ENTRYPOINT [\"/usr/local/bin/orca-docker-ssh-entrypoint\"]' …`.\n- `destroy` should read `recipeResult.userData.resourceId` and run `docker rm -f \"$resource_id\"`.\n\nValidation before wiring/live use:\n\n```bash\ndocker image inspect \"$auth_image\" --format '{{json .Config.Entrypoint}}'\ndocker run -d --name \"$name\" -p 127.0.0.1::22 -e \"ORCA_SSH_PUBLIC_KEY=$pubkey\" \"$auth_image\"\ndocker ps -a --filter \"name=$name\"\ndocker logs \"$name\"\nssh -i \"$key\" -p \"$port\" -o IdentitiesOnly=yes user@127.0.0.1 'codex --version'\n```\n\nIf the container exits immediately, inspect logs before the cleanup trap removes it; a committed\ninteractive image with `ENTRYPOINT [\"bash\"]` is a common cause.\n\nAlso confirm the **host key is stable** across containers: the SSH `ssh -i … 127.0.0.1` dial should not\ntrigger a host-key-changed warning when a second container reuses the port. If it does, the host keys\nweren't baked into the base image (see the `ssh-keygen -A` point above).\n\n### 7i. Windows local-side scripts\n\nThe local-side scripts run on the user's desktop. On **Windows**, a bare `.sh` won't execute. Either\nrequire WSL/Git-Bash (and point `orca.yaml` at e.g. `bash ./scripts/orca-vm/.sh` via a `.cmd`\nlauncher), or scaffold PowerShell equivalents. Minimal PowerShell shape:\n\n```powershell\n#requires -Version 5\n$ErrorActionPreference = 'Stop'\n# resolve env→state→fallback; run the provider CLI / ssh the same way;\n# capture provider output; build the result object for the chosen mode and write ONE line of JSON to stdout.\n# Orca-server mode: @{ schemaVersion=1; pairingCode=$pairingCode; projectRoot=$projectRoot; userData=@{...} }\n# SSH mode: @{ schemaVersion=1; connection=@{ type=\"ssh\"; projectRoot=$projectRoot;\n# target=@{ label=$label; host=$host; port=$port; username=$user } } } (see §7g/§7h)\n($result | ConvertTo-Json -Compress -Depth 6)\n# progress/errors → Write-Error / the error stream, never stdout.\n```\n\nThe remote-side commands you run *inside* the Linux VM stay bash regardless of the desktop OS.\n\n---\n\n## 8. Per-workspace recipe contract (the fast path)\n\nOnce the authenticated snapshot exists, this runs on every workspace create. Define recipes in\n`orca.yaml`:\n\n```yaml\nenvironmentRecipes:\n - id: cloud-sandbox\n name: Cloud Sandbox\n create: ./scripts/orca-vm/cloud-sandbox-create.sh\n suspend: ./scripts/orca-vm/cloud-sandbox-suspend.sh\n resume: ./scripts/orca-vm/cloud-sandbox-resume.sh\n destroy: ./scripts/orca-vm/cloud-sandbox-destroy.sh\n```\n\n`create` runs **locally from the repo root** and prints **one** JSON object to stdout. Its shape depends\non the connection mode chosen in §1:\n\n**Orca-server mode** — boot the env, start `orca serve` in it, and print serve's result:\n\n```json\n{\n \"schemaVersion\": 1,\n \"pairingCode\": \"orca-pairing-code-or-url\",\n \"projectRoot\": \"/absolute/path/to/repo/on/remote\",\n \"userData\": { \"provider\": \"example\", \"resourceId\": \"provider-resource-id\" }\n}\n```\n\nHere `pairingCode` (from `orca serve --recipe-json`) and `projectRoot` are required; `schemaVersion` (`1`)\nand `userData` are optional.\n\n**SSH mode** — do **not** run `orca serve`; print the `connection.type:\"ssh\"` block instead (full shape +\nworked script in §7g). `pairingCode` is **not** used in SSH mode.\n\nLifecycle hooks (all run locally):\n\n- `create`: required. Prints recipe result JSON.\n- `suspend`: optional. Sleep; reads lifecycle payload on stdin.\n- `resume`: optional. Wake; reads payload on stdin and **prints fresh recipe JSON** (pairing may change).\n- `destroy`: optional unless `destroy: none`. Delete/cleanup; reads payload on stdin.\n\nStart Orca remotely with `orca serve --port \"$PORT\" --project-root \"$ABS_ROOT\" --pairing-address\n\"$EXTERNAL_WSS_URL\" --recipe-json` (exact flags + output in §7c). Set `--pairing-address` to the\nexternally reachable address so the emitted `pairingCode` is reachable; tunneling/port mapping is the\nscript's job.\n\nBackward compatibility: `command`→`create`, `cleanup`→`destroy`, `cleanup: none`→`destroy: none`.\nPrefer the lifecycle names.\n\n---\n\n## 9. Doctor and validation\n\nValidate in two stages — the cheap dry run first, then the live self-test.\n\n### Dry run (free, non-destructive) — always do this first\n\n`orca vm recipe doctor --repo-path --json` validates **static wiring only** — it does\n**not** boot anything. It checks: local-host execution (v1), repo path, recipe id exists,\ncreate/destroy/suspend/resume command paths resolve, suspend/resume are paired, and each script is\nexecutable (POSIX exec bit; skipped on Windows). Fix every failure here before spending any cloud money.\n\n### Live self-test (`--provision`) — diagnose and iterate yourself\n\n`orca vm recipe doctor --repo-path --provision --json` actually runs the recipe end\nto end: it executes `create`, validates the returned recipe JSON, then runs `destroy` to **tear the\nenvironment back down** (so the test leaves nothing running, as long as `destroy` works). It spends real\ncloud money, so get the user's OK **once** before starting — that one approval covers the whole loop\nbelow; do not re-ask before each run.\n\nOn failure, the JSON result includes a `provisionTranscript` with the **complete** captured output of\neach stage so you can self-diagnose without asking the user to relay logs:\n\n```json\n{\n \"ok\": false,\n \"checks\": [ { \"id\": \"recipe.provision\", \"status\": \"fail\", \"message\": \"…\" } ],\n \"provisionTranscript\": {\n \"provision\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\", \"parseError\": \"…\" },\n \"destroy\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\" }\n }\n}\n```\n\n**Run it as a loop:** read `provisionTranscript.provision.stderr` / `.stdout` / `.parseError` (and\n`destroy.*`), fix the script, and re-run `--provision` until `ok` is `true` — iterating on your own\nrather than waiting for the user to paste errors. Common reads: a non-empty `stderr` with `exitCode 0`\nplus a `parseError` means `create` ran but printed something other than the single recipe-result JSON on\nstdout (often a stray `echo` — route it to stderr, see §10); a non-zero `exitCode` is a provider/script\nfailure described in `stderr`. Each stream is redacted and capped (head+tail) — large logs keep both the\nsetup context and the failure.\n\nThe self-test cannot see provider-side truth beyond what the scripts print, so still confirm: state has a\npopulated **authenticated** `snapshotId` (Phases 2–3 done), and `destroy` is implemented/tested (or\nexplicitly `none` — in which case the self-test won't tear down, so clean up manually).\n\nFor SSH recipes, also smoke-test the exact emitted target before declaring success: dial the host/port\nwith the identity/proxy settings, run `pwd`, verify the repo path, check the agent binary, and confirm\n`destroy` removes the provider resource/container. For Docker, inspect the auth image entrypoint and do a\nstartup-only `docker run` before the full clone/install path.\n\n---\n\n## 10. Failure modes\n\n- **Build exceeds plan timeout (e.g. Hobby 45m).** Use enough vCPUs and a timeout covering the build;\n else split work or use a higher plan. The cap also limits per-workspace runtime — surface it.\n- **Build exceeds plan RAM.** Build the **headless main only** (drop the renderer) — the biggest fitter.\n- **Private-repo clone hangs/fails.** Wrong/missing token. Use `GIT_ASKPASS` + `GIT_TERMINAL_PROMPT=0`\n so it fails fast instead of prompting.\n- **`GIT_ASKPASS` helper aborts the clone with \"`$1: unbound variable`\".** The `printf`/heredoc that writes\n the helper inside `bash -lc` under `set -u` expanded `$1`/`$GH_TOKEN` at **write** time. Escape them\n (`\\$1`, `\\$GH_TOKEN`) so they land literally and resolve at git-runtime; this also keeps the real token\n out of the file. `rm -f` the helper afterward (§5, §7f).\n- **Agent verified as \"not logged in\" despite a good login.** `codex login status` (and similar) print\n \"Logged in …\" to **stderr**; an stdout-only `grep` misses it. Prefer the status **exit code**; if you\n grep, fold stderr first (`status 2>&1 | grep …`) and match the exact success line — not `grep -qi\n 'logged in'`, which also matches \"not logged in\".\n- **Headless agent login hangs.** Plain OAuth `login` starts a loopback callback server on a VM/container\n port the host browser can't reach. Use the **device-auth** flow (`login --device-auth`) — it prints a\n URL + code the user opens on the host.\n- **`known_hosts` host-key churn on local Docker.** Each ephemeral container regenerating its SSH host key\n collides on `127.0.0.1` as the published port rotates. Bake host keys into the base image at build time\n (`ssh-keygen -A`; runtime generates only if absent) so all containers share one stable key (§7h).\n- **Snapshot expired/evicted.** If `create` hits an unknown snapshot id, rerun Phases 2–3 and update\n `snapshotId`.\n- **Agent auth didn't persist.** Confirm `snapshotId` points at the **authenticated** snapshot; re-run\n Phase 3. Warn that short-lived tokens may need periodic re-auth.\n- **Agent auth copied from the host breaks.** Do not bind-mount/copy a full host agent home; sqlite\n files can be unwritable or host-specific, hooks may need approval again, and config may reference\n local-only env vars. Authenticate inside the runtime and snapshot/commit that layer.\n- **Docker auth image exits immediately.** Inspect `docker image inspect … .Config.Entrypoint` and\n `docker logs`. If the image was committed from an interactive shell, reset the entrypoint to the SSH\n entrypoint during `docker commit`.\n- **Leaked paid resource.** Every long script must trap errors and remove the sandbox it created.\n- **`create` emits non-JSON on stdout.** A stray `echo` corrupts the result — stdout is for the final\n JSON only; everything else to stderr. The `--provision` self-test surfaces this as `exitCode 0` + a\n `parseError` with the offending stdout in `provisionTranscript` (§9).\n\n---\n\n## 11. Boundaries\n\n- Don't create accounts, choose plans/regions, or invent scope/project/org/image/billing ids.\n- Don't invent or store credentials; no secrets in `userData`, state, comments, docs, or commits.\n- Don't run paid/long phases (base snapshot, auth, live test) without an explicit OK.\n- Don't hide provider errors behind generic messages — preserve actionable stderr.\n- Don't make Orca own provider lifecycle beyond invoking the configured scripts.\n- Don't commit or create an Orca workspace unless asked.\n" // oxfmt-ignore -const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create Orca runtime state with `orca orchestration task-create` and `orca orchestration dispatch --inject` or `orca orchestration run`.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Ownership\n\nOrchestration messages and tasks are runtime-global. Lifecycle authority comes from the payload `taskId` + `dispatchId` of the active dispatch, verified against the dispatched pane. Terminal handles are routing metadata — a pane can receive a new handle after restart — so never accept or reject lifecycle provenance by comparing handles. Send `worker_done` and `heartbeat` from the worker's own terminal; the runtime ignores them when sent from a different pane.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --to --subject [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--unread|--peek|--all] [--types ] [--inject] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask --to --question [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- `check` and `check --unread` return unread matches and mark them read. Use `--peek` for unread matches without consuming them; use `--all` for read and unread history without consuming anything. If an older CLI rejects `--peek` as an unknown flag, use `--all` and filter unread rows yourself.\n- Message **one** live agent handle per worker. Use `startupTerminal.handle` from the create response when present; if it is missing or later returns `terminal_handle_stale`, re-resolve with `orca terminal list --worktree ... --json` and continue with the replacement only.\n- `orca orchestration check --unread --inject --json` renders unread mail for the agent terminal that runs it; it does not remotely wake another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,decision_gate --timeout-ms ` instead of sleep/poll loops. Reply to `decision_gate` messages with `orca orchestration reply --id --body --json`, then keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it waits for the reply and returns the answer directly.\n- `check --wait` returns one message at a time. If N workers may finish together, loop N times and dispatch newly ready tasks after each completion.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `decision_gate`, and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` must target the concrete coordinator handle from the live preamble. It is completion authority for one dispatch; group fanout would create false lifecycle mail in unrelated terminals.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also dispatch-scoped. Send it only to the concrete coordinator handle with both `taskId` and `dispatchId`; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA task is the work item, a dispatch assigns it to a terminal, and a gate blocks progress until a coordinator or user decision is recorded.\n\n```bash\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Gates And Coordinator\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\norca orchestration run --spec [--from ] [--poll-interval-ms ] [--max-concurrent ] [--worktree ] [--json]\norca orchestration run-stop [--json]\n```\n\n`run` returns immediately with a run ID. Query progress with `task-list`. Use `ask` for worker-to-coordinator questions; it creates a `decision_gate` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears runtime-global orchestration state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, create the worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Use a new worktree only when explicitly requested or when independent isolated checkout state is intended. For supervised new-worktree workers, decide the desired Orca lineage before creation: use child lineage only when the work is conceptually stacked under or dependent on the active worktree, and use `--no-parent` for independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks. Decide the Git base separately from lineage: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\n```bash\norca worktree create --name --agent codex --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from startupTerminal.handle in the create response.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `startupTerminal.handle` from `worktree create`. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**Agent-first (required for ordinary agent workers):** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker created with `orca terminal create --worktree active` may appear as a peer terminal/agent under the same worktree in the sidebar even though it is a child dispatch in Orca orchestration state. A visible parent/child worktree relationship requires creating a child worktree, but do that only when the task can safely run from an isolated checkout and does not need uncommitted artifacts from the current working tree.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal, even on failure:\n `orca orchestration send --to --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"filesModified\":[\"path/a\"],\"reportPath\":\"\"}' --json`\n- After sending `worker_done`, end your turn and idle at the agent prompt. Do not poll or keep calling `orca orchestration check`; the coordinator re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --to --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n- Prefer inter-worktree workers only for independent work that does not need current uncommitted state. When same-worktree work is required, create fresh terminals in that worktree and keep edit ownership clear.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,decision_gate --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, inspect `task-list`/`dispatch-show` if inheriting state, then choose either a manual loop (`task-create` -> worker -> `dispatch --inject` -> `check --wait`) or `orchestration run`.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" +const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create Orca runtime state with `orca orchestration task-create` and `orca orchestration dispatch --inject` or `orca orchestration run`.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Ownership\n\nOrchestration messages and tasks are runtime-global. Lifecycle authority comes from the payload `taskId` + `dispatchId` of the active dispatch, verified against the dispatched pane. Terminal handles are routing metadata — a pane can receive a new handle after restart — so never accept or reject lifecycle provenance by comparing handles. Send `worker_done` and `heartbeat` from the worker's own terminal; the runtime ignores them when sent from a different pane.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --to --subject [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--unread|--peek|--all] [--types ] [--inject] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask --to --question [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- `check` and `check --unread` return unread matches and mark them read. Use `--peek` for unread matches without consuming them; use `--all` for read and unread history without consuming anything. If an older CLI rejects `--peek` as an unknown flag, use `--all` and filter unread rows yourself.\n- Message **one** live agent handle per worker. Use `startupTerminal.handle` from the create response when present; if it is missing or later returns `terminal_handle_stale`, re-resolve with `orca terminal list --worktree ... --json` and continue with the replacement only.\n- `orca orchestration check --unread --inject --json` renders unread mail for the agent terminal that runs it; it does not remotely wake another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,decision_gate --timeout-ms ` instead of sleep/poll loops. Reply to `decision_gate` messages with `orca orchestration reply --id --body --json`, then keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it waits for the reply and returns the answer directly.\n- `check --wait` returns one message at a time. If N workers may finish together, loop N times and dispatch newly ready tasks after each completion.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `decision_gate`, and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` must target the concrete coordinator handle from the live preamble. It is completion authority for one dispatch; group fanout would create false lifecycle mail in unrelated terminals.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also dispatch-scoped. Send it only to the concrete coordinator handle with both `taskId` and `dispatchId`; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA task is the work item, a dispatch assigns it to a terminal, and a gate blocks progress until a coordinator or user decision is recorded.\n\n```bash\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Gates And Coordinator\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\norca orchestration run --spec [--from ] [--poll-interval-ms ] [--max-concurrent ] [--worktree ] [--json]\norca orchestration run-stop [--json]\n```\n\n`run` returns immediately with a run ID. Query progress with `task-list`. Use `ask` for worker-to-coordinator questions; it creates a `decision_gate` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears runtime-global orchestration state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\n```bash\norca worktree create --name --agent codex --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from startupTerminal.handle in the create response.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `startupTerminal.handle` from `worktree create`. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal, even on failure:\n `orca orchestration send --to --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"filesModified\":[\"path/a\"],\"reportPath\":\"\"}' --json`\n- After sending `worker_done`, end your turn and idle at the agent prompt. Do not poll or keep calling `orca orchestration check`; the coordinator re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --to --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,decision_gate --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, inspect `task-list`/`dispatch-show` if inheriting state, then choose either a manual loop (`task-create` -> worker -> `dispatch --inject` -> `check --wait`) or `orchestration run`.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" // Why: no current guide has bundled reference documents, so --full is byte-identical for now. // oxfmt-ignore From 1def694e80ef6822428128557784e326fb671b0b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 19 Jul 2026 17:31:34 -0700 Subject: [PATCH 08/45] Add native chat skill picker with host-aware discovery (#9480) * Add native chat skill and command picker with host-aware discovery Adds a unified, keyboard-first skill and command picker to native chat that: - Uses agent-native invocation syntax (slash for Claude/OpenClaude/Grok, dollar for Codex) - Discovers skills only on the pane's execution host (local, WSL, SSH-unavailable, or runtime) - Groups or separates commands and skills per agent configuration - Deduplicates by canonical path but preserves visibility through all contributing roots - Handles IME composition, loading states, and errors without claiming PTY-level control - Records picker telemetry (open, item accepted, send classification, discovery outcomes) - Extends shared agent profiles to define per-agent skill grammars and source ownership * Remove obsolete reference and design documentation Clean up stale design specs, implementation plans, and investigation notes from docs/reference/. These documents predate the current implementation and are no longer actively maintained or referenced by the codebase. * Extract shared skill discovery utilities and add skill invocation envelo - Move skill comparison and source classification to shared module for native/WSL reuse - Extract display text sanitization to prevent control/zero-width character spoofing - Add native-chat command envelope parser and surfacer for skill invocations - Extend discovery timeout backstop to account for WSL metadata read sequence * Localize skill picker UI for Spanish, Japanese, Korean, Chinese Translate skill picker UI strings including commands, skills, loading states, error messages, and scope labels for the new skill picker feature across four language locales. * Fix skill picker bugs and improve code robustness - Fix i18n plural handling: rename `count` to `sourceCount` to prevent unintended plural-key resolution in localized strings - Fix skill discovery array mutations: copy `root.providers` to prevent bugs during dedup merge - Fix image attachments being silently dropped when message text starts with /skill or agent prefix - Extract `quoteBashString` utility for WSL command code reuse across builders - Add line-separator safety characters (0x2028/0x2029) to skill display filter - Remove stale doc reference links and clarify inline comments * Add reference docs for git compatibility and headless Linux server setup Track previously untracked operational guides in `docs/reference/` that explain Git binary compatibility requirements across host types and how to run `orca serve` on headless Linux. Update AGENTS.md and README.md to link to these references. --- .gitignore | 13 +- ...ca-mobile-manual-network-address-design.md | 193 ---- docs/reference/README.md | 17 - docs/reference/agent-hook-stdin-lifecycle.md | 204 ---- .../agent-session-resume-cli-evidence.md | 341 ------ .../browser-local-https-certificate-trust.md | 926 ----------------- docs/reference/direct-url-or-file-entry.md | 146 --- .../feature-discovery-interaction-tracking.md | 68 -- .../feature-education-retention-analytics.md | 182 ---- docs/reference/feature-education-state.md | 75 -- .../keyboard-layout-shortcut-dispatch.md | 66 -- .../new-user-parallel-work-telemetry.md | 245 ----- .../opencode-commit-message-stdin.md | 105 -- ...6-27-orca-mobile-manual-network-address.md | 972 ------------------ ...2026-07-10-ssh-repo-host-reconciliation.md | 131 --- docs/reference/project-ordering-mode.md | 108 -- .../reliability-gates-implementation-plan.md | 783 -------------- .../reliability-pain-points-2026-06-30.md | 712 ------------- docs/reference/ssh-gssapi-kerberos.md | 39 - .../ssh-typing-latency-under-relay-load.md | 115 --- docs/reference/telemetry-availability.md | 393 ------- .../terminal-cold-park-reveal-cost.md | 132 --- .../reference/terminal-hidden-view-parking.md | 119 --- .../reference/terminal-model-view-contract.md | 218 ---- docs/reference/terminal-query-authority.md | 347 ------- docs/reference/terminal-session-lifecycle.md | 580 ----------- .../terminal-side-effect-authority.md | 252 ----- ...erminal-switch-typing-lag-investigation.md | 362 ------- .../headless-emulator-fidelity.fuzz.test.ts | 4 +- src/main/daemon/headless-emulator.ts | 2 +- .../terminal-view-attribute-responder.ts | 4 +- src/main/ipc/pty-hidden-delivery-gate.ts | 1 - src/main/ipc/skills.test.ts | 61 +- src/main/ipc/skills.ts | 59 +- src/main/runtime/orca-runtime.ts | 10 + src/main/runtime/rpc/methods/skills.test.ts | 68 ++ src/main/runtime/rpc/methods/skills.ts | 27 +- .../runtime/terminal-model-query-authority.ts | 2 +- .../runtime/terminal-query-responder.test.ts | 2 +- .../runtime/terminal-view-attribute-store.ts | 4 +- .../claude-plugin-skill-sources-wsl.test.ts | 38 + .../skills/claude-plugin-skill-sources-wsl.ts | 100 ++ .../claude-plugin-skill-sources.test.ts | 122 +++ .../skills/claude-plugin-skill-sources.ts | 219 ++++ src/main/skills/discovery.test.ts | 84 ++ src/main/skills/discovery.ts | 108 +- src/main/skills/skill-discovery-sources.ts | 147 ++- src/main/skills/skill-discovery-target.ts | 70 ++ .../skill-discovery-wsl-plugins.test.ts | 83 ++ src/main/skills/skill-discovery-wsl.test.ts | 96 ++ src/main/skills/skill-discovery-wsl.ts | 212 ++++ .../skill-freshness-inventory-limits.test.ts | 3 +- src/main/telemetry/validator.test.ts | 2 +- src/main/wsl-bash-command.ts | 5 +- src/preload/api-types.ts | 2 +- .../NativeChatAutocompleteMenus.test.tsx | 126 +++ .../NativeChatAutocompleteMenus.tsx | 325 ++++-- .../native-chat/NativeChatComposer.test.tsx | 15 +- .../native-chat/NativeChatComposer.tsx | 207 ++-- .../native-chat/NativeChatComposerField.tsx | 61 +- .../native-chat/native-chat-agent-commands.ts | 8 - .../native-chat-composer-state.test.ts | 214 +++- .../native-chat/native-chat-composer-state.ts | 291 +++--- .../native-chat/native-chat-picker-items.ts | 248 +++++ .../native-chat-skill-discovery-context.ts | 156 +++ .../use-native-chat-composer-keydown.test.tsx | 99 ++ .../use-native-chat-composer-keydown.ts | 110 +- .../use-native-chat-live-session.ts | 19 +- ...use-native-chat-picker-command-dispatch.ts | 97 ++ .../use-native-chat-picker-state.ts | 198 ++++ .../use-native-chat-skills.react.test.tsx | 144 +++ .../use-native-chat-skills.test.ts | 90 +- .../native-chat/use-native-chat-skills.ts | 278 ++++- .../use-native-chat-typed-insertion.ts | 50 + .../use-onboarding-flow-persistence.ts | 2 +- .../agent-task-complete-policy.ts | 3 +- .../parked-terminal-byte-watcher.ts | 3 +- .../terminal-pane/pty-delivery-interest.ts | 2 +- .../terminal-parked-tab-watchers.ts | 1 - .../terminal-side-effect-facts-handler.ts | 4 +- .../terminal-view-attributes-publisher.ts | 4 +- .../use-terminal-tab-cold-parking.ts | 2 +- src/renderer/src/i18n/locales/en.json | 15 + src/renderer/src/i18n/locales/es.json | 17 +- src/renderer/src/i18n/locales/ja.json | 17 +- src/renderer/src/i18n/locales/ko.json | 17 +- src/renderer/src/i18n/locales/zh.json | 17 +- src/renderer/src/lib/native-chat-telemetry.ts | 42 + src/renderer/src/web/web-preload-api.test.ts | 29 + src/renderer/src/web/web-preload-api.ts | 6 +- src/shared/command-code-output-status.ts | 3 +- src/shared/feature-interaction-catalog.ts | 4 +- src/shared/native-chat-agent-profiles.test.ts | 30 + src/shared/native-chat-agent-profiles.ts | 45 + .../native-chat-command-envelope.test.ts | 96 ++ src/shared/native-chat-command-envelope.ts | 63 ++ src/shared/skill-display-text.ts | 26 + src/shared/skills.ts | 60 ++ src/shared/telemetry-events.ts | 20 + src/shared/terminal-bell-detector.ts | 4 +- src/shared/terminal-output-side-effects.ts | 4 +- src/shared/terminal-side-effect-facts.ts | 1 - src/shared/terminal-view-attributes.ts | 4 +- src/shared/types.ts | 9 +- 104 files changed, 4134 insertions(+), 8531 deletions(-) delete mode 100644 docs/reference/2026-06-27-orca-mobile-manual-network-address-design.md delete mode 100644 docs/reference/README.md delete mode 100644 docs/reference/agent-hook-stdin-lifecycle.md delete mode 100644 docs/reference/agent-session-resume-cli-evidence.md delete mode 100644 docs/reference/browser-local-https-certificate-trust.md delete mode 100644 docs/reference/direct-url-or-file-entry.md delete mode 100644 docs/reference/feature-discovery-interaction-tracking.md delete mode 100644 docs/reference/feature-education-retention-analytics.md delete mode 100644 docs/reference/feature-education-state.md delete mode 100644 docs/reference/keyboard-layout-shortcut-dispatch.md delete mode 100644 docs/reference/new-user-parallel-work-telemetry.md delete mode 100644 docs/reference/opencode-commit-message-stdin.md delete mode 100644 docs/reference/plans/2026-06-27-orca-mobile-manual-network-address.md delete mode 100644 docs/reference/plans/2026-07-10-ssh-repo-host-reconciliation.md delete mode 100644 docs/reference/project-ordering-mode.md delete mode 100644 docs/reference/reliability-gates-implementation-plan.md delete mode 100644 docs/reference/reliability-pain-points-2026-06-30.md delete mode 100644 docs/reference/ssh-gssapi-kerberos.md delete mode 100644 docs/reference/ssh-typing-latency-under-relay-load.md delete mode 100644 docs/reference/telemetry-availability.md delete mode 100644 docs/reference/terminal-cold-park-reveal-cost.md delete mode 100644 docs/reference/terminal-hidden-view-parking.md delete mode 100644 docs/reference/terminal-model-view-contract.md delete mode 100644 docs/reference/terminal-query-authority.md delete mode 100644 docs/reference/terminal-session-lifecycle.md delete mode 100644 docs/reference/terminal-side-effect-authority.md delete mode 100644 docs/reference/terminal-switch-typing-lag-investigation.md create mode 100644 src/main/runtime/rpc/methods/skills.test.ts create mode 100644 src/main/skills/claude-plugin-skill-sources-wsl.test.ts create mode 100644 src/main/skills/claude-plugin-skill-sources-wsl.ts create mode 100644 src/main/skills/claude-plugin-skill-sources.test.ts create mode 100644 src/main/skills/claude-plugin-skill-sources.ts create mode 100644 src/main/skills/skill-discovery-target.ts create mode 100644 src/main/skills/skill-discovery-wsl-plugins.test.ts create mode 100644 src/main/skills/skill-discovery-wsl.test.ts create mode 100644 src/main/skills/skill-discovery-wsl.ts create mode 100644 src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx delete mode 100644 src/renderer/src/components/native-chat/native-chat-agent-commands.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-picker-items.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-picker-state.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx create mode 100644 src/renderer/src/components/native-chat/use-native-chat-typed-insertion.ts create mode 100644 src/shared/native-chat-agent-profiles.test.ts create mode 100644 src/shared/native-chat-agent-profiles.ts create mode 100644 src/shared/native-chat-command-envelope.test.ts create mode 100644 src/shared/native-chat-command-envelope.ts create mode 100644 src/shared/skill-display-text.ts diff --git a/.gitignore b/.gitignore index 1874a068079..1b3f81e72c0 100644 --- a/.gitignore +++ b/.gitignore @@ -77,20 +77,21 @@ design-docs/ # Machine-local agent hook endpoint files may contain auth tokens. /agent-hooks/ -# Local-only design/planning docs (not checked in). -# Durable docs must live in one of the allow-listed locations below. +# Local-only design/planning docs (not checked in), including most of docs/reference/. +# Durable docs that should be tracked must live in one of the allow-listed +# locations below (assets, readme, STYLEGUIDE, mobile terminal shortcut bar, +# and the tracked reference docs linked from AGENTS.md / README.md). docs/** !docs/ !docs/assets/ !docs/assets/** !docs/readme/ !docs/readme/** -!docs/reference/ -!docs/reference/** -# Keep generated React perf scan ledgers local; durable docs still use docs/reference. -docs/reference/react-performance-audit.md !docs/STYLEGUIDE.md !docs/mobile-terminal-shortcut-bar.md +!docs/reference/ +!docs/reference/git-compatibility.md +!docs/reference/headless-linux-server.md # Stably CLI (only docs/ are tracked) .stably/* diff --git a/docs/reference/2026-06-27-orca-mobile-manual-network-address-design.md b/docs/reference/2026-06-27-orca-mobile-manual-network-address-design.md deleted file mode 100644 index fdcc7987af0..00000000000 --- a/docs/reference/2026-06-27-orca-mobile-manual-network-address-design.md +++ /dev/null @@ -1,193 +0,0 @@ -# Design: Manual network address entry for Orca desktop mobile pairing - -**Date:** 2026-06-27 -**Scope:** Desktop renderer (Settings → Mobile → Network Interface section) -**Status:** Draft, awaiting user review - -## Problem - -`src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx` lets the user pick the network address that gets baked into the mobile-pairing QR code. Today the only options come from `networkInterfaces`, which is the list returned by the main process enumerating OS network interfaces (`en0`, `tailscale0`, etc.). If a user wants a tailnet address that the OS hasn't surfaced yet — a Tailscale MagicDNS hostname, a ZeroTier-assigned address not yet visible to the OS, or a manual LAN IP — they have no way to type one in. The QR ends up pointing at an interface the phone cannot actually reach. - -## Decision summary - -Replace the inner `Select` of `MobileNetworkInterfaceSection` with a `Popover + Command` ("combobox") pattern modeled on the existing `AgentCombobox`. The popover contains a single `CommandInput` that filters the auto-discovered interfaces above and renders a special "Use …" entry at the bottom of the list whenever the input is a valid IPv4 address or Tailscale MagicDNS hostname. Picking that entry selects a custom address; the trigger shows `
(custom)`. Custom addresses are session-scoped (cleared when the settings pane closes). - -## Constraints (from `CONTRIBUTING.md` + `AGENTS.md`) - -- Cross-platform: code paths must not assume a single platform; the manual entry path itself is platform-neutral. -- No `helpers`/`utils`/`misc` file names; use concrete names. -- No `eslint-disable max-lines`; split files instead. -- Prefer `.ts` over `.d.ts`. -- UI work follows `docs/STYLEGUIDE.md` and uses shadcn primitives from `src/renderer/src/components/ui/`. -- The renderer ↔ shared boundary is `src/shared/`; pure logic that may be reused outside the renderer goes there. -- Comments explain *why*, briefly. - -## Files - -| Path | Change | -| --- | --- | -| `src/shared/network/manual-address.ts` | **New.** Pure `parseManualNetworkAddress(input)` returning a discriminated union. | -| `src/shared/network/manual-address.test.ts` | **New.** Vitest cases for IPv4 and MagicDNS hostname validation. | -| `src/renderer/src/components/settings/mobile-network-interface-selection.ts` | Replace `mergeForSelect` with `buildComboboxEntries(interfaces, customAddress)` returning the entry list the UI maps over. | -| `src/renderer/src/components/settings/mobile-network-interface-selection.test.ts` | Replace `mergeForSelect` tests with `buildComboboxEntries` tests. | -| `src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx` | Swap `Select` for `Popover + Command`; add `open`/`query`/`customAddress` state. | -| `src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx` | **New.** Render tests via `@testing-library/react`. | - -No changes to: `mobile/app/pair-scan.tsx`, `MobilePairingQrSection.tsx`, `use-mobile-install-qr.ts`, or any main-process code. The QR generation pipeline already consumes `selectedAddress: string`, which is all the new flow produces. - -## Module 1: `parseManualNetworkAddress` - -```ts -// src/shared/network/manual-address.ts -export type ParseManualAddressResult = - | { ok: true; address: string } - | { ok: false; error: string } - -export function parseManualNetworkAddress(input: string): ParseManualAddressResult -``` - -**Rules** (in order): - -1. `input.trim()` must be non-empty. Otherwise `{ ok: false, error: 'Enter an IPv4 address or Tailscale MagicDNS hostname' }`. -2. Reject any input containing whitespace anywhere; reject any input longer than 253 chars (DNS hostname cap). -3. Accept if it matches the IPv4 grammar (four dotted octets, each 0–255). No leading zeros except for `0` itself. -4. Accept if it matches the Tailscale MagicDNS hostname grammar: - - Regex (case-insensitive): `/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*\.ts\.net$/` -5. Otherwise return the same error as (1). - -Pure function, no React, no I/O. Unit-testable in isolation. - -## Module 2: `buildComboboxEntries` - -```ts -// src/renderer/src/components/settings/mobile-network-interface-selection.ts -export type MobileNetworkInterface = { name: string; address: string } - -export type ComboboxEntry = - | { kind: 'interface'; iface: MobileNetworkInterface } - | { kind: 'use-query'; address: string } // only emitted when current query is valid - -export function buildComboboxEntries( - interfaces: readonly MobileNetworkInterface[], - query: string -): readonly ComboboxEntry[] -``` - -**Behavior:** - -- Trim `query`; if empty, return only `kind: 'interface'` entries from `interfaces` (no `use-query`). -- If `query` is non-empty, behavior branches on `parseManualNetworkAddress(query)`: - - **Valid query:** skip substring filtering and keep every interface visible (so the user can pivot to an existing interface mid-typing). Emit each as `kind: 'interface'`. - - **Invalid query:** filter `interfaces` by case-insensitive substring match on `iface.address` OR `iface.name`. Emit each as `kind: 'interface'`. If the filter yields zero matches, fall back to the full `interfaces` list (so the user always sees the available options, never an empty list mid-typing). -- After the interface entries, if the query parsed as valid AND no emitted interface has an `address` exactly equal to `parsed.address`, append `{ kind: 'use-query', address: parsed.address }`. (Suppression happens regardless of whether filtering ran, because valid queries skip filtering entirely — the check is against the visible interface list, which for valid queries is the full list.) -- Order: interface entries first (stable, in input order — either filtered or the full list per the branch above), then the optional `use-query`. -- The `selectRefreshedNetworkAddress` function is **kept** unchanged — it's still the rule that decides the *initial* `selectedAddress` when no manual entry exists. The UI calls it on mount and on Refresh; afterwards, the combobox owns the selection. - -## Module 3: `MobileNetworkInterfaceSection` UI - -Outer JSX (header text, description, Generate QR button, Refresh button, Tailnet accordion) is untouched. Only the inner selection control is replaced. - -**State:** - -```ts -const [open, setOpen] = useState(false) -const [query, setQuery] = useState('') -const [customAddress, setCustomAddress] = useState(null) -``` - -`customAddress` is the last value the user confirmed via the `use-query` entry. It is the "session-scoped" custom selection that the trigger displays with the `(custom)` label. It is reset when the settings pane unmounts (React unmount handles this automatically; no global store involved). - -**Trigger:** A `Button` styled like the existing `SelectTrigger` (`min-w-[220px]`, `size="sm"`). Label uses the same `formatInterfaceLabel` helper: - -```ts -function formatInterfaceLabel(iface: { name: string; address: string }): string { - return `${iface.address} (${iface.name})` -} -``` - -For the custom selection the rendered iface is `{ name: 'custom', address: customAddress }`, so the trigger shows `100.64.1.20 (custom)`. - -**Popover body:** `Command` containing: - -- `CommandInput` with placeholder `Search or type an address…` and `value={query}` / `onValueChange={setQuery}`. -- `CommandList` containing: - - `CommandEmpty` shown only when no interfaces match AND `parseManualNetworkAddress(query)` is invalid (i.e., truly nothing to pick). - - One `CommandItem` per `kind: 'interface'` entry from `buildComboboxEntries(networkInterfaces, query)`. `onSelect` calls `onSelectedAddressChange(iface.address)`, `setCustomAddress(null)`, `setQuery('')`, `setOpen(false)`. - - Optional visual separator (e.g., `CommandSeparator`) before the `use-query` entry. - - One `CommandItem` for `kind: 'use-query'` (only present when query is valid). Label: `Use ""`. `onSelect` calls `onSelectedAddressChange(address)`, `setCustomAddress(address)`, `setQuery('')`, `setOpen(false)`. - -**Controlled cmdk selection:** Copy the controlled-`commandValue` pattern from `src/renderer/src/components/agent/AgentCombobox.tsx` (imports `createAgentComboboxCommandState`, `resolveAgentComboboxCommandState`, `updateAgentComboboxCommandValue` from `@/components/agent/agent-combobox-command-state`, plus the `Command`, `CommandEmpty`, `CommandInput`, `CommandItem`, `CommandList` primitives from `@/components/ui/command`) so that hovering the footer doesn't leave a stale highlight on a list item. The exact state shape will be minimal — only one list, no footer-group complexity — so the borrowed helpers are sufficient. No new helpers are introduced in this design. - -**Validation feedback:** - -- When `query` is non-empty and invalid, render a one-line `text-xs text-statusRed` message directly below the trigger: `"Enter an IPv4 address or Tailscale MagicDNS hostname"`. Use the existing `statusRed` token from the theme to stay style-guide compliant. -- The `use-query` entry only appears when valid; no need to disable it. - -**Refresh button:** Unchanged. Calls `onRefreshNetworkInterfaces`. The combobox re-renders with the new `networkInterfaces`; `query` and `customAddress` are preserved (user might be mid-typing). - -**Generate QR button:** Unchanged. Disabled when `!selectedAddress`. No new branches. - -## Data flow - -``` -networkInterfaces (prop, refreshed by parent) - │ - ▼ -buildComboboxEntries(networkInterfaces, query) - │ - ▼ -CommandList rows - │ - ▼ onSelect -onSelectedAddressChange(string) ─► parent re-renders MobilePairingQrSection - ─► QR is regenerated with new endpoint -``` - -The parent of `MobileNetworkInterfaceSection` (whichever Settings tab owns it) already maintains `selectedAddress` and re-passes it down. This design does not change that contract. - -## Edge cases - -1. **Duplicate manual entry vs. existing interface** — `buildComboboxEntries` suppresses the `use-query` entry whenever the parsed query exactly equals an emitted interface's `address`. For valid queries the visible list is the full interface list (no substring filter runs), so the suppression check is against every interface, not just filtered ones. The user lands on the existing interface row instead of a duplicate. -2. **OS discovers the manual address later** — If `customAddress === '100.64.1.20'` and a refresh surfaces `100.64.1.20 (tailscale0)`, both are valid options; the user's selection stays. A future iteration may add a "merge" action; out of scope here. -3. **Empty `networkInterfaces`** — All-interface list is empty. If `query` is also empty, `CommandEmpty` shows. If `query` is valid, the `use-query` entry still appears so the user can type an address even when nothing is enumerated. The trigger shows `No interfaces found`. -4. **Manual address becomes unreachable at pair time** — Not handled here. The QR generation succeeds; `pair-scan.tsx` already surfaces "Cannot connect — same network?" on failure. -5. **Closing the popover with an invalid query typed** — `customAddress` and `selectedAddress` are unchanged. Next open starts with an empty `query`. - -## Testing - -**`src/shared/network/manual-address.test.ts`** - -- Accepts: `0.0.0.0`, `255.255.255.255`, `192.168.1.24`, `100.64.1.20`. -- Rejects: `''`, `' '`, `'1.2.3'`, `'1.2.3.4.5'`, `'256.0.0.1'`, `'01.02.03.04'` (leading zeros), `'192.168.1.24 '` (trailing space). -- Accepts MagicDNS: `my-mac.ts.net`, `my-mac.tail-abcd.ts.net`, `a.b.c.d.ts.net`. -- Rejects MagicDNS: `my-mac` (no `.ts.net`), `my-mac.ts.com`, `-foo.ts.net`, `MY-MAC.TS.NET` is accepted (case-insensitive). -- Rejects anything > 253 chars; rejects whitespace anywhere. -- Pure unit tests; no React, no mocks. - -**`mobile-network-interface-selection.test.ts`** - -- `buildComboboxEntries([LAN, TAILNET], '')` returns two interface entries, no `use-query`. -- `buildComboboxEntries([LAN, TAILNET], '100')` (invalid query) returns the tailnet interface only — substring filter on `100.64.1.20` matches `100` — and no `use-query` because the query did not parse. -- `buildComboboxEntries([LAN, TAILNET], '100.64.1.20')` (valid query) returns both interface entries (valid queries skip substring filtering) AND suppresses `use-query` because the parsed address equals an existing interface's `address`. -- `buildComboboxEntries([LAN, TAILNET], 'my-mac.tail-abcd.ts.net')` (valid query) returns both interface entries (valid queries skip substring filtering) plus a `use-query` with the trimmed address. -- `buildComboboxEntries([], '1.2.3.4')` returns just `use-query`. - -**`MobileNetworkInterfaceSection.test.tsx`** (new) - -- Open popover, type `100.64.1.20`, click `Use "100.64.1.20"` → trigger label becomes `100.64.1.20 (custom)`, query clears. -- Type `not-an-address` → error message renders, no `Use …` row. -- Type `192.168.1.24` (matches `en0`) → no `Use …` row; clicking the existing interface selects it as `en0`, not `custom`. -- Trigger label `No interfaces found` shown when `networkInterfaces` is empty and no manual selection. - -## Out of scope - -- Persistent storage of manual addresses across sessions (user explicitly chose session-scoped). -- IPv6, port suffixes, non-Tailscale hostnames (rejected by the parser). -- Mobile-side endpoint override (separate flow; see `mobile/app/pair-scan.tsx`). -- Main-process changes — the renderer has enough information already. - -## Open questions for reviewer - -1. Should the error message stay in English-only here, or get the same `translate('auto.…', 'fallback')` wrap as the rest of the section? Recommend: wrap it for consistency, since the rest of the component already uses `translate()`. -2. Should `customAddress` survive a "Refresh" click? Recommend yes — user might be mid-typing during a VPN reconnect. Confirmed in Edge case 5 above. -3. Should `CommandInput` accept paste of a multi-line string (e.g. user pastes `orca://pair?code=…`)? Recommend: no special handling; the existing trim+validate treats it as an invalid address and shows the error. Pair-URL paste remains the path through `pair-scan.tsx`. diff --git a/docs/reference/README.md b/docs/reference/README.md deleted file mode 100644 index 1ced066c57b..00000000000 --- a/docs/reference/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Durable Docs - -Keep this folder for versioned reference docs that are meant to survive past a single design or implementation pass. - -## What Goes Here - -- Stable reference material. -- Public-facing docs that are not part of the root README. -- Docs that other checked-in files link to. -- Telemetry availability notes that dashboard authors need after the original design or implementation branch is gone. See [Telemetry Availability](./telemetry-availability.md). -- Headless Linux server setup for remote `orca serve` hosts. See [Headless Linux Server](./headless-linux-server.md). -- Feature education state, interaction tracking, and retention analytics notes that define how contextual tours are persisted and measured. See [Feature Education State](./feature-education-state.md), [Feature Discovery Interaction Tracking](./feature-discovery-interaction-tracking.md), and [Feature Education Retention Analytics](./feature-education-retention-analytics.md). -- New-user parallel work telemetry notes that define how the parallel-work tour and setup guide should be measured against retention. See [New User Parallel Work Telemetry](./new-user-parallel-work-telemetry.md). - -## What Stays Out - -Ephemeral design notes, implementation sketches, and planning docs should stay as local Markdown files under `docs/`. They are ignored by default so they do not get checked in accidentally. diff --git a/docs/reference/agent-hook-stdin-lifecycle.md b/docs/reference/agent-hook-stdin-lifecycle.md deleted file mode 100644 index 98b404c97ac..00000000000 --- a/docs/reference/agent-hook-stdin-lifecycle.md +++ /dev/null @@ -1,204 +0,0 @@ -# Agent Hook Stdin Lifecycle - -## Problem - -Orca installs agent hooks in global agent configuration, so a managed hook can -run in an Orca pane, an SSH/WSL runtime, a detached session, or an ordinary -terminal with no Orca environment. Hook runners write an event payload to the -child process's stdin after spawn. - -The generated scripts currently inspect Orca environment variables before they -read stdin. When a guard exits first, the hook runner can still be writing to a -pipe whose reader has closed. POSIX reports `EPIPE`; Windows reports -`ERROR_BROKEN_PIPE`. The same failure exists when a managed launcher finds that -its script was removed or is no longer executable. - -This is an ownership bug rather than an agent-specific parsing bug. Every -process that accepts a hook payload owns the read end until it reaches EOF, -including processes that decide the event is irrelevant. - -## Goals - -- Make successful hook no-ops consume stdin to EOF on macOS, Linux, Windows, - WSL, and SSH hosts. -- Encode stdin ownership once per platform instead of copying ad hoc drains - into every early-exit branch. -- Preserve payload bytes, hook output, transport timeouts, exit-code behavior, - and provider-specific event metadata on the forwarding path. -- Cover every generated script and missing-script launcher with behavioral - regression tests. - -## Non-goals - -- Do not redesign hook configuration schemas, endpoint discovery, relay HTTP - payloads, or event selection. -- Do not make hook payloads unbounded; existing agent and config timeouts remain - the outer lifecycle bound. -- Do not suppress failures from a script that exists and actually runs. Only a - deliberate no-op path exits successfully after consuming stdin. -- Do not change plugin-based integrations that do not receive piped hook stdin. - -## Contract - -1. A generated hook script consumes its input exactly once. -2. No whole-script success exit may occur before that consumption completes. -3. A launcher that declines to start a missing, unreadable, or non-executable - managed script becomes the stdin owner and drains to EOF before returning - success. -4. A launcher propagates the exit code of a managed script that was started. -5. Output required by an agent protocol may be emitted before stdin is read, - but the process must still retain the read end until EOF. -6. Drain commands are platform-qualified where the platform searches the - working directory implicitly. - -## Design - -### POSIX generated scripts - -Capture stdin near the start of the generated script, after any protocol output -that must be immediate and before endpoint refresh, environment guards, or -provider-specific skips: - -```sh -payload=$(cat) -if [ -z "$payload" ]; then - exit 0 -fi -``` - -Antigravity is the one semantic exception: events without payload still post an -empty object, so its shared capture policy maps empty input to `{}` instead of -exiting. Claude's Devin-import skip happens after capture. Command Code captures -before ancestor/endpoint recovery so its comparatively expensive discovery -cannot leave the writer blocked. - -The common payload-capture fragments live in -`src/main/agent-hooks/hook-stdin-contract.ts`. Templates choose the required or -empty-object policy instead of spelling the lifecycle independently. - -### Windows batch generated scripts - -Batch scripts stream stdin directly into system `curl.exe`; buffering arbitrary -JSON in an environment variable would corrupt metacharacters and hit size -limits. Their posting path therefore remains streaming. - -All environment guard failures jump to one epilogue: - -```bat -if "%ORCA_AGENT_HOOK_PORT%"=="" goto :orca_agent_hook_drain_stdin -... -exit /b 0 -:orca_agent_hook_drain_stdin -"%SystemRoot%\System32\more.com" >nul 2>nul -exit /b 0 -``` - -`more.com` is qualified because Windows searches the current working directory -for executables. Shared guard and epilogue builders keep labels and commands -identical across templates. Command Code's existing endpoint-discovery labels -remain subroutines and the drain epilogue is placed after them. - -### PowerShell generated scripts - -PowerShell captures with `[Console]::In.ReadToEnd()` before endpoint and -environment guards. Copilot then parses the captured value only on the posting -path. This mirrors POSIX ownership without starting an additional process. - -### Managed launchers - -- POSIX `/bin/sh` launchers require a regular, readable, executable file. The - rejected path drains with `cat`; the started-script branch keeps propagating - status. -- Encoded PowerShell launchers use `Test-Path -PathType Leaf`. A missing script - calls `[Console]::In.ReadToEnd()` and exits zero; an existing script preserves - `$LASTEXITCODE`. -- Codex's (and Antigravity's/Devin's) cmd fast path is the bare, directly - spawnable `.cmd` path. These agents launch the hook command as a program - (argv[0]), not through cmd.exe, so the launcher cannot lead with a cmd builtin - such as `if` — that argv[0] is unspawnable and fails every hook. A missing - script therefore surfaces a normal launch failure on this fast path; only - launchers that already require a real interpreter (encoded PowerShell, Git - Bash) drain a missing script. Paths cmd.exe cannot invoke verbatim fall back to - the encoded PowerShell launcher, which owns stdin for the missing case. -- Claude's Git Bash fast path uses a POSIX file guard and drain while continuing - to execute the `.cmd` directly rather than interpreting it as shell source. -- Agent-specific direct launchers, including Antigravity event wrappers and - Copilot's PowerShell-file command, adopt the same missing-file behavior. - -## Data Flow - -```text -hook runner spawns command - -> runner writes payload and closes stdin - -> launcher starts managed script - -> script consumes payload - -> refreshes endpoint / evaluates guards - -> posts or exits zero - -> OR launcher cannot start script - -> launcher drains payload - -> exits zero -``` - -Local, WSL, and SSH installs serialize the same POSIX template. Windows local -installs use the batch or PowerShell template. No host assumes another host's -path syntax or shell. - -## Failure Semantics - -- Missing Orca environment: consume input, exit zero, emit only protocol-required - output. -- Empty payload: consume EOF, then follow the agent's existing empty-event rule. -- Missing/unreadable/non-executable script: launcher consumes input and exits - zero. -- Endpoint parse/read failure: preserve the existing fail-open behavior after - stdin ownership has been satisfied. -- Existing script returns nonzero: propagate its status; do not drain again or - disguise the script failure. -- Hook runner never closes stdin: the existing config-level timeout terminates - the hook. Reading to EOF does not introduce an unbounded lifecycle beyond that - already required by normal payload parsing. - -## Verification - -Add one cross-agent lifecycle suite rather than per-service string-position -assertions. - -- Generate every SSH-compatible POSIX managed script, spawn it with Orca - environment removed, write a payload larger than pipe buffers, and assert: - exit zero, no stdin error, and required protocol output remains valid. -- Exercise the Claude Devin skip independently because it is a second no-op - condition before endpoint forwarding. -- Exercise POSIX, encoded PowerShell, cmd.exe, and Git Bash missing-script - launchers with a large payload and verify zero write errors. -- On Windows CI, install and execute every local batch/PowerShell managed script - with missing Orca environment and a large payload. -- Keep structural assertions for shared batch guard/epilogue generation, but do - not use substring placement as the primary regression gate. -- Preserve existing successful-post, timeout, quoting, SSH install, WSL, and - nonzero-exit propagation tests. - -For a macOS smoke test, launch the real Electron app with an isolated `HOME` -and `ORCA_DEV_USER_DATA_PATH`, record the launch time, then run: - -```sh -node config/scripts/verify-agent-hook-stdin-lifecycle.mjs \ - --home "$ISOLATED_HOME" \ - --min-mtime "$LAUNCH_START_MS" -``` - -The verifier rejects stale files, then exercises the 12 POSIX scripts written -by that Electron launch with a payload larger than pipe buffers. It covers -successful no-ops, loopback forwarding without payload changes, required JSON -stdout, Claude's Devin skip, and both branches of the installed missing-script -launcher. - -## Rollout And Compatibility - -The generated scripts are rewritten by existing install/update flows, so no -config migration is needed. Commands use POSIX `sh` primitives, Windows inbox -executables, and PowerShell features available on supported Windows releases. -No Git behavior or provider-specific review behavior changes. - -The change should ship atomically across templates and launchers. A partial -rollout would leave global hooks with platform- or agent-dependent pipe safety, -which is the inconsistency this design removes. diff --git a/docs/reference/agent-session-resume-cli-evidence.md b/docs/reference/agent-session-resume-cli-evidence.md deleted file mode 100644 index 5c26f9ecbc6..00000000000 --- a/docs/reference/agent-session-resume-cli-evidence.md +++ /dev/null @@ -1,341 +0,0 @@ -# Agent Session Resume Evidence for Sleep/Wake - -Date: June 5, 2026 - -Scope: issue [stablyai/orca#1796](https://github.com/stablyai/orca/issues/1796), current worktree code, installed agent CLIs on this machine, cloned open-source CLI repos under `/tmp/orca-agent-resume-research`, and official provider docs where available. - -## Executive conclusion - -Orca can implement one-click agent resume on wake, but it cannot use terminal PTY IDs, pane keys, tab IDs, or worktree IDs as the agent session ID. Those are Orca terminal identifiers. The required value is the provider's conversation/session/thread ID. - -For several first-tier providers, the exact provider ID is available in hook payloads today, but Orca drops it during normalization: - -- Claude: hook payloads include `session_id`; resume command is `claude --resume `. -- Codex: hook runtime sends `session_id`; resume command is `codex resume `. -- Gemini: hook payloads include `session_id`; resume command is `gemini --resume `. -- Antigravity: hook payloads include `conversationId`; resume command is `agy --conversation `. -- OpenCode: Orca's managed plugin already reads OpenCode `sessionID`; resume command is `opencode --session `. -- Droid: hook payloads include `session_id`; resume command is `droid --resume `. -- Grok: hook payloads include `sessionId`/`session_id`; resume command is `grok --resume `. - -The current blocker is not primarily "how do we resume?" The exact resume action is to spawn a new agent process on wake in the same workspace/pane context using the provider-specific resume command. The blocker is that Orca's current hook event and status types have no durable provider-session field, so the ID is discarded before sleep. - -## Current Orca behavior - -Issue #1796 is accurate about current behavior. The issue says sleep loses agent type, session ID, last prompt, and resume command because `dropAgentStatusByWorktree` wipes rows before wake can offer resume. - -Verified current code path: - -- Sleep calls terminal shutdown with identifiers preserved, not provider conversation metadata. See `src/renderer/src/components/sidebar/sleep-worktree-flow.ts`. -- `shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true })` preserves terminal IDs/layout wake hints, then calls `dropAgentStatusByWorktree(worktreeId)`. See `src/renderer/src/store/slices/terminals.ts`. -- `agentStatusByPaneKey` is explicitly documented as "Real-time only - lives in renderer memory, not persisted to disk." See `src/renderer/src/store/slices/agent-status.ts`. -- `WorkspaceSessionState` persists terminal/browser/editor/SSH state, including terminal layouts and remote relay PTY IDs, but no sleeping-agent records. See `src/shared/types.ts`, `src/renderer/src/lib/workspace-session.ts`, and `src/shared/workspace-session-schema.ts`. -- `AgentStatusEntry` has `state`, `prompt`, timestamps, `agentType`, pane/tab/worktree attribution, tool previews, assistant preview, and orchestration context. It has no provider session ID or resume command field. See `src/shared/agent-status-types.ts`. - -The existing sleep persistence is terminal-resume metadata. The feature needs provider-conversation-resume metadata. - -## Hook pipeline gap - -The current local and SSH hook wire shapes drop provider IDs: - -- `AgentHookEventPayload` in `src/shared/agent-hook-listener.ts` includes pane/tab/worktree, prompt cache fields, hook event name, Claude tool/subagent IDs, and normalized `payload`. It has no provider session ID field. -- `AgentHookRelayEnvelope` in `src/shared/agent-hook-relay.ts` mirrors that normalized shape for SSH. Its comment states the relay normalizes before sending the envelope. Therefore remote/SSH events lose provider IDs at the relay boundary too unless the new field is added to the shared envelope. -- `ParsedAgentStatusPayload` in `src/shared/agent-status-types.ts` includes only status fields: `state`, `prompt`, `agentType`, `toolName`, `toolInput`, `lastAssistantMessage`, `interrupted`. - -Implementation implication: provider-session metadata must be extracted before normalization completes and must be carried through both the local HTTP path and the SSH relay path. - -## Installed CLI versions checked - -These exact binaries were available on this machine: - -| Agent | Version checked | -| --- | --- | -| Claude Code | `2.1.165` | -| Codex | `codex-cli 0.137.0` | -| Gemini | `0.44.0` | -| OpenCode | `1.15.13` | -| Cursor Agent | `2025.09.18-7ae6800` | -| Antigravity | `1.0.5` | -| Droid | `0.122.0` | -| Grok | `grok 0.2.22 (967574cb117) [stable]` | -| Pi | `0.72.1` | -| Amp, Copilot, Command Code, Hermes | Not installed locally | - -## Provider matrix - -Legend: - -- `Exact supported`: exact resume command and exact ID source are both verified. -- `Resume verified, ID capture missing`: CLI can resume, but current Orca hook path does not expose a durable exact ID. -- `ID seen, resume not verified`: Orca/source exposes an ID, but no exact resume CLI command was verified. -- `Out of current hook scope`: Orca has no current hook source for this TUI agent. - -| Agent | Resume command evidence | Exact ID source evidence | Current Orca support status | -| --- | --- | --- | --- | -| Claude | `claude --resume ` verified by local `claude --help` and Anthropic CLI docs. | Claude hook docs show hook input includes `session_id` and `transcript_path`; current Orca normalizer drops `session_id`. | Exact supported after adding provider-session metadata extraction. | -| Codex | `codex resume ` verified by local help and cloned Codex source `codex-rs/utils/cli/src/resume_command.rs`. | Cloned Codex source `codex-rs/core/src/hook_runtime.rs` sends `session_id` on `SessionStart`, `PreToolUse`, `PermissionRequest`, and `PostToolUse` hook requests. | Exact supported after extraction. | -| Gemini | `gemini --resume ` verified by local help and Gemini docs. | Gemini hooks reference says all hooks receive `session_id` and `transcript_path`. | Exact supported after extraction. | -| Antigravity | `agy --conversation ` verified by local help and Antigravity docs. | Antigravity hook docs list `conversationId` as the active conversation UUID. | Exact supported after extraction. | -| OpenCode | `opencode --session ` verified by local help, official docs, and cloned OpenCode TUI source. | Orca's OpenCode plugin already reads `event.properties?.sessionID` before posting. | Exact supported after preserving `sessionID`. | -| Droid | `droid --resume ` verified by local help and Factory CLI docs. | Factory hooks reference includes `session_id`; Orca installs Droid hooks and already subscribes to `SessionStart`. | Exact supported after extraction. | -| Grok | `grok --resume ` verified by local help. | Orca already reads `sessionId`/`session_id` in `getGrokChatHistoryPath`, but only to locate chat history. | Exact supported after preserving that same ID. | -| Cursor | `cursor-agent --resume ` and `cursor-agent resume` verified by local help and Cursor docs. | Current Orca Cursor hook install deliberately omits `sessionStart`/`sessionEnd`; the subscribed event set does not currently prove a chat ID source. `cursor-agent create-chat` was locally verified to return a UUID, but preallocation for Orca launches still needs an end-to-end test before relying on it. | Resume verified, ID capture missing. | -| Pi | `pi --session `, `pi --resume`, and `pi --continue` verified by local help and Pi docs. | Orca's bundled Pi/OMP extension posts status events but does not include session file path, session UUID, or session name in the status payload. | Resume verified, ID capture missing. | -| OMP | Same launch family as Pi in Orca. | Orca reuses Pi extension API shape and does not include exact session pointer. | Resume verified by inheritance only from Pi-like CLI contract; exact OMP ID capture missing. | -| Amp | Orca's managed plugin posts `threadId: event.thread.id`. | Amp is not installed locally, and no exact Amp CLI resume command was verified in this investigation. | ID seen, resume not verified. | -| Hermes | Orca's managed Hermes plugin selects `session_id` for session/LLM/tool events. | Hermes is not installed locally, and no exact Hermes CLI resume command was verified in this investigation. | ID seen, resume not verified. | -| Copilot | Orca installs broad Copilot hooks. | Local Copilot CLI not installed; no exact resume command or session ID contract was verified. | Not supported for exact resume yet. | -| Command Code | Orca installs hooks for `PreToolUse`, `PostToolUse`, and `Stop`. | Local Command Code CLI not installed; no exact resume command or session ID contract was verified. | Not supported for exact resume yet. | -| openclaude, autohand, aider, goose, kilo, kiro, crush, aug, cline, codebuff, continue, kimi, mistral-vibe, qwen-code, rovo, openclaw | Not investigated to exact-resume standard. | Not in `HOOK_SOURCE_BY_PATHNAME` today. | Out of current hook scope. | - -## Exact resume behavior by provider - -### Claude - -Verified: - -- Local `claude --help` shows `--continue`, `--resume [value]`, `--session-id `, `--fork-session`, and `--no-session-persistence`. -- Anthropic CLI docs show `claude -r "" "query"` and document `--resume` as resuming a specific session by ID. -- Claude hooks docs show hook input includes `session_id` and `transcript_path`. - -Resume command: - -```sh -claude --resume -``` - -If Orca stores `lastPromptSummary` separately, it should not pass that summary as a new user prompt by default. The one-click resume action should reopen the prior conversation. Auto-submitting a summary would create a new turn. - -### Codex - -Verified: - -- Local `codex resume --help` accepts `[SESSION_ID] [PROMPT]`, where the session target is a UUID or session name. -- Cloned Codex source `codex-rs/utils/cli/src/resume_command.rs` formats `codex resume `. -- Cloned Codex source `codex-rs/core/src/hook_runtime.rs` sends `session_id` on hook requests. -- Cloned Codex source has tests asserting resumed root sessions use the thread ID as session ID. - -Resume command: - -```sh -codex resume -``` - -Avoid `codex resume --last` for this feature when an exact ID is available. `--last` can resume the wrong conversation if another Codex session ran after the slept workspace. - -### Gemini - -Verified: - -- Local `gemini --help` shows `--resume`, `--session-id`, `--session-file`, and `--list-sessions`. -- Gemini official hooks reference states all hook input includes `session_id` and `transcript_path`. -- Gemini official configuration docs describe `--resume [session_id]` and state that a UUID can be supplied. - -Resume command: - -```sh -gemini --resume -``` - -### Antigravity - -Verified: - -- Local `agy --help` shows `--continue` and `--conversation`. -- Antigravity conversation docs show `agy --continue` for most recent and `agy --conversation ` for a specific session. -- Antigravity hooks docs define common hook input `conversationId`. - -Resume command: - -```sh -agy --conversation -``` - -### OpenCode - -Verified: - -- Local `opencode --help` shows `--continue` and `--session`. -- Official OpenCode docs list `--session` as the session ID to continue. -- Cloned OpenCode source `packages/opencode/src/cli/cmd/tui/thread.ts` defines `--session` and passes it into TUI args as `sessionID`. -- Cloned OpenCode source `validate-session.ts` validates the supplied session ID through the OpenCode client. -- Orca's managed OpenCode plugin reads `event.properties?.sessionID`. - -Resume command: - -```sh -opencode --session -``` - -### Droid - -Verified: - -- Local `droid --help` shows `--resume [sessionId]`, `--fork `, and `--cwd`. -- Factory CLI docs list resume behavior and `droid exec -s ` for exec mode. -- Factory hooks reference says `SessionStart` has a `resume` matcher and hook input includes `session_id`. - -Resume command: - -```sh -droid --resume -``` - -### Grok - -Verified: - -- Local `grok --help` shows `--resume []`, `--continue`, `--cwd`, and `--restore-code`. -- Orca already reads `sessionId`/`session_id` in `getGrokChatHistoryPath`. - -Resume command: - -```sh -grok --resume -``` - -### Cursor - -Verified: - -- Local `cursor-agent --help` shows `--resume [chatId]`. -- Cursor docs show listing prior chats with `cursor-agent ls`, resuming latest with `cursor-agent resume`, and resuming a specific conversation with `cursor-agent --resume="chat-id-here"`. -- Local help shows `cursor-agent create-chat` creates an empty chat and returns its ID. -- Running `cursor-agent create-chat` locally returned UUID `1fff2e62-9dd0-4cf3-9d8c-3569fea0aff7`, proving the command can preallocate a chat ID without an initial prompt. - -Not verified: - -- Current Orca Cursor hook install subscribes to `beforeSubmitPrompt`, `stop`, tool events, approval events, and `afterAgentResponse`, but intentionally does not subscribe to `sessionStart`/`sessionEnd`. -- I did not verify a current subscribed Cursor hook payload containing `chatId`. -- I did not run `cursor-agent --resume ` because that would start a real agent turn and may make workspace changes. - -Conclusion: exact Cursor resume is CLI-supported, but Orca currently needs either a verified hook payload chat ID or a launch preallocation strategy that is tested end to end. - -### Pi and OMP - -Verified: - -- Local `pi --help` shows `--continue`, `--resume`, `--session `, `--fork `, `--session-dir`, and `--no-session`. -- Pi docs state `/resume` opens a picker and `pi -r` opens the same picker at startup. -- Orca's Pi/OMP extension posts status events from Pi's extension API, but the status events do not include session path, UUID, or session name. - -Resume command: - -```sh -pi --session -``` - -Conclusion: exact Pi resume is CLI-supported, but current Orca capture is missing the exact pointer. - -## What Orca should store - -Store a durable record per sleeping agent pane, not per worktree only: - -```ts -type SleepingAgentSessionRecord = { - id: string - worktreeId: string - tabId?: string - paneKey: string - connectionId: string | null - agentType: TuiAgent - providerSession: { - kind: 'session_id' | 'conversation_id' | 'thread_id' | 'chat_id' | 'path' - value: string - } - resumeCommand: { - argv: string[] - cwd?: string - } - lastPrompt: string - lastAssistantMessage?: string - capturedAt: number - retentionUntil?: number | null -} -``` - -Important constraints: - -- `resumeCommand.argv` should be structured argv, not a shell string. This avoids cross-platform quoting bugs. -- Do not persist a terminal PTY ID as `providerSession.value`. -- For SSH, `cwd` and the command execute on the remote host. The provider session ID also belongs to the remote CLI's session store. -- For local workspaces, execute locally in the worktree path. -- For auto-resume, launch exactly one process per sleeping agent record and guard against double-click/duplicate wake races. - -## Provider extraction map - -This is the extraction map that is supported by the evidence above: - -| Hook source | Provider session field(s) to extract | Resume argv | -| --- | --- | --- | -| `claude` | `session_id` | `['claude', '--resume', id]` | -| `codex` | `session_id` | `['codex', 'resume', id]` | -| `gemini` | `session_id` | `['gemini', '--resume', id]` | -| `antigravity` | `conversationId` | `['agy', '--conversation', id]` | -| `opencode` | `sessionID` | `['opencode', '--session', id]` | -| `droid` | `session_id` | `['droid', '--resume', id]` | -| `grok` | `sessionId`, fallback `session_id` | `['grok', '--resume', id]` | -| `amp` | `threadId` | not enabled until Amp resume command is verified | -| `hermes` | `session_id` | not enabled until Hermes resume command is verified | - -## Required implementation shape - -1. Add a provider-session metadata type to shared hook types. -2. Extract provider session evidence in `parseAgentHookEvent` from raw `hookPayloadRecord` before normalizing to `ParsedAgentStatusPayload`. -3. Carry it in `AgentHookEventPayload`. -4. Carry it in `AgentHookRelayEnvelope` so SSH does not lose it. -5. Store latest provider-session evidence by pane key alongside live agent status. -6. On sleep, snapshot the live agent status plus latest provider-session evidence before `dropAgentStatusByWorktree`. -7. Persist sleeping-agent records in `WorkspaceSessionState` and schema. -8. On wake, render sleeping resumable rows separately from active rows and launch the provider-specific structured argv when the user resumes. -9. Clear records on explicit worktree removal, not on sleep. -10. If the provider session no longer exists, let the provider CLI fail and surface that failure without deleting the record until the user dismisses it. - -## Open questions that remain genuinely unverified - -- Cursor: whether any currently subscribed Cursor hook payload carries `chatId`. If not, the only plausible exact strategy is preallocating a chat with `cursor-agent create-chat` at launch and starting/resuming that known chat. That needs an end-to-end test before productizing. -- Pi/OMP: whether the extension API can expose the current session file/path/UUID. Current Orca extension does not forward it. -- Amp: whether the Amp CLI has an exact thread resume command matching `threadId`. -- Hermes: whether Hermes has an exact session resume CLI command matching `session_id`. -- Copilot and Command Code: exact resume command and exact hook session field were not verified because the CLIs were not installed locally and no sufficient primary docs/source were found during this pass. -- Non-hook TUI agents: no exact support should be promised until Orca adds a hook/metadata capture path for each. - -## Sources - -Local Orca source: - -- `src/shared/agent-hook-listener.ts` -- `src/shared/agent-hook-relay.ts` -- `src/shared/agent-status-types.ts` -- `src/shared/types.ts` -- `src/shared/tui-agent-config.ts` -- `src/renderer/src/store/slices/terminals.ts` -- `src/renderer/src/store/slices/agent-status.ts` -- `src/renderer/src/lib/workspace-session.ts` -- `src/shared/workspace-session-schema.ts` -- `src/main/opencode/hook-service.ts` -- `src/main/amp/hook-service.ts` -- `src/main/hermes/hook-service.ts` -- `src/main/pi/agent-status-extension-source.ts` -- `src/main/cursor/hook-service.ts` - -Cloned source: - -- `/tmp/orca-agent-resume-research/codex` -- `/tmp/orca-agent-resume-research/gemini-cli` -- `/tmp/orca-agent-resume-research/opencode` - -Official docs: - -- GitHub issue: https://github.com/stablyai/orca/issues/1796 -- Claude CLI reference: https://docs.anthropic.com/en/docs/claude-code/cli-usage -- Claude hooks reference: https://code.claude.com/docs/en/hooks -- Gemini hooks reference: https://geminicli.com/docs/hooks/reference/ -- Cursor CLI parameters: https://docs.cursor.com/en/cli/reference/parameters -- Cursor CLI usage: https://docs.cursor.com/en/cli/using -- OpenCode CLI docs: https://opencode.ai/docs/cli/ -- Antigravity conversation docs: https://antigravity.google/docs/cli-conversations -- Antigravity hooks docs: https://www.antigravity.google/docs/hooks -- Factory Droid hooks reference: https://docs.factory.ai/cli/configuration/hooks-reference -- Factory Droid CLI reference: https://docs.factory.ai/cli/configuration/cli-reference -- Pi docs: https://pi.dev/docs/latest/tree diff --git a/docs/reference/browser-local-https-certificate-trust.md b/docs/reference/browser-local-https-certificate-trust.md deleted file mode 100644 index d858e030cb9..00000000000 --- a/docs/reference/browser-local-https-certificate-trust.md +++ /dev/null @@ -1,926 +0,0 @@ -# Built-in Browser Local HTTPS and Certificate Trust - -## Status - -Implemented reference design for -[issue #8454](https://github.com/stablyai/orca/issues/8454). - -The branch includes scheme-less local-dev URL classification, certificate-specific -failure copy, `Try HTTPS`, exact per-WebContents certificate grants, -`Proceed Anyway (Unsafe)`, local IPC, remote/headless runtime propagation, and -focused unit and real-Electron coverage. A session-level request gate contains -Chromium's cached certificate continuation so approval cannot leak to sibling -tabs, assets, fetches, iframes, or WebSockets. The release gates below remain -the acceptance contract for Electron upgrades and cross-platform validation. - -## Summary - -Orca's built-in browser cannot open a local HTTPS development server when the -server presents an untrusted certificate. A scheme-less local address also -defaults to HTTP, so entering `localhost:3000` does not discover an HTTPS-only -server. - -This design keeps HTTP as the default for the scheme-less local forms that -already use it, adds a visible `Try HTTPS` recovery action after a failed local -HTTP navigation, and adds an explicit `Proceed Anyway (Unsafe)` path for an -untrusted local HTTPS certificate. Certificate approval is narrow and -temporary: it is bound to one browser WebContents, one secure endpoint, the -SHA-256 digest of the exact leaf certificate, and the specific certificate -error. A secure endpoint treats `https` and its companion `wss` scheme as one -TLS endpoint but still includes the canonical hostname and effective port. No -approval state is persisted. - -The certificate decision runs in the process that owns the browser page. Local -desktop webviews use the desktop main process; SSH/headless pages use the remote -runtime process and expose the decision through runtime RPC. - -## Problem - -There are three independent behaviors behind the current failure. - -1. `normalizeBrowserNavigationUrl` and the tab-create entry classifier prepend - `http://` to scheme-less loopback input. Explicit `https://` input is already - preserved. -2. Electron rejects an untrusted server certificate by default. Orca has no - `certificate-error` decision flow, so a self-signed development certificate - fails with a Chromium certificate error such as - `ERR_CERT_AUTHORITY_INVALID (-202)`. -3. Browser load errors are presented as generic connectivity failures. For a - loopback URL, Orca advises the user to check that the server is running even - when the server responded and only certificate verification failed. - -The same trust limitation exists in the offscreen browser backend used by -headless and SSH-owned browser pages. A desktop-only handler would fix the -visible local webview while leaving remote browser ownership inconsistent. - -## Goals - -- Show certificate-specific failure copy for main-frame certificate errors. -- Let a user explicitly approve an untrusted local development certificate. -- Bind approval to the exact browser surface, secure endpoint, leaf certificate - digest, and certificate error. -- Keep approval in memory only and clear it with the owning browser surface. -- Support both desktop webviews and SSH/headless offscreen browser pages. -- Give scheme-less local input a discoverable path to HTTPS without silently - changing the existing HTTP default. -- Preserve the current browser sandbox, navigation allowlist, session profiles, - and mixed-content policy. -- Keep browser failure state accurate across event-order races, early webview - attachment, reload, stale user actions, and remote latency. - -## Non-goals - -- Do not automatically trust a self-signed certificate. -- Do not disable `webSecurity`, enable insecure mixed content, or add - `--ignore-certificate-errors`. -- Do not install a certificate into the operating-system trust store. -- Do not persist certificate exceptions across app restarts, browser surface - recreation, or session-profile changes. -- Do not default every scheme-less localhost URL to HTTPS. -- Do not automatically probe HTTPS and then downgrade to HTTP. -- Do not add a general-purpose exception flow for public websites in v1. -- Do not make every Chromium certificate error overridable in v1. -- Do not extend localhost worktree-label proxying to HTTPS targets. - -## Product Decisions - -### Scheme-less local input continues to use HTTP - -`localhost:3000` remains `http://localhost:3000/`. HTTP is still the common -development-server default, and changing the inference globally would turn -working HTTP servers into certificate or TLS failures. - -When an HTTP navigation to a loopback URL fails, the browser failure overlay -offers `Try HTTPS`. The action replaces only the URL scheme and preserves the -host, explicit non-default port, path, query, and fragment. An absent port, or -an explicit `:80` normalized away by the URL parser, becomes HTTPS's default -port 443. Orca does not automatically retry because an invisible -cross-protocol retry would add latency, send an unexpected second request, and -make downgrade behavior opaque. - -Explicit input remains authoritative: - -- `http://localhost:3000` stays HTTP. -- `https://localhost:3000` stays HTTPS. -- URLs advertised by a workspace process keep their advertised scheme. - -The shared browser URL domain becomes the source of truth for the existing -scheme-less local-dev classification. The tab-create entry classifier calls -that shared logic instead of keeping a second regular expression and scheme -rule. This consolidation must not reuse the stricter certificate-eligibility -predicate: doing so would change current behavior for inputs such as -`0.0.0.0`, bracketed non-loopback IPv6 addresses, and `.localhost` subdomains. - -### V1 approval covers untrusted local authorities only - -The first version offers `Proceed Anyway (Unsafe)` only when all of these are -true: - -- the failure is for the main frame; -- the attempted URL is HTTPS; -- Electron reports `net::ERR_CERT_AUTHORITY_INVALID`, corresponding to - Chromium load error `-202`; -- the host is an eligible loopback host; -- the WebContents is a browser surface managed by Orca; and -- Orca retained a live pending challenge for that browser surface. - -Other certificate errors receive accurate copy but no bypass action. In -particular, v1 does not bypass revoked certificates, malformed certificates, -weak keys, date errors, or hostname/SAN mismatches. Expanding the allowlist -requires a separate security decision and dedicated tests. - -### Eligible loopback hosts - -The shared predicate accepts: - -- `localhost`, case-insensitively, with an optional trailing dot; -- valid subdomains of `.localhost`, also with an optional trailing dot; -- valid IPv4 addresses in `127.0.0.0/8`; and -- IPv6 loopback `::1`. - -The predicate does not treat `0.0.0.0`, `::`, arbitrary bracketed IPv6 -addresses, private LAN addresses, or a DNS name that merely resolves to -loopback as certificate-bypass eligible. Wildcard bind addresses should already -be normalized to a connectable loopback address before navigation. - -SSH local forwards are eligible when their browser-facing URL uses one of these -loopback hosts. Forwarding does not fix certificate names: a URL using -`127.0.0.1` still needs an IP SAN for `127.0.0.1`; a certificate containing only -`localhost` will fail with `ERR_CERT_COMMON_NAME_INVALID`, which v1 does not -bypass. Custom advertised DNS names remain subject to normal system trust in -v1, even if the name maps to loopback. - -### Approval lifetime - -An accepted grant is scoped to: - -```text -browser WebContents ID + secure endpoint + leaf SHA-256 digest + error name -``` - -The secure endpoint canonicalizes `wss:` to its companion `https:` endpoint and -includes the normalized hostname and effective port. This lets an approved -local page load same-endpoint HTTPS assets and its WSS development socket. A -grant for `https://localhost:3000` does not apply to port 3001, to -`https://127.0.0.1:3000`, to another browser tab, or to a replacement -certificate. - -Construct the secure endpoint from the parsed request URL: map `https:` and -`wss:` to `https:`, lowercase the hostname, remove one optional trailing dot, -use the URL parser's canonical IP representation, and use port 443 when the -parsed port is empty. Exclude credentials, path, query, and fragment. Reject -every other scheme. - -Accepted grants survive reloads and same-surface navigation back to the exact -endpoint. They are cleared when the WebContents commits a main-frame navigation -whose canonical secure endpoint differs from the grant's endpoint (reusing the -per-WebContents navigation-sequence tracking), when the WebContents is destroyed -or replaced, the session profile changes, the browser-owning process exits, or -the app restarts. Binding grant lifetime to the granting main-frame document -prevents a later top-level document in the same tab — for example a navigation -to a public site — from silently reusing the loopback grant for a same-endpoint -iframe, fetch, or WSS subrequest. -Clearing a grant affects future requests; it cannot retroactively terminate a -document that already loaded. Grants are not written to settings, browser -history, workspace session state, or logs. - -Chromium caches an accepted `certificate-error` continuation across the -Electron session partition. Orca therefore retains every endpoint/certificate -identity accepted in that partition until the browser-owning process exits and -gates all later HTTPS/WSS requests with `webRequest.onBeforeRequest`. Only the -WebContents holding the matching grant may reach a cached endpoint. A sibling -main-frame navigation receives a synthetic challenge for the known identity; -sibling subresources, fetches, iframes, workers, and WSS requests fail silently. - -Because a preflight request gate cannot inspect the certificate that will be -presented after Chromium's cache is consulted, one endpoint is locked to the -first accepted untrusted leaf identity for that process lifetime. If that -endpoint later presents a different untrusted leaf, strict verification rejects -it and Orca does not offer another bypass until the browser-owning process -restarts. A replacement system-trusted certificate may load normally; the -conservative request gate still keeps sibling WebContents blocked until restart. - -## User Experience - -### Local HTTP failure - -For a failed `http://` loopback navigation, keep the current connectivity -title and recovery hint. Add `Try HTTPS` when an HTTPS form can be constructed. - -Example: - -```text -Can't reach localhost:3000 -We couldn't connect to your local server. - -[Try HTTPS] [Retry] [Copy Address] [Open Externally] -``` - -`Try HTTPS` is the default button because it is the targeted recovery action -for a local HTTP failure. Keep the existing `Copy Address` recovery action; -`Retry` uses `outline`, while `Copy Address` and `Open Externally` stay quiet -secondary actions. `Try HTTPS` does not appear for an already-HTTPS URL or a -non-eligible host. - -### Certificate failure - -For `ERR_CERT_AUTHORITY_INVALID (-202)` on an eligible local endpoint, replace -the generic connectivity content with: - -```text -Connection isn't secure -Orca doesn't trust the authority that issued the certificate for localhost:3000. - -For local development, use a trusted local certificate when possible. - -[Open Externally] [Retry] [Copy Address] [Proceed Anyway (Unsafe)] -``` - -- `Open Externally` is the default safe recovery action when the URL is - reachable from the desktop. -- When `Open Externally` is unavailable, `Retry` becomes the default action. -- `Proceed Anyway (Unsafe)` uses `outline`; `Copy Address` stays quiet. -- `Proceed Anyway (Unsafe)` is always visibly labeled; it is not hidden behind - a tooltip. -- The unsafe action does not use the destructive color. It does not delete or - irreversibly mutate user data, and the explicit label carries the warning. -- Use a muted `ShieldAlert` icon from `lucide-react`; do not add a warning color - or new token. - -For a remote-owned page whose URL is remote localhost, `Open Externally` is -hidden because the desktop system browser cannot reach the remote loopback -address. It remains available for a desktop-owned page or a locally forwarded -URL. - -For certificate errors that v1 does not permit bypassing, show the same -certificate-specific title with error-specific body text, `Retry`, -`Copy Address`, and an eligible `Open Externally` action. `Copy Address` remains -present as a quiet secondary action in every certificate-failure branch, -eligible or not, so only `Try HTTPS` and `Proceed Anyway (Unsafe)` toggle in and -out across cases. The same default-action rule applies as for `-202`: -`Open Externally` is the default when the URL is reachable from the desktop, -otherwise `Retry` becomes the default. Do not show the local-server-running -hint. - -Use this presentation mapping, inserting the display host into the copy: - -| Chromium code | Body copy | Proceed in v1 | -| ----------------------- | -------------------------------------------------------------------------- | ----------------------------- | -| `-200` | `The certificate doesn't match {host}.` | No | -| `-201` | `The certificate for {host} isn't valid at the current date and time.` | No | -| `-202` | `Orca doesn't trust the authority that issued the certificate for {host}.` | Eligible local endpoints only | -| Other certificate error | `Orca couldn't verify the certificate for {host}.` | No | - -Keep the raw error name/code available to diagnostics and optional details, but -do not make users interpret it to understand the primary failure. - -### Interaction behavior - -- The overlay is persistent inline UI because the user must read and act on the - failure; do not use a toast. -- The overlay does not steal focus when it appears. -- Actions are reachable by Tab and have visible focus rings. -- Use `aria-live="polite"`; repeated background failures must not repeatedly - announce the same message. -- Clicking `Proceed Anyway (Unsafe)` disables the action immediately, and also - disables the other overlay actions (`Retry`, `Try HTTPS`, `Copy Address`, - `Open Externally`) for the duration of the approval round-trip so a stale - click cannot race the controller's own `loadURL`. Re-enable them once a - success or typed-failure response returns. -- Map every `proceedCertificate` failure reason to an overlay outcome: - `expired`, `changed`, `ineligible`, and `missing` keep the overlay open and - show the inline recovery message below; `navigated` shows no message because - the overlay for that navigation is already cleared. In every non-success case - `Proceed Anyway (Unsafe)` stays disabled until a fresh pending challenge is - announced rather than re-enabling on its own. -- Delay the spinner by 200 ms, matching the style guide's remote-latency rule. - After that delay, show the canonical `Loader2` with `Connecting…` and use a - fixed button width so the label swap cannot move adjacent actions. -- If approval expired or the certificate changed, keep the overlay open and - show `The certificate changed or the approval expired. Retry the page.` -- A successful main-frame navigation clears the visible certificate failure. - -The UI must use existing `background`, `foreground`, `muted-foreground`, -`border`, and `ring` tokens plus the existing shadcn `Button` primitive. No new -color, radius, or shadow tier is required. Do not apply a blanket opacity to -the overlay's interactive content; use semantic muted text classes so button -and focus-ring contrast remains intact. Put every new user-visible string -through the existing localization catalog. - -## Architecture - -### Ownership - -The browser-owning main process is the only authority that can accept a -certificate challenge. - -- Desktop-owned `` pages are controlled by the desktop main process. -- Headless/SSH-owned pages are controlled by the remote `orca serve` main - process through `OffscreenBrowserBackend`. -- The renderer only presents a pending challenge and requests approval. It - never decides whether a certificate is trusted. - -### New main-process controller - -Add `src/main/browser/browser-certificate-trust-controller.ts` to manage pending -certificate challenges, and -`src/main/browser/browser-certificate-request-guard.ts` to manage grants plus -the session-level cached-certificate request boundary. - -The controller owns: - -```ts -type BrowserCertificateFailure = { - challengeId: string - browserPageId: string - errorCode: number | null - error: string - origin: string - displayHost: string - canProceed: boolean - observedAt: number -} - -type PendingBrowserCertificateChallenge = { - challengeId: string - guestWebContentsId: number - browserPageId: string | null - navigationSequence: number - navigationUrl: string - origin: string - secureEndpoint: string - leafCertificateSha256: string - errorCode: number | null - error: string - expiresAt: number -} - -type BrowserCertificateGrant = { - guestWebContentsId: number - secureEndpoint: string - leafCertificateSha256: string - error: string -} -``` - -Generate challenge IDs with `randomUUID()`. Compute the certificate identity in -the browser-owning main process as SHA-256 over the DER bytes of Electron's leaf -`certificate.data`; do not depend on the undocumented formatting or algorithm -of `certificate.fingerprint`. Renderer and runtime payloads expose no -certificate bytes or digest—only the challenge ID, display host, origin, error -code, error name, `canProceed`, and observation time. - -Pending challenges expire after five minutes and are bounded to 32 entries. -Accepted grants are bounded to 32 entries per browser-owning process and are -also removed with their WebContents. Bounds evict the oldest entry and notify -the affected UI when a live challenge is evicted. Ordinary cleanup should keep -both collections much smaller. Accepted endpoint identities are not evicted: -Chromium's corresponding cache cannot be cleared narrowly, so forgetting one -would reopen cross-WebContents access until process exit. - -### Certificate event handling - -Register exactly one app-level `certificate-error` listener after Electron is -ready but before either a desktop window or the offscreen backend can create a -browser page. Every branch must call Electron's callback exactly once; an -unexpected exception fails closed with `callback(false)`. - -Electron supplies the certificate error as a string on this event, while -`did-fail-load` supplies the Chromium number later. Normalize the string to one -canonical error name and map only known names to numbers for presentation. An -unknown name keeps `errorCode: null` and is never bypass-eligible. - -For every event: - -1. Resolve the WebContents through `BrowserManager` and reject unmanaged, - retired, or popup WebContents. -2. Parse the request URL, normalize its secure endpoint, and compute the leaf - certificate SHA-256 digest. Invalid data fails closed. -3. Compare the normalized error name, endpoint, and digest against an accepted - grant for this exact WebContents. On an exact match, first retain the accepted - identity in the owning Electron session, then call `event.preventDefault()` - and `callback(true)`. -4. If no grant matches, reject non-main-frame failures without creating a - challenge. Subresources and iframes can consume an existing grant but cannot - mint one. -5. For a main-frame failure, record a pending challenge only when the URL and - error are eligible. Notify the owning page when its page ID is known, then - call `callback(false)`. - -Track a monotonically increasing main-frame navigation sequence per -WebContents. A new main-frame navigation invalidates its pending challenge in -both main and renderer state before the next certificate decision. Repeated -identical events within one sequence reuse the challenge ID; a retry or a -different navigation gets a new ID. This prevents an old button or compromised -renderer from approving a page the user has already left. - -The first failure is always rejected. User approval records a grant and reloads -the page; the next certificate event is the one that succeeds. - -Do not leave Electron's callback pending while waiting for user input. Rejecting -first gives Orca a normal load-failure lifecycle, avoids an unbounded blocked -navigation, and makes stale approval cleanup deterministic. - -Do not use `session.setCertificateVerifyProc`. It applies to an entire session -partition, cannot bind a decision to one browser page, and verification results -can be cached by Chromium's network service. - -### Session request gate and Electron upgrade guard - -The pinned Electron 43 binary caches `callback(true)` broadly enough that a -second WebContents in the same partition can load the endpoint without another -`certificate-error` event. The certificate callback alone is therefore not an -isolation boundary. - -Install exactly one `webRequest.onBeforeRequest` listener on every Orca browser -session before its first page loads. Electron permits only one listener for this -event, so the browser session registry owns installation and removal. Once an -endpoint identity has been accepted, the listener blocks every HTTPS/WSS -request to that endpoint unless `details.webContentsId` has the matching grant. -Requests without a WebContents ID, including background/worker traffic, fail -closed. A blocked main-frame request recreates the challenge and certificate -load error; other resource types are canceled without replacing the current -page's overlay. - -The real-Electron integration suite must prove same-tab reload, same-endpoint -HTTPS assets and WSS, different-tab documents and subresources, and -different-port isolation against the exact Electron version in -`pnpm-lock.yaml`. Any Electron upgrade that breaks those tests blocks release -until the design is revised; unit mocks are insufficient. - -### BrowserManager integration - -Add a narrow BrowserManager query API instead of exposing its internal maps: - -```ts -getManagedBrowserGuestContext(webContentsId: number): { - browserPageId: string | null - worktreeId: string | null - sessionProfileId: string | null - owner: 'desktop-webview' | 'offscreen' -} | null -``` - -For guest lifecycle, follow the existing extracted-controller precedent -(`browser-grab-session-controller.ts`, `browser-guest-ui.ts`, -`browser-download-destination.ts`), all of which BrowserManager drives by -calling a plain method on the controller directly from `registerGuest` / -`unregisterGuest`. Have BrowserManager call -`certificateTrustController.onGuestRegistered({ browserPageId, webContentsId })` -and `.onGuestRetired(webContentsId)` from those same bodies, rather than adding a -new listener/emitter registration API to BrowserManager — there is no such -pub-sub surface in `src/main/browser` today and this single consumer does not -warrant introducing one. - -`getManagedBrowserGuestContext` must recognize an attached primary desktop -guest before renderer registration, returning a null page ID, while rejecting -popup descendants that merely inherited browser policies. It must also -recognize offscreen pages after `registerOffscreenGuest` and stop recognizing a -guest immediately when BrowserManager retires or unregisters it. The controller -re-checks this ownership on every certificate event even if it still has an -in-memory grant. - -An attached desktop webview can encounter a certificate error before its -document reaches `dom-ready`, which is when BrowserPane currently registers the -guest. Keep pending challenges keyed by WebContents ID until registration -supplies the page ID, then flush the failure to the renderer. - -BrowserPane should also register idempotently on the webview's `did-attach` -event, with `dom-ready` retained as a fallback. `registerGuest` should report -whether the main process accepted the registration so a rare attach-policy race -can retry at `dom-ready`; the renderer must not cache the WebContents ID as -registered until it receives `true`. - -Offscreen pages already register before `loadURL`, so their page ID is normally -available when the certificate event fires. Bring their main-frame navigation -and `did-fail-load` observation under the same BrowserManager lifecycle used by -desktop guests; the current offscreen loader only logs a rejected `loadURL`, so -it cannot by itself populate runtime failure snapshots. - -### Renderer state - -Certificate challenges are transient runtime state and must not be added to -the persisted `BrowserPage` schema. - -Add a transient map to the browser store: - -```ts -browserCertificateFailuresByPageId: Record -``` - -The workspace session serializer must omit this map. Clear an entry when: - -- the page completes a successful main-frame navigation; -- the user starts a different navigation; -- the page or workspace closes; -- the owning browser WebContents is replaced; or -- the main process reports that the challenge expired. - -The existing `BrowserLoadError` remains the persisted diagnostic source. Add a -pure Chromium-error classifier for Electron 43's certificate range (`-200` -through `-219`, excluding the unused values) so certificate failures still -render accurate copy after restore even when no live challenge exists. A live -certificate failure's error code takes presentation precedence over a -synthesized `-1` Chromium-error-page fallback. Independently, only a live, -matching challenge controls whether `Proceed Anyway (Unsafe)` is enabled. - -Use both the page ID and attempted origin when combining the live challenge -with `BrowserLoadError`. A stale challenge from a prior navigation must never -add a proceed action to a different failure. Main-process navigation-sequence -invalidation is the security boundary; renderer cleanup is defense in depth and -keeps the UI honest. - -### Local IPC - -Extend the browser preload API with: - -```ts -onCertificateFailureChanged( - callback: (event: { - browserPageId: string - failure: BrowserCertificateFailure | null - }) => void -): () => void - -proceedCertificate(args: { - browserPageId: string - challengeId: string -}): Promise< - | { ok: true } - | { - ok: false - reason: 'expired' | 'changed' | 'ineligible' | 'missing' | 'navigated' - } -> -``` - -Register the handler with the existing browser IPC group and reuse -`isTrustedBrowserRenderer`. The main process resolves the page to its current -WebContents and then consumes the exact pending challenge. Renderer-provided -origin, error code, or certificate identity are never trusted. - -On success, main consumes the challenge, records the grant, and calls -`loadURL` with the challenge's main-owned `navigationUrl`. It must not use -`webContents.getURL()`, which may be `chrome-error://chromewebdata/` or a -previously committed page. It also must not reload if the page's current -navigation sequence no longer matches the challenge. The renderer does not -assign `webview.src` as a second side effect. - -### Remote runtime RPC - -The remote browser owner exposes equivalent behavior through: - -```text -browser.certificate.proceed -``` - -with the runtime worktree selector, remote browser page ID, and challenge ID. -Advertise support through a new `browser.certificate-trust.v1` capability only -when the runtime has both a browser backend and the certificate controller. -Keep the method out of the mobile-scope RPC allowlist; it is for an -authenticated runtime-scope Orca client presenting browser chrome, not a guest -page or unauthenticated caller. - -Extend `RuntimeMobileSessionBrowserTab` compatibly with optional `loadError` -and `certificateFailure` fields. Both are emitted from live runtime state. -`loadError` keeps the existing `BrowserPage` last-known diagnostic semantics, -including workspace persistence; `certificateFailure` is transient and is -never written to workspace persistence. Controller changes, challenge expiry, -navigation, and offscreen `did-fail-load` must mark the runtime session snapshot -dirty so clients do not wait for unrelated state to change. Web clients and -desktop clients reconcile these fields through the existing remote page-handle -mapping. Each full snapshot replaces the prior transient certificate failure -for that page, including clearing it when the field is absent. An older runtime -without the capability always clears any previously mirrored proceed action. - -When the active page has a remote owner, BrowserPane routes approval to that -environment. It must not call local desktop IPC. If the runtime lacks the new -capability, the UI still classifies the certificate failure accurately but does -not render `Proceed Anyway (Unsafe)`. `Try HTTPS`, retry, and other navigation -actions continue through the existing owner-aware navigation path rather than -touching a local webview for a remote-owned page. - -### Scheme recovery - -Add pure functions in the shared browser URL domain: - -```ts -classifySchemeLessLocalDevAddress(rawInput: string): URL | null -isEligibleLocalCertificateHost(hostname: string): boolean -toHttpsRecoveryUrl(rawUrl: string): string | null -``` - -`classifySchemeLessLocalDevAddress` preserves the exact current HTTP-default -input set and is shared by address-bar normalization and tab-create entry. It -is deliberately broader than certificate eligibility. - -`isEligibleLocalCertificateHost` accepts a parsed/canonical hostname; normalize -case and an optional trailing dot and handle the brackets returned by -`URL.hostname` for IPv6. Validate IPv4 octets and DNS label boundaries instead -of using a substring or loose suffix check. - -`toHttpsRecoveryUrl` returns a value only for an HTTP URL with an eligible -loopback host. It changes `protocol` to `https:` on a parsed `URL` object and -preserves credentials, hostname, an explicit non-default port, path, query, and -fragment. When no port is present, including when the parsed HTTP URL normalized -`:80` away, the result uses HTTPS's default port 443. It never probes either -protocol. - -Both address-bar submission and tab-create entry use the shared local-address -classifier. Existing advertised workspace-port URLs remain authoritative and -require no new probing. - -## Security Model - -### Invariants - -- Default certificate verification remains strict. -- Only a trusted Orca renderer or authenticated runtime-scope Orca client can - request approval. -- The renderer cannot choose the origin, certificate digest, error, - WebContents, or eligibility result. -- A grant is exact-match only and never applies across tabs, ports, - certificates, or browser-owning processes. -- Session-cached accepted endpoints remain request-gated until process exit, - even after their original WebContents or bounded grant is removed. -- Certificate PEM/DER data and certificate digests do not enter workspace - state, history, telemetry, or ordinary logs. -- Subresource and iframe certificate failures cannot mint an approval action; - they can consume an already-approved exact-endpoint grant on the same - WebContents. -- The proceed affordance exists only in Orca chrome; guest content has no - direct approval channel. Main-process challenge validation remains the - security boundary and does not assume proof of a physical click. -- A remote client cannot approve a certificate in a different runtime - environment. -- Starting another main-frame navigation invalidates the pending approval in - the browser-owning process, not only in renderer state. - -### Threat cases - -| Threat | Required behavior | -| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -| Compromised web page sends forged IPC | Guest WebContents has no preload API; trusted-renderer check rejects it. | -| Compromised renderer invents a challenge | Main consumes only a server-created pending challenge tied to the current page and WebContents. | -| Certificate changes after approval | Leaf-digest mismatch rejects; a second untrusted leaf for that endpoint cannot be bypassed until restart. | -| Same host uses a different port | Secure-endpoint mismatch rejects. | -| Another tab visits the approved endpoint | WebContents mismatch rejects. | -| Approved tab later loads a foreign top document | Grant is invalidated when the main frame navigates to a different endpoint; the new document cannot reuse it. | -| Challenge is replayed | Challenge ID is single-use and expires. | -| User leaves the failed page before clicking | Navigation-sequence mismatch rejects without reloading the stale URL. | -| Approved page opens same-endpoint WSS/HMR | Existing endpoint grant permits it; WSS cannot create a grant by itself. | -| Public site presents an untrusted certificate | Host eligibility fails; no proceed action is available. | -| Remote page approval is sent locally | Ownership routing and remote page handles select the browser-owning runtime. | -| App restarts after approval | No persisted grant exists. | - -## Failure Handling - -- If a certificate event arrives before guest registration, retain it by - WebContents ID and flush after registration. -- If guest registration never completes, expire the pending challenge without - notifying unrelated pages. -- If `Proceed` arrives after expiry, navigation, or WebContents replacement, - return a typed failure and leave strict verification in place. -- If reload fails for a different reason, clear the certificate challenge and - present the new load error. -- If the same rejected challenge fires repeatedly in one navigation, coalesce - it without rotating the challenge ID or stacking announcements. -- If the certificate-error listener cannot parse the URL or leaf certificate, - reject normally and do not offer approval. -- If the remote connection drops during approval, keep the local UI stable and - surface the runtime error inline after the latency threshold. -- If an unexpected controller error occurs, call Electron's callback once with - `false`; never leave the request suspended. -- If an accepted main-frame page later opens a new same-endpoint TLS connection, - apply the exact grant to that HTTPS/WSS subrequest. Reject unmatched - subrequests silently without replacing the main-frame overlay. -- If an accepted endpoint presents a different untrusted leaf, reject it without - replacing the process-lifetime accepted identity or offering a second bypass. - -## Data and Privacy - -No migration is required. - -Add no new product analytics in v1. Do not record URLs, certificate digests, -challenge IDs, certificate subjects, or approval results. If product analytics -becomes a requirement later, define coarse actions in the existing typed -feature-interaction catalog as a separate reviewed change. - -Existing diagnostics may retain the Chromium numerical error code, as they do -for other browser load failures. Debug logging must contain at most the error -name, page ID, owner kind, and normalized loopback host class; omit query -strings, full URLs, and certificate digests. - -## Test Plan - -### Shared URL tests - -- Explicit HTTP and HTTPS local URLs remain unchanged. -- Scheme-less `localhost`, IPv4 loopback, and IPv6 loopback still normalize to - HTTP. -- Tab-create and address-bar local-address classification stay in parity for - the full legacy set, including wildcard and bracketed IPv6 inputs. -- The broader scheme-less classifier is not accidentally substituted for the - stricter certificate-eligibility predicate. -- `toHttpsRecoveryUrl` preserves host, an explicit non-default port, path, - query, and fragment; a URL with no parsed port changes from HTTP's default 80 - to HTTPS's default 443. -- `Try HTTPS` is unavailable for HTTPS, file URLs, public hosts, wildcard bind - hosts, private LAN hosts, and invalid URLs. -- Loopback eligibility covers `localhost`, trailing-dot localhost, - `.localhost` subdomains with and without a trailing dot, the full valid IPv4 - 127/8 range, and bracketed or unbracketed `::1` after URL parsing. -- Reject malformed IPv4, arbitrary bracketed IPv6, `0.0.0.0`, `::`, and names - that only contain the word `localhost`. - -### Main-process trust-controller tests - -- Unmanaged, retired, and popup WebContents are rejected without pending - challenges. -- A managed `-202` loopback failure creates one pending challenge and calls - `callback(false)`. -- Every event path calls its callback exactly once; only an accepted grant calls - `preventDefault()` and `callback(true)`. -- The digest is SHA-256 over DER leaf bytes and is stable across PEM formatting. -- An exact accepted grant covers same-endpoint HTTPS assets, WSS, and iframes on - the same WebContents without allowing those requests to mint a challenge. -- Wrong page, WebContents, origin, port, certificate digest, error type, and - challenge ID fail closed. -- A token is single-use and expires after five minutes. -- Repeated identical failures in one navigation coalesce; a new navigation - invalidates the token and rotates the challenge ID. -- Approving after navigation returns `navigated` and never reloads the old URL. -- Approval loads the main-owned challenged URL, not the current Chromium error - URL or a renderer-supplied URL. -- Certificate rotation fails closed and cannot replace the process-lifetime - accepted endpoint identity. -- WebContents destruction removes pending challenges and grants. -- Bounds evict the oldest pending challenge/grant. -- Registering a page after an early certificate event flushes the event to the - correct renderer. -- Concurrent tabs and session profiles remain isolated. - -### IPC and runtime tests - -- Browser certificate IPC rejects untrusted senders and malformed arguments. -- Renderer-supplied origin or certificate-identity fields are neither accepted - nor used. -- Local approval reloads only the currently mapped guest. -- Remote approval calls the owning environment's RPC with the remote page ID. -- Local IPC is not called for a remote-owned page. -- Mobile-scope runtime clients cannot call the approval method. -- Older runtimes without `browser.certificate-trust.v1` never show an enabled - proceed action. -- Runtime snapshots carry live load errors and certificate failure state, - publish immediately on changes, and do not add certificate challenges to - persisted session schemas. -- A snapshot that omits or clears a challenge removes the mirrored proceed - action rather than leaving a stale one behind. -- Two runtime environments with identical page IDs cannot approve each other's - challenge. - -### Renderer tests - -- `-202` renders certificate-specific copy and no server-running hint. -- A live certificate challenge still wins when Chromium's error-page fallback - produced diagnostic code `-1`. -- A live eligible challenge renders `Proceed Anyway (Unsafe)`. -- A restored `-202` load error without a live challenge remains accurate but - cannot proceed. -- Other certificate errors render accurate copy without v1 bypass. -- Local HTTP failures render `Try HTTPS`; HTTPS and non-loopback failures do not. -- Starting a new navigation clears stale certificate state. -- Success clears the overlay. -- Proceed disables immediately, delays the spinner, and preserves its width. -- Expired/changed responses show the inline recovery message. -- Keyboard focus and `aria-live` behavior do not regress the address bar. -- `Copy Address` remains available, interactive content is not blanket-dimmed, - and all new strings are present in the localization catalog. - -### Electron integration - -Run a local HTTPS server with a generated certificate whose SAN includes -`localhost` but whose authority is not trusted. - -1. `https://localhost:` fails with `-202` and shows certificate copy. -2. `Proceed Anyway (Unsafe)` reloads and renders the page. -3. HTTPS assets and a WSS echo/HMR endpoint on the same host and port load after - approval; a different-port asset remains blocked. -4. Reload in the same tab remains allowed without broadening the grant. -5. A second tab using the same session profile, origin, and certificate remains - blocked. This is a release-blocking assertion against the pinned Electron - binary, not a mocked controller test. -6. Navigating away before clicking makes the old challenge unusable and does - not pull the tab back to the failed URL. -7. Replacing the certificate keeps the original tab blocked and offers no - second bypass until the browser-owning process restarts. -8. Closing and recreating the browser surface removes the grant. -9. Restarting Orca removes the grant. -10. A system-trusted local certificate loads without an interstitial. -11. A public/non-loopback untrusted origin has no proceed action. -12. A hostname/SAN mismatch shows certificate copy but no proceed action. -13. `localhost:` first attempts HTTP and its failure offers `Try HTTPS`. - -Repeat the ownership-sensitive cases for: - -- a local desktop webview; -- an SSH local port forward opened in the desktop browser, using a certificate - whose SAN matches the browser-facing forwarded hostname; and -- a headless/offscreen browser page owned by `orca serve` under Linux/Xvfb. - -## Implementation Plan - -1. Add the shared scheme-less local classifier, strict certificate-host - predicate, secure-endpoint canonicalizer, and HTTPS-recovery function with - focused tests. -2. Consolidate address-bar and tab-create local classification without changing - current scheme defaults. -3. Add BrowserManager ownership/registration/retirement hooks, early - `did-attach` registration with a `dom-ready` fallback, and offscreen load - failure observation. -4. Add shared certificate failure/result types and the main-process trust - controller, including navigation-sequence invalidation and the app-level - listener. Its certificate-event handling resolves ownership through the - BrowserManager hooks from step 3, so those must land first. -5. Add local IPC, preload APIs, and transient renderer state. -6. Add the certificate-specific classification and copy as new exported - functions/branches in the existing `browser-notices.ts` (which already owns - load-failure copy via `formatLoadFailureDescription` / - `formatLoadFailureRecoveryHint`), and extract only the overlay JSX into a - focused `browser-load-failure-overlay.tsx` module instead of growing the - already grandfathered `BrowserPane.tsx`; do not add or extend a max-lines - disable. -7. Add certificate-specific and `Try HTTPS` UI, preserve existing recovery - actions, and update the localization catalog. -8. Add the offscreen/runtime capability, live snapshot fields and publication, - and approval RPC. -9. Add unit, IPC, renderer, runtime, and real-Electron integration coverage. -10. Run formatting, typecheck, lint, localization verification, targeted tests, - and cross-platform manual validation. - -## Rollout - -No feature flag is required for accurate error copy or `Try HTTPS` because both -paths preserve strict certificate verification. - -The proceed path should ship only after desktop and offscreen ownership tests -pass. If remote runtime support cannot land in the same release, gate the -button on `browser.certificate-trust.v1`; local desktop support may ship while -older/remote owners retain accurate error copy and trusted-CA guidance. - -Do not close issue #8454 until a packaged build has been validated against a -real self-signed local HTTPS server. Unit mocks alone do not establish Electron -certificate-event ordering. - -## UI Quality Bar - -- Follow `docs/STYLEGUIDE.md` and the adjacent browser failure overlay. -- Use only existing semantic tokens, shadcn buttons, and lucide icons. -- Keep the overlay quiet and monochrome; certificate failure must not introduce - an amber warning treatment. -- Copy must distinguish connection, DNS, and certificate failures without - claiming more than the Chromium error proves. -- Buttons must remain on one line at supported pane widths or wrap as one - deliberate group without overlap. -- Validate light/dark mode, macOS/Windows/Linux font metrics, and 200 ms remote - latency. -- Capture review evidence for local HTTP failure, eligible certificate failure, - ineligible certificate failure, accepted certificate, and the same browser - chrome after recovery. - -## Release Gates and Open Risks - -- The pinned Electron binary must prove that the session request gate blocks a - second tab, sibling HTTPS subresources/WSS, and a second port even when - Chromium does not re-emit `certificate-error`. If any isolation assertion - fails, omit the proceed path or redesign it around a dedicated ephemeral - partition; do not silently widen the grant. -- Same-endpoint WSS and fresh HTTPS subresource connections must work after - approval. A main document that renders while HMR/API traffic remains blocked - does not fix the local-development use case. -- A remote/headless certificate failure must reach the client as both accurate - load-error copy and a live challenge. Logging a rejected offscreen `loadURL` - is not sufficient. -- Stale approval must be rejected in the browser-owning process after any new - main-frame navigation, including when renderer cleanup or a remote snapshot - is delayed. -- SSH-forward validation must use a certificate whose SAN matches the - browser-facing hostname. Authority bypass must not mask a hostname mismatch. -- The app-level listener and real-certificate tests must pass on macOS, Windows, - and Linux/Xvfb. Platform trust-store differences do not justify enabling a - broader error allowlist. - -## Review Decisions and Follow-ups - -### From 2026-07-12 review - -- The integrated browser is part of the development workflow (including - automation, annotations, and remote ownership), so `Open Externally` and trusted - local-certificate guidance remain safer alternatives but do not replace an - explicit in-browser decision. -- V1 intentionally limits approval to `ERR_CERT_AUTHORITY_INVALID (-202)`. - Hostname/SAN, date, revoked, and malformed-certificate failures remain blocked; - broadening that set requires a separate security review and real-certificate - tests. -- Raw error details remain available to existing diagnostics rather than adding a - new disclosure affordance to the quiet recovery overlay. diff --git a/docs/reference/direct-url-or-file-entry.md b/docs/reference/direct-url-or-file-entry.md deleted file mode 100644 index 8664965147f..00000000000 --- a/docs/reference/direct-url-or-file-entry.md +++ /dev/null @@ -1,146 +0,0 @@ -# Direct URL Or File Entry - -## Problem - -The tab bar `+` menu only offers fixed actions: terminal, browser, new markdown, and open markdown in `src/renderer/src/components/tab-bar/TabBar.tsx`. It has no text entry point for a user who already knows the URL or file path they want. - -Quick Open already loads files for the active worktree through `listRuntimeFiles`, watches the active SSH target status, excludes nested linked worktrees, ranks via `prepareQuickOpenFiles`/`rankQuickOpenFiles`, and opens a selected match with `openFile`. That flow is modal and file-only. It does not live in the `+` menu, does not accept URLs, and cannot create a named new file from the typed query. - -The runtime file client has the required local/SSH/runtime primitives, with caveats: - -- `listRuntimeFiles(context, { rootPath, excludePaths })` returns relative paths only and can fail for auth, missing provider, ripgrep/size, or stale worktree reasons. -- `statRuntimePath(context, absolutePath)` is a one-path existence/type check. -- `createRuntimePath(context, absolutePath, 'file')` creates a single empty file and creates parent directories on the current local, SSH, and runtime-backed paths. Directory creation has separate no-clobber/recursive semantics elsewhere in the file stack, so v1 should create files directly and not expose directory creation as a separate action. - -## Goal - -Add an entry field at the top of the tab bar `+` menu so users can type: - -1. A URL to open an Orca browser tab. -2. An existing file name/path to open an editor tab. -3. A new relative file path to create in the active worktree and open. - -The behavior must work from both the titlebar tab strip and split-group tab strips. File operations must route through `RuntimeFileOperationArgs`; browser/editor creation must preserve the target group. - -## Non-goals - -- Do not replace global Quick Open. -- Do not add persisted launcher state. -- Do not support standalone directory creation. -- Do not open external URLs outside Orca. -- Do not make URL/file detection configurable. - -## Design - -1. Add a `TabBarCreateEntry` surface inside `TabBar`'s dropdown content above the fixed rows. Use the existing `DropdownMenu` shell, but render the input in a plain form/container, not as a `DropdownMenuItem`; Radix menu item typeahead/selection should not own text input keystrokes. Stop propagation only where required for input typing, and close the dropdown only after a successful submit. - -2. Extend `TabBarProps` with a presentation callback that resolves success by returning and failure by throwing: - - ```ts - onOpenEntry?: (args: { query: string; worktreeId: string; groupId: string }) => Promise - ``` - - `TabBar` owns input text, pending state, focus, and dropdown close behavior. It does not create browser tabs or files. `groupId` should be `groupId ?? worktreeId`, matching the existing `resolvedGroupId` fallback. - -3. Add a small hook/helper pair instead of duplicating Quick Open logic: - - `useTabEntryFileList` loads the file list when the menu opens, using the same inputs as `QuickOpen`: active worktree path, `getConnectionId(worktreeId)`, nested worktree exclusions, and active SSH target status. It cancels stale requests on close or key changes. - - A pure classifier/opening helper accepts the query, file-list snapshot, load/error state, worktree metadata, runtime context, and target group. - -4. Wire the callback in both owners: - - `Terminal.tsx` titlebar fallback resolves the current active worktree and target group the same way `handleNewTab` / `handleNewBrowserTab` do. - - `useTabGroupWorkspaceModel` passes its explicit `worktreeId` and `groupId`. Do not rely on ambient `activeGroupIdByWorktree`; split-group `+` can be invoked from an unfocused group. - -5. Classify submissions in this order: - - Empty after trim: reject inline. - - Explicit URL: accept only `http://` and `https://` URLs with a parseable host. - - Existing file: once the file-list snapshot is ready, normalize query separators for matching, prefer exact relative-path match, then exact basename match, then `rankQuickOpenFiles`. Before opening, `statRuntimePath` the matched absolute path and reject directories/stale missing matches instead of blindly opening stale list entries. - - Host-like URL: only after there is no existing file match, accept strict bare hosts such as `example.com`, `localhost:3000`, or `127.0.0.1:3000`, normalized to `https://...` when no scheme is present. Do not run host-like URL parsing for bare input containing `/` or `\`; `new URL('https://docs/readme.md')` parses, so parsing alone is not a path/file guard. Also reject common source/document filename extensions such as `md`, `ts`, `tsx`, `js`, `jsx`, `json`, `yml`, `yaml`, `toml`, `css`, `html`, and `py` so `README.md` and `src/foo.test.ts` stay file/create candidates. - - New file: only after file listing has completed successfully with no existing-file match and no host-like URL match. Treat the query as a relative worktree path. If listing fails, allow explicit `http://` / `https://` URLs only; keep bare host-like inputs blocked because they cannot be disambiguated from files. - -6. Validate new file paths before joining: - - Reject POSIX absolute paths, Windows drive paths, UNC paths, `~`, empty path, trailing slash, control characters, `.` / `..` segments, and empty raw segments such as `a//b`. - - Normalize `\` to `/` only after absolute-path checks, then run segment validation on the normalized path too so traversal like `a\..\b` is still rejected. - - Build the absolute path with `joinPath(worktreePath, relativePath)`, then create with `createRuntimePath(context, absolutePath, 'file')`. - - On `EEXIST` / "exists", immediately `statRuntimePath`; if it is now a file, open it. If it is a directory, show an error. This handles another window/process winning the create race. - -7. Open actions: - - URL: in paired web clients, call `createWebRuntimeSessionBrowserTab({ worktreeId, url, targetGroupId: groupId })`; otherwise call `createBrowserTab(worktreeId, url, { activate: true, targetGroupId: groupId, title: url })`. Do not use `openNewBrowserTabInActiveWorkspace`; it only opens the default URL. - - Existing/new file: call `openFile(fileInfo, { preview: false, targetGroupId: groupId })` with `language: detectLanguage(relativePath)`. Include the active runtime environment owner as `runtimeEnvironmentId` through the normal `openFile` fallback; do not suppress runtime ownership. - -8. Preserve current fixed actions. Existing `New Terminal`, `New Browser Tab`, `New Markdown`, `Open Markdown...`, and quick-launch rows remain below the entry and keep their current shortcuts/icons. - -9. Surface errors with existing toast or compact inline text. Keep the dropdown open and preserve the query on validation/runtime errors. For Quick Open's special ripgrep guidance, either extract its parser/UI deliberately or show the cleaned error string; do not duplicate a private parser inline. - -## Data Flow - -- User opens the `+` menu. -- `TabBarCreateEntry` focuses the input and `useTabEntryFileList` starts/reuses the menu-local file-list request. -- User types; the menu may show the best existing-file match or "create file" affordance based on the current snapshot. -- Enter calls `onOpenEntry({ query, worktreeId, groupId })`. -- Helper classifies and dispatches: - - URL -> browser tab creation with target group. - - Existing file -> stat matched path -> `openFile(..., { targetGroupId })`. - - New file -> validate -> `createRuntimePath(..., 'file')` -> `openFile(..., { targetGroupId })`. -- Success closes the dropdown. Failure keeps it open. - -## Edge Cases - -- No active worktree: disable the entry, including URL entry, because Orca browser tabs are worktree-scoped. -- SSH/runtime connection not ready: mirror Quick Open by keying the list request on active target status. Non-URL submissions are disabled while loading/connecting to avoid creating a duplicate before the real list arrives. -- File listing failure: explicit `http://` / `https://` URL submissions still work; file and bare host-like submissions are blocked with the cleaned list error. -- Ambiguous host-like/file names: an existing listed file wins over bare host-like URL normalization. Explicit `http://` or `https://` input is the escape hatch when the user wants a browser tab despite a file-name collision. -- File list stale because another window/process added or removed a file: stat before opening matched files; handle create `EEXIST` by stat-and-open. -- Existing directory match: show an error; do not open as an editor tab. -- Internal spaces in file paths are allowed. Leading/trailing whitespace is trimmed. Control characters are rejected. -- Windows-style separators match existing relative paths after normalization, but Windows absolute and UNC paths are rejected for creation. -- Duplicate Enter while pending is disabled. -- Browser creation in paired web/mobile clients must use `createWebRuntimeSessionBrowserTab`; local desktop uses `createBrowserTab`. -- External file-watch invalidation is not required for v1. The menu-local list reloads on each open and on worktree/connection/status changes; successful create can close the menu without mutating the list. - -## Test Plan - -- Unit test URL classification: schemes, host-like domains, localhost/IP ports, listed file named `example.com` winning over host-like normalization, `README.md`/`readme.md`, `src/foo.test.ts`, `docs/readme.md`, whitespace, and invalid schemes. -- Unit test path validation: Windows/POSIX absolute paths, UNC, `~`, traversal, empty segments, trailing slash, control characters, spaces, and separator normalization. -- Unit test existing-file selection with `prepareQuickOpenFiles`/`rankQuickOpenFiles`: exact path beats basename, basename beats fuzzy, stale stat failure blocks open, directory stat blocks open. -- Unit/helper test new-file creation with `RuntimeFileOperationArgs`, `createRuntimePath`, `statRuntimePath`, EEXIST stat-and-open, and SSH/runtime connection context. -- Component test `TabBar`: input renders above fixed rows, focuses on open, typing does not close the menu, Enter awaits the callback, success closes, failure preserves text, fixed actions still fire. -- Store/model tests: titlebar and split-group callbacks pass `targetGroupId`; URL creation uses `createWebRuntimeSessionBrowserTab` in web runtime and `createBrowserTab` otherwise. -- Electron validation: open URL, open existing file by exact path/name, create nested new file, invalid traversal/absolute path error, and smoke-test existing menu actions. - -## UI Quality Bar - -- Follow `docs/STYLEGUIDE.md` and existing `DropdownMenu`/`Input` tokens. Do not add custom colors, shadows, or a modal-like panel inside the menu. -- The input is the first focus target and must not break menu keyboarding. -- The menu remains compact; loading, match preview, create preview, and error rows fit at the current menu width without overlap or awkward height jumps. -- Long typed paths scroll/truncate within the input; fixed rows retain icons, shortcuts, hover states, and dense spacing. - -## Review Screenshots - -Attach evidence to the PR conversation; do not commit images. - -1. `+` menu open in a normal workspace with the entry focused and fixed rows visible. -2. Typed URL state. -3. Typed existing-file query with visible best match. -4. Typed new-file path/create state. -5. Rejected absolute/traversal path error. -6. Adjacent-feature smoke: fixed menu rows still visible and aligned. - -## Rollout - -1. Add classifier/path-validation helper and tests. -2. Add menu-local file-list hook by extracting the reusable Quick Open loading inputs, not by copying private UI-only parsing. -3. Add `TabBarCreateEntry` and wire it into `TabBar`. -4. Add titlebar and split-group callbacks. -5. Add component/store tests. -6. Run typecheck, lint, targeted tests, UI review, and Electron validation with screenshots. - -## Lightweight Eng Review - -- Scope: Correctly scoped to the tab bar `+` menu. The implementation must not mutate Quick Open behavior except for extracting reusable search/listing code. -- Architecture/data flow: Good if `TabBar` stays presentational and all worktree/runtime/group decisions live in owners plus a shared helper. The original `onOpenEntry(query, groupId?)` shape was under-specified; include `worktreeId` and a resolved `groupId`. -- Failure modes: Must explicitly handle loading list, failed list, stale list, directory matches, create races, no active worktree, no SSH provider yet, and paired web runtime browser creation. -- Feasibility: Specific URL browser tabs cannot go through `openNewBrowserTabInActiveWorkspace` because that action uses the default URL. `createRuntimePath(..., 'file')` is feasible and creates parent directories for current local/SSH/runtime paths, but do not expose recursive directory creation as a separate v1 behavior. -- Concurrency/invalidation: A menu-local file list is acceptable if every matched file is statted before open and `EEXIST` is handled on create. No global cache is needed. -- Performance/blast radius: Listing on menu open has Quick Open's cost class, but the menu is more casually opened than the modal; cancel stale loads and key requests tightly by worktree path, connection, exclusions, and SSH status. -- Tests: Add pure helper tests first; then component and store/model tests. Electron screenshots are required because Radix menu focus/input behavior is the highest-risk UI part. -- Residual risk: Host-like URL heuristics can surprise users. Keep the heuristic narrow and prefer file matches over host-like normalization whenever the query contains path separators or matches a listed file. diff --git a/docs/reference/feature-discovery-interaction-tracking.md b/docs/reference/feature-discovery-interaction-tracking.md deleted file mode 100644 index a9e9eb1a46f..00000000000 --- a/docs/reference/feature-discovery-interaction-tracking.md +++ /dev/null @@ -1,68 +0,0 @@ -# Feature Discovery Interaction Tracking - -This document defines local feature-interaction state used to decide whether Orca should still teach a feature with an education surface such as a tour or feature tip. - -## Decision - -Track first meaningful interaction plus local interaction count for education-targeted features in `PersistedUIState.featureInteractions`. - -Do not upload this state as broad analytics. Product analytics should continue to use bounded telemetry events and downstream product events. Local interaction state answers a different question: "Has this user already found enough of this feature that education would be redundant?" - -## Rules - -- Add a `FeatureInteractionId` in `src/shared/feature-interactions.ts` before using it. -- Preserve `firstInteractedAt`, and increment `interactionCount` on each later meaningful interaction. -- Prefer explicit actions over passive visibility. -- Passive visibility is acceptable only when opening the surface is itself the product use, such as opening Tasks. -- Record after persisted UI is hydrated by using `recordFeatureInteraction(...)`; it no-ops before hydration. -- Keep IDs stable. If the meaning changes materially, add a new ID. -- Do not include user text, paths, URLs, repo names, branch names, hostnames, commands, prompts, or tokens in this state. -- Old records without `interactionCount` hydrate as `interactionCount: 1`. - -## Feature Catalog - -| Feature | Interaction ID | Record when | Education use | -| ------------------------------ | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| Review notes to agent | `review-notes` | A diff or markdown review note is added, or review notes are marked sent to an agent. | Suppress or target a future review-notes tour/tip about adding line notes and sending focused feedback back to an agent. | -| AI commit generation | `ai-commit-generation` | AI commit-message generation is enabled or an AI commit message is generated. | Suppress future education about AI commit generation. No contextual tour is planned for this branch. | -| AI PR generation | `ai-pr-generation` | AI pull-request title/body/draft fields are generated. | Suppress future education about AI PR generation. No contextual tour is planned for this branch. | -| Floating Workspace | `floating-workspace` | The floating workspace opens, is enabled, or is configured. | Keep the existing Floating Workspace tour, and suppress future education about the global terminal/browser/markdown workspace. | -| Quick Commands | `quick-commands` | A terminal quick command is created or edited. | Suppress future tips about saved terminal commands. No contextual tour is planned for this branch. | -| Computer Use setup | `computer-use-setup` | Computer Use was selected in legacy onboarding, a permission setup is opened, or the skill setup terminal opens. | Suppress setup-focused tips once the user has started setup. | -| Computer Use | `computer-use` | A successful `computer.*` runtime method other than capability probing is handled. | Suppress future usage tips once an agent has actually invoked Computer Use. | -| Mobile pairing | `mobile-pairing` | Mobile is enabled or a mobile pairing QR/code is generated. | Suppress future mobile-pairing tips. No contextual tour is planned for this branch. | -| Browser element grab | `browser-grab` | Browser grab mode is started, an element is copied, or an element screenshot is copied. | Suppress future tips about grabbing page context once the user has used the element picker. | -| Browser annotations | `browser-annotations` | Browser annotation mode is started, an annotation is added, browser annotations are copied, or browser annotations are cleared. | Suppress future tips about annotating local pages and sending concrete UI feedback to an agent. | -| Cookie import | `cookie-import` | Browser cookies are imported from another browser or file, imported through runtime browser profile methods, or default imported cookies are cleared. | Suppress future cookie-import education without conflating cookie setup with Agent Browser Use itself. | -| Workspace board actions | `workspace-board-actions` | A card/status action, lane configuration, density change, pin drop, or board drag action is used. | Keep the existing workspace-board tour, and avoid repeating deeper board-action education after real use. | -| Automation creation | `automation-created` | A local automation or external Hermes cron is created. | Keep the existing Automations tour, and suppress creation-focused education after the user creates one. | -| Automation run | `automation-run` | A local or external automation run is manually queued. | Keep the existing Automations tour, and suppress run/inspection education after the user queues a run. | -| Resource Manager | `resource-manager` | The Resource Manager status-bar popover is opened, or its status-bar visibility is toggled in Appearance/status-bar controls. | Suppress future tips about CPU, memory, session, daemon, and workspace disk-scan controls after the user has found the manager. | -| Workspace cleanup / disk space | `workspace-cleanup` | The Space page opens, a workspace disk scan starts/cancels, or cleanup removes scanned workspace rows. | Suppress future tips about scanning workspace disk usage and reclaiming old workspace storage. | -| Ports | `ports` | The Ports popover opens, the Ports status-bar item is configured, external ports are expanded, or a port is opened/copied/stopped. | Suppress future tips about discovering and acting on workspace ports. | -| SSH | `ssh` | SSH status opens, SSH status-bar visibility changes, a target is added/imported/tested, or an SSH target is connected/disconnected. | Suppress future SSH setup/status tips after the user has interacted with remote target controls. | -| Provider usage tracking | `usage-tracking` | Stats & Usage is opened, Claude/Codex/OpenCode usage analytics are enabled, provider usage details are opened from the status bar, Gemini usage/OAuth is configured, or provider usage status-bar toggles are changed. | Suppress future tips about where to find token/rate-limit/usage tracking for Claude, Codex, Gemini, OpenCode, and related providers. | -| Claude account switching | `claude-account-switching` | A Claude managed account is added, selected, reauthenticated, removed, or selected from the status bar. | Suppress future tips about using multiple Claude accounts once the user has started account management. | -| Codex account switching | `codex-account-switching` | A Codex managed account is added, selected, reauthenticated, removed, or selected from the status bar. | Suppress future tips about Codex account switching and restart follow-up flows. | -| Workspace tabs | `terminal-tabs` | A workspace tab is created, reordered, renamed, recolored, pinned/unpinned, moved, or closed. | Suppress future tips about tab-level workspace organization. These are real workspace tabs, not workspace board cards. | -| Split panes | `terminal-panes` | A split group is created from the pane shortcut/menu, a split is resized, or panes are merged. | Suppress future tips about pane-level split workflows. | -| Tab splits | `tab-splits` | A workspace tab is moved into another pane or split into a new pane from a tab split/drop action. | Suppress future tips about tab-level split workflows separately from pane creation/resizing. | -| Agent Browser Use setup | `agent-browser-setup` | Browser Use was selected in legacy onboarding, enabled in settings, or its setup terminal opens. | Suppress future setup tips once the user has started Browser Use setup. | -| Agent Browser Use | `agent-browser-use` | A successful non-profile `browser.*` runtime method is handled. | Suppress future usage tips once an agent/runtime has actually driven Orca's browser. | -| Agent Orchestration setup | `agent-orchestration-setup` | Orchestration was selected in legacy onboarding, enabled in settings, or its setup terminal opens. | Suppress future setup tips once the user has started Orchestration setup. | -| Agent Orchestration | `agent-orchestration` | A successful `orchestration.*` runtime method is handled. | Suppress future usage tips once an agent/runtime has actually used orchestration. | -| Notifications | `notifications` | Notifications are enabled in onboarding/settings or a test notification is sent. | Suppress future notification setup tips. | - -## Surface-Level Features - -Orca also records surface-level interactions for feature areas where opening the surface is itself a meaningful discovery signal: - -- `workspace-board`: workspace board opened -- `workspace-agent-sessions`: workspace terminal surface opened with agent split controls visible -- `browser`: non-blank browser page viewed -- `tasks`: Tasks page opened -- `automations`: Automations page opened -- `floating-workspace`: floating workspace opened -- `workspace-creation`: workspace creation flow opened - -These remain intentionally separate from action-level IDs such as `workspace-board-actions`, `automation-created`, and `automation-run`. Surface-level IDs answer "has the user entered the feature area?" Action-level IDs answer "has the user performed the deeper workflow?" diff --git a/docs/reference/feature-education-retention-analytics.md b/docs/reference/feature-education-retention-analytics.md deleted file mode 100644 index df259f43172..00000000000 --- a/docs/reference/feature-education-retention-analytics.md +++ /dev/null @@ -1,182 +0,0 @@ -# Feature Education Retention Analytics - -This document records how Orca should use contextual-tour telemetry to evaluate retention and feature adoption. - -## Decision - -Track only contextual tour exposure and outcome in PostHog: - -- `contextual_tour_shown` -- `contextual_tour_outcome` - -Do not add broad telemetry that mirrors local education state, such as "feature interaction first recorded." Local state like `featureInteractions` exists to suppress redundant education on the user's machine; it is not the analytics source of truth. - -Feature adoption analysis should join tour cohorts to existing downstream product events. Add new telemetry only for concrete user actions that are not already represented by an existing event. - -Automatic contextual tours are limited to new users for this rollout. The local `contextualToursAutoEligible` flag is set once after persisted UI and onboarding state load: users still in first-run onboarding become eligible; users with closed onboarding become ineligible. This avoids surprising existing users with education for surfaces they may already understand. - -## Product Questions - -Use the data to answer: - -- Do users who see contextual tours retain better at D1 and D7? -- Do completed tours correlate with higher downstream feature adoption than skipped or cancelled tours? -- Which tours have high skip/cancel rates and should be revised or suppressed? -- Which tours introduce features that users later use in real workflows? - -## Event Contract - -### `contextual_tour_shown` - -Emitted once when a contextual tour first renders a measured target. - -Payload: - -- `tour_id`: `workspace-board`, `workspace-agent-sessions`, `browser`, `tasks`, `automations`, or `workspace-creation` -- `source`: bounded source enum from `src/shared/feature-education-telemetry.ts` -- `was_feature_previously_interacted`: boolean from local education state at the moment the tour is shown - -### `contextual_tour_outcome` - -Emitted once when a contextual tour ends. - -Payload: - -- `tour_id`: same enum as `contextual_tour_shown` -- `source`: same bounded source enum -- `outcome`: `completed`, `skipped`, or `cancelled` -- `steps_seen`: bounded integer -- `total_steps`: bounded integer - -## Dashboard Plan - -Create a PostHog dashboard named **Feature Education Retention**. - -### Tile: Tour Exposure Volume - -Question: How often are tours shown? - -Insight: - -- Event: `contextual_tour_shown` -- Breakdown: `tour_id` -- Visualization: stacked time series by day - -Action: - -- If volume is unexpectedly high, inspect gating before expanding tours. -- If a tour is never shown, verify the surface gate and target selectors. - -### Tile: Tour Outcome Rate - -Question: Which tours are completed, skipped, or cancelled? - -Insight: - -- Event: `contextual_tour_outcome` -- Breakdown: `tour_id` and `outcome` -- Visualization: stacked bar or table - -Action: - -- High skipped rate means copy, timing, or audience targeting needs revision. -- High cancelled rate means UI state is likely interrupting tours or targets are disappearing. - -### Tile: Median Steps Seen - -Question: How far do users get before leaving? - -Insight: - -- Event: `contextual_tour_outcome` -- Metric: median `steps_seen` -- Breakdown: `tour_id` -- Optional derived ratio: `steps_seen / total_steps` - -Action: - -- If most users see only step 1, shorten or reprioritize the tour. -- If later steps are rarely reached, do not rely on those steps for critical education. - -### Tile: D1/D7 Retention By Tour Outcome - -Question: Does tour status correlate with retention? - -Insight: - -- Cohort A: users with `contextual_tour_outcome` where `outcome = completed` -- Cohort B: users with `contextual_tour_outcome` where `outcome = skipped` -- Cohort C: users with `contextual_tour_outcome` where `outcome = cancelled` -- Cohort D: users with no `contextual_tour_shown` -- Retention: return to `app_opened` on day 1 and day 7 -- Breakdown: `tour_id` where PostHog supports it, otherwise build one insight per tour - -Action: - -- Keep or expand tours whose completed cohort outperforms skipped/no-tour cohorts. -- Rework or suppress tours whose shown cohort underperforms no-tour users. - -### Tile: Downstream Feature Adoption By Tour - -Question: Do users later use the feature taught by the tour? - -Build funnels from tour events to real product events: - -- `tasks`: `contextual_tour_outcome` -> workspace creation from task-linked flows where existing telemetry exposes the source; if the current event is insufficient, add a targeted task-started-workspace event. -- `workspace-creation`: `contextual_tour_outcome` -> `workspace_created` -- `browser`: `contextual_tour_outcome` -> browser grab/annotation events. If these do not exist, add targeted events for the concrete actions, not a broad feature-interaction event. -- `automations`: `contextual_tour_outcome` -> automation creation/run events. If these do not exist, add targeted events for create/run. -- `workspace-board`: `contextual_tour_outcome` -> board-specific actions such as card moved/status changed. If these do not exist, add targeted events for those actions. -- `workspace-agent-sessions`: `contextual_tour_outcome` -> terminal pane split actions and later workspace creation. - -Recommended funnel shape: - -1. `contextual_tour_shown` filtered by `tour_id` -2. `contextual_tour_outcome` filtered by same `tour_id`, broken down by `outcome` -3. Downstream action event within 1 day and 7 days - -Action: - -- If completion improves downstream action conversion, keep the tour. -- If shown-but-skipped users still convert, the surface may be discoverable without a tour. -- If no downstream event exists, add the smallest action-specific telemetry needed for that feature. - -## Telemetry Cost Rule - -At 1,000 DAU, this PR should emit at most: - -- One `contextual_tour_shown` per shown tour -- One `contextual_tour_outcome` per shown tour -- Currently one contextual tour per session - -Expected launch volume: under 2,000 events/day at 1,000 DAU and lower for mature cohorts because existing users are not auto-eligible and `contextualToursSeenIds` fills in for eligible new users. - -Do not add recurring, render-loop, polling, passive snapshot, or local-state-mirror events for this analysis. - -## Privacy Rule - -Payloads must remain low-cardinality and bounded. Do not include: - -- prompts -- commands -- paths -- URLs -- hostnames -- repo names -- branch names -- user-entered text -- raw errors -- tokens or IDs beyond the existing anonymous telemetry identity - -## Existing Users - -Existing users should not receive automatic contextual tours from this rollout. We cannot reliably know whether they have already viewed or used every covered surface, so the least surprising default is to leave them alone and measure this launch on new-user cohorts. - -Implementation: - -- `contextualToursAutoEligible: false` for profiles whose onboarding is already closed when the rollout first runs -- `contextualToursAutoEligible: true` for profiles still in first-run onboarding when the rollout first runs -- automatic tour requests require `contextualToursAutoEligible === true` -- local `featureInteractions` still records supported surface entry for all users after UI hydration - -For existing-user analysis, compare users by actual tour exposure and outcome only when they have exposure. Most existing users should fall into the no-tour baseline for this PR. If a later release adds manual tour entry points or truly new feature education, evaluate that release separately instead of reusing this rollout's eligibility rule. diff --git a/docs/reference/feature-education-state.md b/docs/reference/feature-education-state.md deleted file mode 100644 index 950d4823d48..00000000000 --- a/docs/reference/feature-education-state.md +++ /dev/null @@ -1,75 +0,0 @@ -# Feature Education State - -Orca uses local persisted UI state to decide which education surfaces should appear for a user. Keep these concepts separate; they answer different questions and should not be collapsed into one flag. - -For the feature-interaction catalog, see [`feature-discovery-interaction-tracking.md`](./feature-discovery-interaction-tracking.md). For retention analysis and dashboard construction, see [`feature-education-retention-analytics.md`](./feature-education-retention-analytics.md). - -## State Types - -`featureTipsSeenIds` answers: "Has Orca already surfaced this tip?" - -Set this when a restart tip is opened, dismissed, or acted on. It prevents the same tip from reappearing after restart, crash, or dismissal. It does not mean the user used the feature. - -`contextualToursSeenIds` answers: "Has Orca already surfaced this contextual tour?" - -Set this after a tour has rendered a measured target, or when the user skips/completes the tour. It prevents the same tour from showing again. It does not mean the user used the feature. - -`contextualToursAutoEligible` answers: "May this profile receive automatic contextual tours from this rollout?" - -Missing means Orca has not classified the profile yet. On first hydrated launch after this change, the renderer sets it once: - -- `true` when first-run onboarding is still open, which covers new users from this rollout forward -- `false` when onboarding is already closed, which covers existing users - -Automatic contextual tours require this flag to be `true`. Existing users still get local feature interaction state recorded when they enter supported surfaces, but they are not auto-toured by this rollout. - -`featureInteractions` answers: "Has the user actually interacted with this feature?" - -This is the product-state signal for already-discovered features. It lives in `PersistedUIState.featureInteractions`, keyed by `FeatureInteractionId`, and stores the first local interaction timestamp plus a local interaction count: - -```ts -{ - tasks: { - firstInteractedAt: 1716500000000, - interactionCount: 3 - } -} -``` - -Use this to suppress tips or tours that would teach a feature the user has already found on their own. - -## Rules - -- Define every trackable feature in `src/shared/feature-interactions.ts`. -- The `interaction` text must clearly state what counts as "used." Avoid vague labels like "seen" or "visited" unless passive visibility is truly the intended signal. -- Record only the first interaction. Repeated opens should not churn the user-data file. -- Record interactions only after persisted UI state is hydrated, so startup defaults cannot overwrite real user data. -- Do not rename or reuse ids. If semantics change materially, add a new id and leave the old one readable. -- Unknown or malformed persisted ids are ignored during hydration for forward/backward compatibility. -- This state is local product state, not telemetry. Analytics should use the bounded feature-education telemetry events (`contextual_tour_shown` and `contextual_tour_outcome`) plus existing downstream action events, rather than reading or uploading the persisted state blob. -- Automatic contextual tours are new-user-only for this rollout. Do not reset `contextualToursAutoEligible` for existing users unless the product decision changes for a later release. - -## Adding A Tip - -When a tip should not show after the user has already used the feature, add the relevant interaction id to the tip definition: - -```ts -{ - id: 'voice-dictation', - completedByFeatureInteractions: ['voice-dictation'] -} -``` - -Then make sure the feature records its interaction at the meaningful product moment. For example, voice dictation records only after a session reaches `listening`, not when the settings pane opens. - -## Adding A Tour - -Tours still use `contextualToursSeenIds` to avoid repeating the same tour. If opening or using the toured surface should also suppress future education for that feature, call `recordFeatureInteraction(featureId)` from the surface's meaningful interaction point. - -For contextual tours in this branch, `useContextualTour(...)` records the matching interaction once the surface is enabled and persisted UI is ready. Surface-specific gates decide what "enabled" means; for example, the browser tour waits for a non-blank local browser page. - -## Test Profiles - -Completed-onboarding E2E profiles should preseed both education exposure and feature interaction state. That keeps first-run education from covering unrelated UI under test while preserving production behavior for real profiles. - -Profiles that model existing users should also set `contextualToursAutoEligible: false`; profiles that explicitly exercise contextual tours can set it to `true`. diff --git a/docs/reference/keyboard-layout-shortcut-dispatch.md b/docs/reference/keyboard-layout-shortcut-dispatch.md deleted file mode 100644 index 33a2a4d867e..00000000000 --- a/docs/reference/keyboard-layout-shortcut-dispatch.md +++ /dev/null @@ -1,66 +0,0 @@ -# Keyboard Layout Shortcut Dispatch - -## Problem - -Keyboard shortcuts must follow the user's active keyboard layout. A shortcut like `Cmd+W` -means "Command plus the key that produces `w`", not "Command plus the physical key labeled -W on a US keyboard." Physical-position matching breaks Dvorak, Colemak, AZERTY, JIS, and -other non-US layouts, and it also makes user keybinding overrides impossible to reason about. - -## Decision - -Orca app shortcuts dispatch by logical key by default. - -The shared keybinding registry in `src/shared/keybindings.ts` is the source of truth for -app commands, configurable commands, shortcut recording, labels, conflict detection, browser -guest forwarding, and terminal pane commands. Code handling a user-facing app command must -call `keybindingMatchesAction`, `keybindingMatchesInput`, or a policy function built on those -helpers. - -Physical `KeyboardEvent.code` may only decide a shortcut when the key is layout-invariant or -the platform cannot provide a real logical key. - -Allowed physical-code uses: - -- Modifier key release tracking, such as left/right Control release for held `Ctrl+Tab`. -- Layout-invariant keys, such as arrows, Tab, Enter, Escape, Backspace, Delete, Insert, - PageUp, PageDown, and explicit numpad bindings. -- Dead, unidentified, or missing logical keys where `KeyboardEvent.key` cannot describe the - produced key. -- Terminal byte encoding where the intent is a physical terminal escape sequence rather than - an Orca command. - -Disallowed physical-code uses: - -- Letter shortcuts for app actions. -- Punctuation shortcuts for app actions when `KeyboardEvent.key` reports the produced - punctuation. -- Clipboard shortcuts that are exposed as app or terminal UI commands. -- Hardcoded undo/redo/new/close/copy/paste handling outside the shared registry. - -## Terminal Boundary - -Terminal handling has two different jobs: - -1. Orca commands that act on terminal UI, such as copy selection, paste, search, clear, - pane focus, split, and close. These are app shortcuts and must be layout-aware. -2. Bytes sent to the shell, such as readline escapes and Option-as-Alt sequences. These may - use physical key positions when terminal compatibility requires it. - -This boundary is intentional. It lets non-US layouts use Orca commands naturally while -preserving shell behavior where users expect physical terminal-control sequences. - -## Regression Requirements - -Shortcut tests must cover both directions of a non-QWERTY swap: - -- The key that produces the configured logical character must match, even if its physical code - differs. -- The physical US key must not match when it produces a different logical character. - -Tests must also cover intentional exceptions: - -- Dead or missing key fallback. -- Shifted punctuation aliases. -- Numpad-specific bindings. -- Terminal byte-encoding paths that intentionally use physical codes. diff --git a/docs/reference/new-user-parallel-work-telemetry.md b/docs/reference/new-user-parallel-work-telemetry.md deleted file mode 100644 index abe64e6f881..00000000000 --- a/docs/reference/new-user-parallel-work-telemetry.md +++ /dev/null @@ -1,245 +0,0 @@ -# New User Parallel Work Telemetry - -## Goal - -Measure whether the new parallel-work tours and the eight-step `Getting started` flow improve new-user activation and retention. - -The product question is not "did the tour render?" It is: - -- Did users who were eligible for the new parallel-work education retain better at D1, D3, and D7? -- Did seeing or completing the tour increase the behaviors that the activation plan expects: terminal splits, two agents in one worktree, two worktrees within 72 hours, task/review source use, setup script configuration, notifications, and agent capability setup? -- Where do users leave the education flow: before the tour starts, during a tour, after skipping a tour, after opening `Getting started`, or partway through the eight setup steps? - -This document implements the telemetry portion of [`new-user-parallel-work-activation.md`](./new-user-parallel-work-activation.md). - -Validation mode: the current branch logs the would-be event name and payload to the console instead of sending these new feature-education events through product telemetry. Keep the logged payload shape identical to the event payloads below so reviewers can verify what would be emitted before product telemetry is re-enabled. - -## Product Decisions - -Question: Should Orca keep, revise, or disable the automatic `workspace-agent-sessions` tour for new users? - -Decision owner/use: Product and growth review the retention and activation read after rollout. If exposed users retain and activate better than eligible-but-unexposed users, keep or expand the tour. If exposed users underperform or skip/cancel heavily, revise copy, timing, or gating. - -Question: Which part of the education flow is weak? - -Decision owner/use: If users usually stop on a specific tour step, revise that step or shorten the tour. If users complete the tour but do not progress through `Getting started`, improve the handoff. If users open `Getting started` but stall before the first two parallel-work steps, revise the checklist actions. - -Dashboard: `New User Parallel Work Activation` - -Action: Use the dashboard to decide whether to continue rollout, adjust the tour, reorder checklist steps, or add missing downstream action telemetry. - -## Existing Telemetry To Reuse - -Keep the existing contextual-tour events as the primary tour exposure and outcome contract: - -- `contextual_tour_shown` -- `contextual_tour_outcome` - -`contextual_tour_outcome` already carries: - -- `tour_id` -- `source` -- `outcome`: `completed`, `skipped`, or `cancelled` -- `steps_seen` -- `total_steps` - -For the current branch, `workspace-agent-sessions` is the activation tour. Its source should distinguish automatic first-workspace exposure from checklist-triggered education: - -- `workspace_agent_sessions_visible` for automatic surface-driven tour exposure -- `setup_guide_parallel_work` for the explicit checklist `Try it out` path - -Do not add one telemetry event per tour opportunity or step render. Opportunity checks are driven by local UI eligibility, retries, target visibility, modal state, and session guards; emitting every "not shown" reason would turn tour gating into passive eligibility telemetry. One shown event plus one outcome event keeps volume low and still answers whether users saw, skipped, cancelled, completed, and how far they got. - -## Tour Depth Improvement - -Add stable step-depth fields to `contextual_tour_outcome` so the analysis can tell whether a user reached the actual late steps, not only how many visible steps happened to render. - -Additive schema fields: - -- `furthest_step_index`: integer `1..8` -- `defined_step_count`: integer `1..8` - -Keep `steps_seen` and `total_steps`: - -- `steps_seen` remains the count of unique rendered visible steps. -- `total_steps` remains the visible-step denominator for the user session. -- `furthest_step_index` is the highest 1-based index from the tour definition reached by the user. -- `defined_step_count` is the number of steps defined for that tour, regardless of target skipping. - -This handles target-skipping correctly. A user who sees tour steps 1, 4, and 5 should report `steps_seen: 3`, `total_steps: 3`, `furthest_step_index: 5`, and `defined_step_count: 5`. - -## New Setup Guide Events - -The eight `Getting started` steps need a small telemetry contract because they are not contextual-tour steps. They are durable activation milestones. - -### `setup_guide_opened` - -Emit once when the modal opens. - -Payload: - -- `source`: `sidebar`, `contextual_tour`, `settings`, `feature_wall`, `help_menu`, or `unknown` -- `initial_completed_count`: integer `0..8` -- `total_steps`: integer `8` -- `first_incomplete_step_id`: one of the eight setup step ids, or `none` - -Use: - -- Measures whether users reach the checklist after the tour. -- Establishes starting depth so existing progress does not look like checklist-driven progress. - -### `setup_guide_closed` - -Emit once when a shown setup guide closes or unmounts. - -Payload: - -- `source`: same value used by `setup_guide_opened` -- `outcome`: `completed`, `dismissed`, or `interrupted` -- `initial_completed_count`: integer `0..8` -- `final_completed_count`: integer `0..8` -- `total_steps`: integer `8` -- `active_step_id`: one of the eight setup step ids, or `none` - -Use: - -- Measures whether users treat the checklist as useful or dismiss it without progress. -- Gives a compact "how far through the 8 steps" read without uploading a progress snapshot on every render. - -### `setup_guide_step_completed` - -Emit once per setup step when that step first transitions from incomplete to complete while the setup guide is visible. Hidden/background progress is persisted locally as baseline state but not emitted, because async setup refresh cannot reliably distinguish old completions from new user action. - -Payload: - -- `step_id`: one of `two-worktrees`, `browser`, `notifications`, `default-agent`, `task-sources`, `setup-script`, `add-two-repos`, `agent-capabilities` -- `section_id`: `parallel-work` or `setup` -- `completed_count`: integer `1..8` -- `total_steps`: integer `8` -- `setup_guide_visible`: boolean - -Use: - -- Measures the in-guide depth through the eight steps without backfilling old setup state on launch. -- Lets retention analysis bucket users by `0`, `1`, `2`, `3-4`, `5-7`, and `8` completed setup steps. - -Implementation detail: persist a local set of emitted setup-guide step ids, similar in spirit to `contextualToursSeenIds`, so inferred durable state does not re-emit on every launch. Do not upload that local set. - -## Downstream Activation Events - -Reuse existing downstream events whenever possible: - -- `workspace_created` for worktree/workspace creation depth -- `agent_started` for agent activity and follow-up actions -- `settings_changed` for supported settings such as notifications -- setup script prompt events for setup-script configuration paths -- task/source-specific events where they already exist - -Add action-specific downstream telemetry only when the retention question cannot be answered from existing events. The first likely gap is terminal splitting. The activation plan names first-session terminal split as a primary success criterion, but the branch currently records `terminal-pane-split` as local feature-interaction state, not product telemetry. - -If no existing telemetry captures terminal split, add: - -### `terminal_pane_split` - -Emit when a split succeeds, capped to the first split for each `source` + `direction` pair per UTC day on the local profile. This keeps the event useful for activation cohorts without making heavy terminal users dominate telemetry volume. - -Payload: - -- `source`: `contextual_tour`, `keyboard`, `context_menu`, `command`, `unknown` -- `direction`: `vertical` or `horizontal` - -Do not include pane ids, worktree ids, paths, commands, prompts, repo names, branch names, hostnames, or terminal content. - -## Retention Analysis - -Use new-user eligibility as the least-biased baseline: - -1. Eligible baseline: new-user cohorts represented by first-run/onboarding events rather than uploading local tour eligibility state. -2. Exposed cohort: users with `contextual_tour_shown` where `tour_id = workspace-agent-sessions`. -3. Tour outcome cohorts: users with `contextual_tour_outcome` broken down by `outcome`. -4. Tour depth cohorts: `furthest_step_index / defined_step_count` and `steps_seen / total_steps`. -5. Checklist depth cohorts: latest `setup_guide_step_completed.completed_count`, bucketed as `0`, `1`, `2`, `3-4`, `5-7`, and `8`. - -Primary retention read: - -- D1, D3, and D7 return to `app_opened` -- Compare new-user baseline, shown, completed, skipped, cancelled, and checklist-depth cohorts - -Primary activation read: - -- terminal split in first workspace session -- two agent sessions in one worktree -- two worktrees within 72 hours -- follow-up/manual agent action -- task/review source use -- setup script configured -- notifications configured -- agent capabilities completed - -Interpretation rules: - -- A completed tour with no downstream activation means the tour is educational but not converting. -- A skipped tour with strong downstream activation means the feature may be discoverable without the tour. -- A high cancelled rate usually points to target loss, modal conflicts, or timing problems. -- Strong checklist-depth retention with weak tour retention means the checklist is the value driver, not the automatic tour. - -## Volume Estimate - -Telemetry volume estimate: - -- Trigger: contextual tour shown/outcome, setup guide open/close, setup step first completion, optional terminal split -- Expected events/user/day: 2-6 for new users who encounter the flow -- Max events/user/day: about 14 on a heavy first day: 2 tour events, 2 setup guide events, 8 setup step completions, 1-2 split events -- At 1,000 DAU: expected 2,000-6,000 events/day -- Monthly at 1,000 DAU: expected 60,000-180,000 events/month -- Approval note: The events answer the rollout decision directly and are bounded by explicit user actions or one-time durable setup completions. There are no timers, render-loop events, polling snapshots, or passive heartbeats. - -If observed volume exceeds expectation, remove or sample `setup_guide_opened` / `setup_guide_closed` before removing completion events. Completion events are the durable activation-depth read. - -## Privacy - -All payloads must be low-cardinality and schema-bounded. - -Never include: - -- prompts -- terminal commands -- paths -- URLs -- hostnames -- repo names -- branch names -- task titles -- user-authored text -- raw errors -- tokens -- pane ids, worktree ids, repo ids, or other persistent product identifiers - -## Implementation Plan - -1. Extend `contextual_tour_outcome` schema in `src/shared/telemetry-events.ts` with optional `furthest_step_index` and `defined_step_count`. -2. Track the highest reached defined tour step in `ContextualTourOverlay.tsx` alongside the existing `steps_seen` set. -3. Add setup-guide event schemas and bounded enums in `src/shared/telemetry-events.ts`. -4. Add a setup-guide telemetry helper in `src/renderer/src/lib/feature-education-telemetry.ts` or a dedicated setup-guide telemetry module. -5. Emit `setup_guide_opened` and `setup_guide_closed` from `SetupGuideModal.tsx`. -6. Emit `setup_guide_step_completed` from a small hook that observes visible setup-guide progress transitions, persists emitted step ids locally, and ignores hidden/background completions except as local baseline state. -7. Add `terminal_pane_split` only if no existing telemetry captures successful terminal splits. -8. Add schema tests for accepted payloads, rejected raw strings, count bounds, and enum bounds. -9. Add renderer tests that prove each modal open emits one open event, each close emits one close event, each step completion emits once, rerenders do not re-emit, and pre-completed persisted steps are not backfilled repeatedly. - -## Dashboard Tiles - -Create a PostHog dashboard named `New User Parallel Work Activation`. - -Tiles: - -- Tour exposure: `contextual_tour_shown` by `tour_id` and `source` -- Tour outcome: `contextual_tour_outcome` by `outcome` -- Tour depth: median `furthest_step_index / defined_step_count` -- Setup guide reach: `setup_guide_opened` by `source` -- Setup guide close outcome: `setup_guide_closed` by `outcome` -- Eight-step depth: latest `setup_guide_step_completed.completed_count` bucket -- Activation funnel: tour shown -> tour outcome -> setup guide opened -> first two setup steps complete -> D1/D3/D7 return -- Downstream actions: tour shown -> terminal split -> two agents in one worktree -> two worktrees within 72 hours - -Do not publish dashboard tiles that require raw user content or identifiers. diff --git a/docs/reference/opencode-commit-message-stdin.md b/docs/reference/opencode-commit-message-stdin.md deleted file mode 100644 index c7141fb4469..00000000000 --- a/docs/reference/opencode-commit-message-stdin.md +++ /dev/null @@ -1,105 +0,0 @@ -# OpenCode Commit Message Stdin Delivery - -## Problem - -OpenCode's preset Source Control AI path passes the full generated prompt as the final `opencode run` argv positional. Commit-message prompts include branch, staged file summary, user instructions, and the staged patch. The patch is capped by `STAGED_DIFF_BYTE_BUDGET = 200_000`, but the implementation uses JavaScript string length, not encoded byte length, so argv can still be much larger than command-line limits on Windows and some POSIX/SSH wrapper paths before OpenCode starts. - -Verified current code: - -- `src/shared/commit-message-agent-spec.ts:24-32` defines `promptDelivery` as the argv/stdin contract for large diffs. -- `src/shared/commit-message-agent-spec.ts:322-337` sets OpenCode to `promptDelivery: 'argv'` and appends `prompt`. -- `src/shared/commit-message-plan.ts:108-123` already routes `promptDelivery === 'stdin'` into `stdinPayload`. -- `src/main/text-generation/commit-message-text-generation.ts:470-601` pipes local `stdinPayload` to child stdin. -- `src/main/providers/ssh-git-provider.ts:122-140` forwards `stdinPayload` as relay `stdin`; `src/relay/agent-exec-handler.ts:160-303` writes it to the remote child. -- `src/shared/commit-message-plan.test.ts:36` currently locks in the broken OpenCode argv behavior. - -## Feasibility Gate - -`opencode --version` on the local machine reports `1.15.13`. `opencode run --help` confirms the required args exist: `--model`, `--agent`, `--format`, and `--variant`, with optional `[message..]` positionals. It does not document stdin prompt input. - -Shipping gate: before changing the preset, run a controlled smoke test using the exact no-message shape, and verify stdout clearly reflects the stdin prompt rather than an empty task: - -```sh -printf '%s\n' 'Write: test commit' | opencode run --model --agent build --format default -``` - -Also smoke `--variant ` if the selected model exposes thinking levels. If credentials, model access, or an unsupported OpenCode version block validation, do not treat `--help` alone as proof. If stdin fails, do not add an argv fallback; stop and choose a different no-argv transport or version-gated behavior. - -## Root Cause - -The planner and both local/SSH execution paths already know how to carry stdin payloads. The mismatch is the OpenCode preset args. - -OpenCode is not the only argv-delivered path in the registry: Cursor and custom commands that explicitly use `{prompt}` still put prompts in argv. They are out of scope here and should not be described as fixed by this change. - -## Non-Goals - -- Do not change prompt wording, diff truncation, output cleanup, model discovery, or model selection. -- Do not change interactive OpenCode terminal launch behavior. -- Do not change GitHub/GitLab/provider review flows. -- Do not change Cursor argv delivery or custom command `{prompt}` semantics. -- Do not add a second OpenCode execution path. - -## Design - -1. Change the OpenCode preset spec to `promptDelivery: 'stdin'`. -2. Add a short why-comment near that setting: OpenCode prompt input can contain large staged diffs and must stay out of argv. -3. Remove `prompt` from OpenCode `buildArgs`. The preset args must be exactly `run --model --agent build --format default`, plus `--variant ` when present. -4. Keep `modelSource`, `modelDiscovery`, default model, and dynamic-model fallback behavior unchanged. -5. Keep preset `agentCommandOverride` behavior unchanged: an override such as `npx opencode` prefixes the same OpenCode args, while the prompt remains `stdinPayload`. -6. Accept the shared-spec blast radius: OpenCode prompts for commit-message, PR-field, and branch-name generation all move to stdin because they all call `planCommitMessageGeneration(...)`. If the requirement is commit-message-only, this design is wrong and needs operation-aware delivery instead. - -## Data Flow - -- Source Control AI receives a staged context and builds the prompt. The context is not atomic: branch, staged summary, and staged patch are read by separate git commands before generation. -- `planCommitMessageGeneration(...)` validates the agent, model, and thinking level against the existing dynamic/static model rules. -- Because OpenCode becomes stdin-delivered, the planner passes an empty prompt into `buildArgs` and stores the real prompt in `plan.stdinPayload`. -- Local generation writes `stdinPayload` to the child process stdin. -- SSH generation serializes the same plan to the relay as `{ stdin: plan.stdinPayload }`, and the relay writes it to the remote child process stdin after spawn. - -This fix removes the prompt from process argv only. It does not reduce prompt memory, model latency, token use, or diff truncation pressure. Remote execution still sends the prompt in one JSON-RPC frame; the relay frame cap is 16 MB, and today's prompt budget is under that, but future budget increases must account for it. - -## Edge Cases - -- Large staged diff: the prompt must be absent from `args`, including no empty-string placeholder. -- Thinking level selected: preserve `--variant `. -- No thinking level selected: omit `--variant`. -- Dynamic OpenCode model not present in the seed catalog: preserve the existing dynamic fallback and still pass the selected model id. -- Agent command override: `npx opencode` becomes `binary: 'npx'`, `args: ['opencode', 'run', ...]`, with prompt in `stdinPayload`. -- Windows batch shims: removing the prompt from argv also removes prompt metacharacters from the batch command line; do not route the prompt through `{prompt}` for this preset. -- SSH: the prompt is JSON-serialized as `stdin`, not shell-quoted, and is written by the relay after spawn. -- SSH payload size: this is safe for the current prompt budget, not an unbounded transport. Anything near 16 MB needs streaming or another transport. -- OpenCode ignores stdin: the smoke test must catch this before shipping because `--help` does not document stdin. -- Output cleanup remains unchanged; OpenCode stderr/status chatter should be handled by existing failure and cleanup logic. - -## Consistency And Concurrency - -- No cache invalidation is introduced. Each generation plans from the request's current settings and the staged context supplied to that request. -- The staged context is not a true snapshot. If the index changes between the summary and patch reads, or after the context is read, the generated message can describe a mixed or stale view. That is existing behavior and not fixed here. -- Multiple windows can request generation for the same worktree. Local execution does not serialize same-`cwd` requests; the cancel map is keyed by `operation:local:` and the newest request overwrites the reachable cancel token. Do not claim this change fixes local multi-window races. -- SSH execution queues matching `operation + cwd` lanes inside a `SshGitProvider` instance. The relay itself tracks only one active child per lane and cancels a prior active child if another request for the same lane reaches it, which matters if separate provider instances/windows bypass the same queue. -- Settings or command overrides changed while a generation is in flight affect the next request only; the current child keeps its immutable plan. - -## Test Plan - -- `src/shared/commit-message-agent-spec.test.ts`: add OpenCode coverage asserting `promptDelivery === 'stdin'`, no prompt or empty placeholder in `buildArgs`, variant preservation, and no variant when absent. -- `src/shared/commit-message-plan.test.ts`: update OpenCode expectations so `stdinPayload` receives the prompt and argv excludes it; include an OpenCode command-override case. -- Because `planCommitMessageGeneration(...)` is reused by commit-message, PR-field, and branch-name generation, the plan tests are the main regression coverage for the shared blast radius. -- Keep existing Codex stdin, custom-command stdin, local child stdin, SSH provider, and relay stdin tests passing. -- Run targeted Vitest for the touched shared tests, plus `pnpm typecheck` and `pnpm lint` for the implementation branch. -- Run the OpenCode stdin smoke test from the feasibility gate. If it cannot run, do not mark implementation risk as resolved. - -## Lightweight Eng Review - -- Scope: shared preset-agent plumbing and shared tests, with an explicit shared-operation blast radius for OpenCode commit-message, PR-field, and branch-name prompts. No renderer redesign, settings migration, hosted review flow, or provider-specific review behavior. -- Architecture/data flow: use the existing `promptDelivery` contract; local and SSH execution already honor `stdinPayload`. -- Failure modes covered: prompt re-enters argv, `--variant` is dropped, unknown dynamic model ids regress, command overrides change stdin behavior, remote JSON-RPC frame limits are ignored, or OpenCode accepts the flags but ignores stdin. -- Performance/blast radius: no renderer, watcher, IPC subscription, or startup cost. Local argv shrinks; remote payload size is unchanged because the prompt still crosses JSON-RPC as stdin data in one frame. -- UI quality bar: no visual change; skip visual-design review. -- Screenshots: none required for this headless invocation change. If PR evidence is needed, prefer unit-test output and the controlled OpenCode stdin smoke result. -- Residual risk: OpenCode stdin prompt input is not documented by `opencode run --help`; remote hosts may have different OpenCode versions; existing local multi-window cancellation races and staged-context non-atomicity remain. - -## Rollout - -1. Update OpenCode spec delivery and args. -2. Update shared spec and planner tests. -3. Run targeted tests, typecheck, lint, and the OpenCode smoke test before shipping. diff --git a/docs/reference/plans/2026-06-27-orca-mobile-manual-network-address.md b/docs/reference/plans/2026-06-27-orca-mobile-manual-network-address.md deleted file mode 100644 index c27fe37b255..00000000000 --- a/docs/reference/plans/2026-06-27-orca-mobile-manual-network-address.md +++ /dev/null @@ -1,972 +0,0 @@ -# Manual Network Address Entry — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Let users type a custom IPv4 address or Tailscale MagicDNS hostname into the desktop mobile-pairing Network Interface dropdown, so the QR code can target a host the OS hasn't auto-discovered. - -**Architecture:** Replace the inner `Select` of `MobileNetworkInterfaceSection` with a `Popover + Command` ("combobox") pattern modeled on the existing `AgentCombobox`. A new shared pure function `parseManualNetworkAddress` enforces the input grammar; a new helper `buildComboboxEntries` builds the row list (filtered interfaces + an optional `Use ""` row). The custom selection is session-scoped. - -**Tech Stack:** TypeScript, React 18, shadcn/ui primitives (`Command`, `Popover`, `Button`) from `src/renderer/src/components/ui/`, vitest, `@testing-library/react`. No new dependencies. - -**Spec:** [`docs/reference/2026-06-27-orca-mobile-manual-network-address-design.md`](../2026-06-27-orca-mobile-manual-network-address-design.md) - ---- - -## File map - -**Create:** -- `src/shared/network/manual-address.ts` — pure parser. -- `src/shared/network/manual-address.test.ts` — parser unit tests. -- `src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx` — UI render tests. - -**Modify:** -- `src/renderer/src/components/settings/mobile-network-interface-selection.ts` — swap `mergeForSelect` for `buildComboboxEntries` (or add `buildComboboxEntries` and keep the old one if other call sites exist; confirm with grep before deleting). -- `src/renderer/src/components/settings/mobile-network-interface-selection.test.ts` — replace `mergeForSelect` tests with `buildComboboxEntries` tests. -- `src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx` — swap `Select` for `Popover + Command`. - -**Reference (read-only, do not modify):** -- `src/renderer/src/components/ui/command.tsx` — `Command`, `CommandEmpty`, `CommandInput`, `CommandItem`, `CommandList`, `CommandSeparator`. -- `src/renderer/src/components/ui/popover.tsx` — `Popover`, `PopoverTrigger`, `PopoverContent`. -- `src/renderer/src/components/ui/button.tsx` — `Button`. -- `src/renderer/src/components/agent/AgentCombobox.tsx` — full reference implementation of the controlled cmdk pattern. -- `src/renderer/src/components/agent/agent-combobox-command-state.ts` — `createAgentComboboxCommandState`, `resolveAgentComboboxCommandState`, `updateAgentComboboxCommandValue`. -- `src/renderer/src/components/settings/MobilePairingQrSection.tsx` — consumer of `selectedAddress`; unchanged. - -**Out of scope:** mobile-side endpoint override (`mobile/app/pair-scan.tsx`); main-process code; persistent storage of custom addresses; IPv6, ports, non-Tailscale hostnames. - ---- - -## Task 1: Add `parseManualNetworkAddress` (TDD) - -**Files:** -- Create: `src/shared/network/manual-address.ts` -- Create: `src/shared/network/manual-address.test.ts` - -- [ ] **Step 1: Confirm no existing call site for `mergeForSelect`** - - Run: - ```bash - grep -rn "mergeForSelect" src/ docs/ 2>/dev/null - ``` - Expected: no matches. (If there are matches, stop and update Task 2 to keep `mergeForSelect` and only add `buildComboboxEntries` alongside it.) - -- [ ] **Step 2: Write the failing tests** - - Create `src/shared/network/manual-address.test.ts` with this exact content: - - ```ts - import { describe, it, expect } from 'vitest' - import { parseManualNetworkAddress } from './manual-address' - - describe('parseManualNetworkAddress', () => { - describe('IPv4', () => { - it('accepts canonical IPv4', () => { - expect(parseManualNetworkAddress('192.168.1.24')).toEqual({ - ok: true, - address: '192.168.1.24' - }) - expect(parseManualNetworkAddress('100.64.1.20')).toEqual({ - ok: true, - address: '100.64.1.20' - }) - }) - - it('accepts boundary IPv4 values', () => { - expect(parseManualNetworkAddress('0.0.0.0').ok).toBe(true) - expect(parseManualNetworkAddress('255.255.255.255').ok).toBe(true) - }) - - it('rejects malformed IPv4', () => { - for (const bad of ['', ' ', '1.2.3', '1.2.3.4.5', '256.0.0.1']) { - expect(parseManualNetworkAddress(bad)).toEqual({ - ok: false, - error: 'Enter an IPv4 address or Tailscale MagicDNS hostname' - }) - } - }) - - it('rejects leading zeros in octets', () => { - expect(parseManualNetworkAddress('01.02.03.04')).toEqual({ - ok: false, - error: 'Enter an IPv4 address or Tailscale MagicDNS hostname' - }) - expect(parseManualNetworkAddress('0.0.0.0').ok).toBe(true) - }) - }) - - describe('Tailscale MagicDNS hostname', () => { - it('accepts short MagicDNS names', () => { - expect(parseManualNetworkAddress('my-mac.ts.net')).toEqual({ - ok: true, - address: 'my-mac.ts.net' - }) - }) - - it('accepts tailnet-qualified MagicDNS names', () => { - expect(parseManualNetworkAddress('my-mac.tail-abcd.ts.net')).toEqual({ - ok: true, - address: 'my-mac.tail-abcd.ts.net' - }) - expect(parseManualNetworkAddress('a.b.c.d.ts.net').ok).toBe(true) - }) - - it('is case-insensitive', () => { - expect(parseManualNetworkAddress('MY-MAC.TS.NET').ok).toBe(true) - }) - - it('rejects non-Tailscale hostnames', () => { - for (const bad of ['my-mac', 'my-mac.ts.com', '-foo.ts.net', 'my-mac.com']) { - expect(parseManualNetworkAddress(bad).ok).toBe(false) - } - }) - }) - - describe('length and whitespace', () => { - it('rejects inputs longer than 253 chars', () => { - const long = `${'a'.repeat(250)}.ts.net` - expect(long.length).toBeGreaterThan(253) - expect(parseManualNetworkAddress(long).ok).toBe(false) - }) - - it('trims leading and trailing whitespace before validating', () => { - expect(parseManualNetworkAddress(' 192.168.1.24 ')).toEqual({ - ok: true, - address: '192.168.1.24' - }) - }) - }) - }) - ``` - -- [ ] **Step 3: Run tests to verify they fail** - - Run from repo root: - ```bash - pnpm vitest run src/shared/network/manual-address.test.ts - ``` - Expected: error like `Cannot find module './manual-address'`. This is the failing-test step — do not skip it. - -- [ ] **Step 4: Implement `parseManualNetworkAddress`** - - Create `src/shared/network/manual-address.ts` with this exact content: - - ```ts - // Why: pure shared helper so the same validation runs in renderer - // today and in any future CLI/main-process caller without duplicating - // the IPv4 + Tailscale MagicDNS grammar. - const IPV4_OCTET = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])' - const IPV4 = `(?:${IPV4_OCTET}\\.){3}${IPV4_OCTET}` - // MagicDNS hostname: lowercase letters/digits/hyphens, dot-separated, ending in .ts.net. - // Labels may not start or end with a hyphen; max 63 chars per label (DNS limit). - const MAGICDNS_LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?' - const MAGICDNS = `(?:${MAGICDNS_LABEL}\\.)+ts\\.net` - - const HOSTNAME_MAX_LENGTH = 253 - const ERROR_MESSAGE = 'Enter an IPv4 address or Tailscale MagicDNS hostname' - - export type ParseManualAddressResult = - | { ok: true; address: string } - | { ok: false; error: string } - - export function parseManualNetworkAddress(input: string): ParseManualAddressResult { - const trimmed = input.trim() - if (trimmed === '' || trimmed.length > HOSTNAME_MAX_LENGTH) { - return { ok: false, error: ERROR_MESSAGE } - } - if (/\s/.test(trimmed)) { - return { ok: false, error: ERROR_MESSAGE } - } - - const ipv4Regex = new RegExp(`^${IPV4}$`) - if (ipv4Regex.test(trimmed)) { - return { ok: true, address: trimmed } - } - - const magicRegex = new RegExp(`^(?:${MAGICDNS})$`, 'i') - if (magicRegex.test(trimmed)) { - return { ok: true, address: trimmed } - } - - return { ok: false, error: ERROR_MESSAGE } - } - ``` - -- [ ] **Step 5: Run tests to verify they pass** - - Run: - ```bash - pnpm vitest run src/shared/network/manual-address.test.ts - ``` - Expected: all tests pass. - -- [ ] **Step 6: Lint and typecheck** - - ```bash - pnpm lint src/shared/network/ - pnpm typecheck - ``` - Expected: no errors. Fix any and re-run before continuing. - -- [ ] **Step 7: Commit** - - ```bash - git add src/shared/network/manual-address.ts src/shared/network/manual-address.test.ts - git commit -m "feat(mobile-pairing): add parseManualNetworkAddress validator" - ``` - ---- - -## Task 2: Replace `mergeForSelect` with `buildComboboxEntries` (TDD) - -**Files:** -- Modify: `src/renderer/src/components/settings/mobile-network-interface-selection.ts` -- Modify: `src/renderer/src/components/settings/mobile-network-interface-selection.test.ts` - -- [ ] **Step 1: Read the current contents of both files** - - Read the full source of: - - `src/renderer/src/components/settings/mobile-network-interface-selection.ts` - - `src/renderer/src/components/settings/mobile-network-interface-selection.test.ts` - - Confirm: `mergeForSelect` is the only exported function besides the `MobileNetworkInterface` type and `selectRefreshedNetworkAddress`. If `selectRefreshedNetworkAddress` is called from `MobileNetworkInterfaceSection.tsx`, keep it. - -- [ ] **Step 2: Write the failing tests for `buildComboboxEntries`** - - Replace the entire body of `mobile-network-interface-selection.test.ts` with: - - ```ts - import { describe, it, expect } from 'vitest' - import { - buildComboboxEntries, - selectRefreshedNetworkAddress, - type MobileNetworkInterface - } from './mobile-network-interface-selection' - - const LAN: MobileNetworkInterface = { name: 'en0', address: '192.168.1.24' } - const TAILNET: MobileNetworkInterface = { name: 'tailscale0', address: '100.64.1.20' } - - describe('buildComboboxEntries', () => { - it('returns only interface entries when query is empty', () => { - const entries = buildComboboxEntries([LAN, TAILNET], '') - expect(entries).toEqual([ - { kind: 'interface', iface: LAN }, - { kind: 'interface', iface: TAILNET } - ]) - }) - - it('filters interfaces by substring on address or name (case-insensitive)', () => { - const entries = buildComboboxEntries([LAN, TAILNET], 'TAIL') - expect(entries).toEqual([{ kind: 'interface', iface: TAILNET }]) - }) - - it('appends a use-query entry when the query is a valid address not in the list', () => { - const entries = buildComboboxEntries([LAN, TAILNET], 'my-mac.tail-abcd.ts.net') - expect(entries).toEqual([ - { kind: 'interface', iface: LAN }, - { kind: 'interface', iface: TAILNET }, - { kind: 'use-query', address: 'my-mac.tail-abcd.ts.net' } - ]) - }) - - it('suppresses use-query when query equals an existing interface address', () => { - const entries = buildComboboxEntries([LAN, TAILNET], '100.64.1.20') - expect(entries).toEqual([ - { kind: 'interface', iface: LAN }, - { kind: 'interface', iface: TAILNET } - ]) - }) - - it('returns only use-query when interfaces are empty and query is valid', () => { - const entries = buildComboboxEntries([], '1.2.3.4') - expect(entries).toEqual([{ kind: 'use-query', address: '1.2.3.4' }]) - }) - - it('omits use-query when query is invalid', () => { - const entries = buildComboboxEntries([LAN, TAILNET], 'not-an-address') - expect(entries).toEqual([ - { kind: 'interface', iface: LAN }, - { kind: 'interface', iface: TAILNET } - ]) - }) - }) - - describe('selectRefreshedNetworkAddress', () => { - // Existing behavior is preserved verbatim from the spec. - it('keeps the selected address when refresh discovers a new tailnet interface', () => { - expect(selectRefreshedNetworkAddress(LAN.address, [LAN, TAILNET])).toBe(LAN.address) - }) - - it('selects the first refreshed interface when there is no current address', () => { - expect(selectRefreshedNetworkAddress(undefined, [TAILNET, LAN])).toBe(TAILNET.address) - }) - - it('prefers a tailnet address when no address is selected yet', () => { - expect(selectRefreshedNetworkAddress(undefined, [LAN, TAILNET])).toBe(TAILNET.address) - }) - - it('moves to the first refreshed interface when the current address disappeared', () => { - expect(selectRefreshedNetworkAddress('10.0.0.4', [TAILNET, LAN])).toBe(TAILNET.address) - }) - - it('moves to a tailnet address when the current address disappeared', () => { - expect(selectRefreshedNetworkAddress('10.0.0.4', [LAN, TAILNET])).toBe(TAILNET.address) - }) - - it('clears the selection when no interfaces are available', () => { - expect(selectRefreshedNetworkAddress(LAN.address, [])).toBeUndefined() - }) - }) - ``` - -- [ ] **Step 3: Run the test file to verify the new tests fail** - - ```bash - pnpm vitest run src/renderer/src/components/settings/mobile-network-interface-selection.test.ts - ``` - Expected: `buildComboboxEntries is not a function` (or import error) for the new block; the `selectRefreshedNetworkAddress` block still passes. - -- [ ] **Step 4: Rewrite `mobile-network-interface-selection.ts`** - - Replace the entire file with: - - ```ts - import { isTailnetIPv4Address } from '../../../../shared/tailnet-address' - import { parseManualNetworkAddress } from '../../../../shared/network/manual-address' - - export type MobileNetworkInterface = { - name: string - address: string - } - - export type ComboboxEntry = - | { kind: 'interface'; iface: MobileNetworkInterface } - | { kind: 'use-query'; address: string } - - // Why: the UI needs a single ordered list to render inside CommandList. - // Behavior branches on whether the query parses as a valid address — - // valid queries show the full interface list (so users can pivot to an - // existing interface mid-typing), invalid queries substring-filter and - // fall back to the full list when nothing matches. - export function buildComboboxEntries( - interfaces: readonly MobileNetworkInterface[], - query: string - ): readonly ComboboxEntry[] { - const trimmed = query.trim() - if (trimmed === '') { - return interfaces.map((iface) => ({ kind: 'interface' as const, iface })) - } - - const parsed = parseManualNetworkAddress(trimmed) - - let visible: readonly MobileNetworkInterface[] - if (parsed.ok) { - // Valid address: keep every interface visible. - visible = interfaces - } else { - // Invalid: substring-filter; fall back to full list when nothing matches. - const lowered = trimmed.toLowerCase() - const filtered = interfaces.filter( - (iface) => - iface.address.toLowerCase().includes(lowered) || - iface.name.toLowerCase().includes(lowered) - ) - visible = filtered.length > 0 ? filtered : interfaces - } - - const entries: ComboboxEntry[] = visible.map((iface) => ({ - kind: 'interface' as const, - iface - })) - - if (parsed.ok && !visible.some((iface) => iface.address === parsed.address)) { - entries.push({ kind: 'use-query', address: parsed.address }) - } - - return entries - } - - export function selectRefreshedNetworkAddress( - currentAddress: string | undefined, - interfaces: readonly MobileNetworkInterface[] - ): string | undefined { - if (interfaces.length === 0) { - return undefined - } - if (currentAddress && interfaces.some((iface) => iface.address === currentAddress)) { - return currentAddress - } - return ( - interfaces.find((iface) => isTailnetIPv4Address(iface.address))?.address ?? - interfaces[0]!.address - ) - } - ``` - - The relative import paths (`../../../../shared/tailnet-address`, `../../../../shared/network/manual-address`) must match the file's location in the tree. If they don't resolve, adjust based on the existing path style in the same directory (e.g. `../foo` vs `@/foo`). - -- [ ] **Step 5: Run the test file to verify everything passes** - - ```bash - pnpm vitest run src/renderer/src/components/settings/mobile-network-interface-selection.test.ts - ``` - Expected: all tests pass. - -- [ ] **Step 6: Lint and typecheck** - - ```bash - pnpm lint src/renderer/src/components/settings/mobile-network-interface-selection.ts - pnpm typecheck - ``` - -- [ ] **Step 7: Commit** - - ```bash - git add src/renderer/src/components/settings/mobile-network-interface-selection.ts \ - src/renderer/src/components/settings/mobile-network-interface-selection.test.ts - git commit -m "feat(mobile-pairing): buildComboboxEntries for network interface combobox" - ``` - ---- - -## Task 3: Rewrite `MobileNetworkInterfaceSection` UI to use Popover + Command - -**Files:** -- Modify: `src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx` -- Create: `src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx` - -- [ ] **Step 1: Read `AgentCombobox.tsx` end-to-end** - - Read `src/renderer/src/components/agent/AgentCombobox.tsx` and `agent-combobox-command-state.ts` in full. You will be borrowing: - - The `Popover` + `Command` + `CommandInput` + `CommandList` + `CommandItem` JSX structure. - - The controlled `commandState`/`commandValue` pattern that mirrors `AgentCombobox`. - - The `setInputNode`/`focusSearchInput` pattern for keyboard accessibility. - - Do not import agent-specific helpers (e.g. `searchAgentPickerEntries`, `agent-combobox-command-state` is shared but the search logic isn't needed here). - -- [ ] **Step 2: Read the current `MobileNetworkInterfaceSection.tsx` in full** - - Read the file. Note the props contract: - ```ts - type MobileNetworkInterfaceSectionProps = { - networkInterfaces: MobileNetworkInterface[] - selectedAddress: string | undefined - onSelectedAddressChange: (address: string) => void - refreshingNetworkInterfaces: boolean - onRefreshNetworkInterfaces: () => void - loading: boolean - hasQrCode: boolean - onGenerateQr: () => void - } - ``` - The contract **must not change** — only the inner control does. `MobilePairingQrSection.tsx` consumes `selectedAddress` and is unaffected. - -- [ ] **Step 3: Write the failing render tests** - - Create `src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx`: - - ```tsx - import React from 'react' - import { describe, it, expect, vi } from 'vitest' - import { render, screen } from '@testing-library/react' - import userEvent from '@testing-library/user-event' - import { MobileNetworkInterfaceSection } from './MobileNetworkInterfaceSection' - import type { MobileNetworkInterface } from './mobile-network-interface-selection' - - const LAN: MobileNetworkInterface = { name: 'en0', address: '192.168.1.24' } - const TAILNET: MobileNetworkInterface = { name: 'tailscale0', address: '100.64.1.20' } - - function renderSection(overrides: Partial> = {}) { - const onSelectedAddressChange = vi.fn() - const onRefreshNetworkInterfaces = vi.fn() - const onGenerateQr = vi.fn() - const props: React.ComponentProps = { - networkInterfaces: [LAN, TAILNET], - selectedAddress: TAILNET.address, - onSelectedAddressChange, - refreshingNetworkInterfaces: false, - onRefreshNetworkInterfaces, - loading: false, - hasQrCode: false, - onGenerateQr, - ...overrides - } - const user = userEvent.setup() - const utils = render() - return { ...utils, user, onSelectedAddressChange, onRefreshNetworkInterfaces, onGenerateQr } - } - - describe('MobileNetworkInterfaceSection', () => { - it('renders the trigger with the currently selected address', () => { - renderSection() - expect(screen.getByRole('combobox')).toHaveTextContent('100.64.1.20 (tailscale0)') - }) - - it('lets the user type a custom address and confirms via the Use row', async () => { - const { user, onSelectedAddressChange } = renderSection() - await user.click(screen.getByRole('combobox')) - const input = screen.getByPlaceholderText(/search or type/i) - await user.type(input, 'my-mac.tail-abcd.ts.net') - await user.click(screen.getByRole('option', { name: /Use "my-mac\.tail-abcd\.ts\.net"/ })) - expect(onSelectedAddressChange).toHaveBeenCalledWith('my-mac.tail-abcd.ts.net') - }) - - it('shows an inline error and no Use row when the query is invalid', async () => { - const { user } = renderSection() - await user.click(screen.getByRole('combobox')) - await user.type(screen.getByPlaceholderText(/search or type/i), 'not an address') - expect(screen.getByText(/Enter an IPv4 address or Tailscale MagicDNS hostname/i)).toBeInTheDocument() - expect(screen.queryByRole('option', { name: /Use / })).not.toBeInTheDocument() - }) - - it('suppresses the Use row when the typed address matches an existing interface', async () => { - const { user } = renderSection() - await user.click(screen.getByRole('combobox')) - await user.type(screen.getByPlaceholderText(/search or type/i), '192.168.1.24') - expect(screen.getByRole('option', { name: '192.168.1.24 (en0)' })).toBeInTheDocument() - expect(screen.queryByRole('option', { name: /Use "192\.168\.1\.24"/ })).not.toBeInTheDocument() - }) - - it('renders the (custom) label on the trigger after a custom selection', () => { - renderSection({ selectedAddress: 'my-mac.tail-abcd.ts.net' }) - expect(screen.getByRole('combobox')).toHaveTextContent('my-mac.tail-abcd.ts.net (custom)') - }) - - it('shows No interfaces found when the list is empty', () => { - renderSection({ networkInterfaces: [], selectedAddress: undefined }) - expect(screen.getByRole('combobox')).toHaveTextContent(/no interfaces found/i) - }) - }) - ``` - -- [ ] **Step 4: Run the test file to verify it fails** - - ```bash - pnpm vitest run src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx - ``` - Expected: failures on every assertion — the component still uses `Select`. This is the failing-test step. - -- [ ] **Step 5: Rewrite the component** - - Replace the entire body of `MobileNetworkInterfaceSection.tsx` with: - - ```tsx - import { useCallback, useMemo, useState } from 'react' - import { ChevronDown, ExternalLink, Loader2, QrCode, RefreshCw, Wifi } from 'lucide-react' - import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger - } from '../ui/accordion' - import { Button } from '../ui/button' - import { - Command, - CommandEmpty, - CommandInput, - CommandItem, - CommandList, - CommandSeparator - } from '../ui/command' - import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' - import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' - import { parseManualNetworkAddress } from '../../../../shared/network/manual-address' - import { translate } from '@/i18n/i18n' - import { - buildComboboxEntries, - type MobileNetworkInterface - } from './mobile-network-interface-selection' - - const TAILSCALE_DOWNLOAD_URL = 'https://tailscale.com/download' - const TRIGGER_LABEL_CUSTOM = 'custom' - const ERROR_MESSAGE = 'Enter an IPv4 address or Tailscale MagicDNS hostname' - - type MobileNetworkInterfaceSectionProps = { - networkInterfaces: MobileNetworkInterface[] - selectedAddress: string | undefined - onSelectedAddressChange: (address: string) => void - refreshingNetworkInterfaces: boolean - onRefreshNetworkInterfaces: () => void - loading: boolean - hasQrCode: boolean - onGenerateQr: () => void - } - - function formatInterfaceLabel(iface: MobileNetworkInterface): string { - return `${iface.address} (${iface.name})` - } - - function isCustomLabel(name: string): boolean { - return name === TRIGGER_LABEL_CUSTOM - } - - export function MobileNetworkInterfaceSection({ - networkInterfaces, - selectedAddress, - onSelectedAddressChange, - refreshingNetworkInterfaces, - onRefreshNetworkInterfaces, - loading, - hasQrCode, - onGenerateQr - }: MobileNetworkInterfaceSectionProps): React.JSX.Element { - const [open, setOpen] = useState(false) - const [query, setQuery] = useState('') - // Why: session-scoped custom selection (see spec). Cleared automatically - // when the settings pane unmounts because the state lives in this component. - const [customAddress, setCustomAddress] = useState(null) - - const selectedIface = useMemo(() => { - if (!selectedAddress) return null - const matched = networkInterfaces.find((iface) => iface.address === selectedAddress) - if (matched) return matched - if (selectedAddress === customAddress) { - return { name: TRIGGER_LABEL_CUSTOM, address: selectedAddress } - } - return null - }, [networkInterfaces, selectedAddress, customAddress]) - - const triggerLabel = selectedIface - ? formatInterfaceLabel(selectedIface) - : translate( - 'auto.components.settings.MobileNetworkInterfaceSection.b2c384cfd6', - 'No interfaces found' - ) - - const entries = useMemo( - () => buildComboboxEntries(networkInterfaces, query), - [networkInterfaces, query] - ) - - const queryParse = useMemo(() => parseManualNetworkAddress(query), [query]) - const showInlineError = query.trim() !== '' && !queryParse.ok - - const handleSelectInterface = useCallback( - (iface: MobileNetworkInterface) => { - setCustomAddress(null) - setQuery('') - setOpen(false) - onSelectedAddressChange(iface.address) - }, - [onSelectedAddressChange] - ) - - const handleSelectUseQuery = useCallback( - (address: string) => { - setCustomAddress(address) - setQuery('') - setOpen(false) - onSelectedAddressChange(address) - }, - [onSelectedAddressChange] - ) - - return ( -
-
- - - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.406a35121c', - 'Network Interface' - )} - -
-

- {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.d536b5e20d', - 'Choose which network address to advertise in the QR code. Use your LAN address for same-network pairing, or an overlay network address (Tailscale, ZeroTier) for cross-network access.' - )} -

-
-
- - - - - - - - - - {showInlineError ? ERROR_MESSAGE : translate( - 'auto.components.settings.MobileNetworkInterfaceSection.new-combobox-empty', - 'No matching interfaces' - )} - - {entries.map((entry, index) => { - if (entry.kind === 'interface') { - return ( - handleSelectInterface(entry.iface)} - > - {formatInterfaceLabel(entry.iface)} - - ) - } - const isFirstUseQuery = index > 0 - return ( -
- {isFirstUseQuery ? : null} - handleSelectUseQuery(entry.address)} - > - Use "{entry.address}" - -
- ) - })} -
-
-
-
- - - - - - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.a9db5d771d', - 'Refresh network interfaces' - )} - - -
- {showInlineError ? ( -

- {ERROR_MESSAGE} -

- ) : null} - -
- - - - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.39fad211d9', - 'Connect outside your Wi-Fi with a tailnet' - )} - - -

- {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.9fc5d203ff', - 'Orca Mobile connects directly to this computer. To use it away from the same local network, put your computer and phone on the same private overlay network, then generate the QR code with that network address selected.' - )} -

-
    -
  1. - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.51d29927eb', - 'Install' - )}{' '} - {' '} - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.668016be7a', - 'on your computer and phone.' - )} -
  2. -
  3. - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.1f7c26d36a', - 'Sign in to the same tailnet on both devices.' - )} -
  4. -
  5. - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.87985ba6f5', - 'In this Network Interface menu, choose the Tailscale address, usually a 100.x.y.z IP.' - )} -
  6. -
  7. - {translate( - 'auto.components.settings.MobileNetworkInterfaceSection.63d5e4ae1e', - 'Regenerate the QR code and scan it from the Orca mobile app.' - )} -
  8. -
-
-
-
-
- ) - } - ``` - - Notes for the implementer: - - `Command shouldFilter={false}` because we already filter in `buildComboboxEntries`. Without this, cmdk's default fuzzy filter would re-filter the manually-injected `__use__` row out of view. - - The `value` prop on each `CommandItem` keeps cmdk happy but does not affect our filtering; we own filtering. - - `useMemo` is used for `selectedIface`, `entries`, and `queryParse` so re-renders triggered by parent prop changes don't churn the combobox state. - - If `isCustomLabel` is unused after a lint pass (no caller in the final file), delete the helper — the spec lists it but the implementation inlines the check. - -- [ ] **Step 6: Run the new test file** - - ```bash - pnpm vitest run src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx - ``` - Expected: all six tests pass. - - If any fail: - - `toHaveTextContent` mismatches → check the trigger label format. - - `getByRole('option', { name: /Use "…"/ })` not found → verify `Command shouldFilter={false}` is set. - - Inline error not appearing → verify `showInlineError` is computed against `queryParse.ok`. - -- [ ] **Step 7: Run the full test suite for the settings folder** - - ```bash - pnpm vitest run src/renderer/src/components/settings/ - ``` - Expected: all green. Confirm nothing else broke (e.g. `MobilePairingQrSection.test.tsx` if it exists). - -- [ ] **Step 8: Lint, typecheck, build** - - ```bash - pnpm lint - pnpm typecheck - pnpm build - ``` - Expected: all green. If `pnpm lint` complains about an unused import (e.g. `isCustomLabel` or `CommandSeparator`), remove it and re-run. - -- [ ] **Step 9: Commit** - - ```bash - git add src/renderer/src/components/settings/MobileNetworkInterfaceSection.tsx \ - src/renderer/src/components/settings/MobileNetworkInterfaceSection.test.tsx - git commit -m "feat(mobile-pairing): combobox with manual address entry" - ``` - ---- - -## Task 4: End-to-end verification & PR prep - -**Files:** none modified; verification only. - -- [ ] **Step 1: Run the four CI checks locally** - - ```bash - pnpm lint - pnpm typecheck - pnpm test - pnpm build - ``` - Expected: all green. This is exactly what `.github/workflows/` runs. - -- [ ] **Step 2: Manual smoke check** - - If Orca can be launched locally with the desktop build: - 1. Open Settings → Mobile. - 2. Click the Network Interface dropdown — confirm both interfaces list. - 3. Type a tailnet address — confirm the `Use "…"` row appears. - 4. Click `Use "…"` — confirm the trigger switches to `… (custom)`. - 5. Click Refresh — confirm the custom selection survives. - 6. Type an invalid string — confirm the inline error appears and no `Use …` row is shown. - 7. Type an address that already matches an interface (e.g. `192.168.1.24`) — confirm the `Use` row is hidden and selecting the interface row does NOT show `(custom)`. - - Skip any step that requires a running desktop build and note it in the PR description. - -- [ ] **Step 3: Capture before/after screenshots** - - Save screenshots to a temp directory (NOT to the repo — `CONTRIBUTING.md` and `AGENTS.md` forbid committing PR evidence images). Use gstack browse if running the production binary: - ```bash - $HOME/.claude/skills/gstack/browse/dist/browse screenshot /tmp/orca-mobile-section-before.png --selector ... - ``` - Attach the screenshots to the PR conversation (never use `gh-attach`). - -- [ ] **Step 4: Push the branch and draft the PR** - - ```bash - git push origin HEAD - gh pr create \ - --title "feat(mobile-pairing): combobox with manual network address entry" \ - --body-file /tmp/pr-body.md - ``` - - The PR body should follow `pull_request_template.md`: - - **Summary:** explain that the desktop Network Interface dropdown now accepts a manually-typed IPv4 or Tailscale MagicDNS hostname. - - **Screenshots:** link to the conversation-attached screenshots. - - **Testing:** check all four `pnpm` boxes; note that `pnpm test` includes the three new test files (`manual-address.test.ts`, `mobile-network-interface-selection.test.ts`, `MobileNetworkInterfaceSection.test.tsx`). - - **AI Review Report:** confirm the review checked cross-platform (no platform-specific code; uses shadcn primitives + cmdk patterns already in the codebase), SSH/remote/local compatibility (no change to main-process IPC), agent/integration compatibility (no change), performance (UI re-render cost is bounded by `useMemo` over the interface list), UI quality (follows STYLEGUIDE.md; uses `text-statusRed` for the error), security (no new IPC; the address flows through the existing `selectedAddress` prop which `MobilePairingQrSection` already validated). - - **Security Audit:** no new IPC, no new auth surface, no new dependency, no new env var. The parser rejects malformed input early so the QR endpoint never sees invalid data. - - **Notes:** none — single platform-agnostic renderer change. - - **X handle:** include the contributor's X handle per CONTRIBUTING.md so maintainers can shout out. - -- [ ] **Step 5: Request review** - - ```bash - gh pr edit --add-reviewer - ``` - Or comment `@` in the PR if reviewer auto-assignment isn't available. - ---- - -## Definition of done - -- All four `pnpm` checks pass locally. -- All new and existing tests pass. -- The component renders correctly in a smoke test (or smoke-test steps are documented as skipped in the PR). -- The PR is open with a body matching `pull_request_template.md`. -- The contributor's X handle is in the PR description. diff --git a/docs/reference/plans/2026-07-10-ssh-repo-host-reconciliation.md b/docs/reference/plans/2026-07-10-ssh-repo-host-reconciliation.md deleted file mode 100644 index ac3249d306e..00000000000 --- a/docs/reference/plans/2026-07-10-ssh-repo-host-reconciliation.md +++ /dev/null @@ -1,131 +0,0 @@ -# SSH Repo Host Reconciliation Design - -Date: 2026-07-10 - -## Problem - -The proven flow is **Settings -> SSH -> remove host -> re-add the same host**. Main matches the -removal tombstone and `Store.reassignSshTargetId` moves the persisted repo from the old target ID to -the new target ID without changing `Repo.id`. A renderer catalog merge can then retain the cached -old-target row alongside the fetched new-target row because repo rows are keyed by execution host -and repo ID. - -The stale row can route a terminal or destructive action to a removed SSH target. PR #7997 made -worktree deletion host-scoped and fail closed; this follow-up removes the superseded renderer row -without weakening that boundary. - -## Why UUID Inference Is Unsafe - -The same `Repo.id` can legitimately exist on multiple hosts. For example, a local checkout and a -checkout on an SSH server can share the repository UUID. Removing the SSH host without re-adding it -must keep the SSH ghost visible so the user can forget it. A local or runtime row with the same UUID -does not prove that the SSH row moved. - -Main already has exact migration evidence. `readoptOrphanedWorkspacesForTarget` selects a removed -target tombstone, and `Store.reassignSshTargetId` knows which repo IDs it moved from that old target -to the new target. The renderer must consume this evidence instead of inferring migration from live -siblings or SSH target metadata. - -## Ordering Gap - -Main sends `repos:changed` before `ssh:addTarget` or `ssh:importConfig` returns. Either order is -possible in renderer state: - -1. The catalog transaction can merge old and new rows before the add response supplies migration - evidence. -2. The add response can supply evidence while renderer state still contains only the old row. - -Evidence must therefore remain pending until the corresponding new direct-SSH row arrives. At that -point the renderer removes only the mapped `(repoId, oldTargetId)` row and its old host setup, and -moves cached worktree ownership onto the new host. - -## Non-Goals - -- Do not re-key repo or worktree UUIDs or introduce compound serialized IDs. -- Do not infer host migration from labels, paths, target-list absence, or same-UUID siblings. -- Do not remove PR #7997's execution-host context from destructive operations. -- Do not change git-provider behavior or add provider-specific assumptions. - -## Design - -1. `Store.reassignSshTargetId` returns the exact repo IDs it moved instead of only a count. -2. Main records a list of `{ oldTargetId, newTargetId, repoIds }` for each add/import operation. -3. The SSH IPC add and import results return the changed targets plus this migration evidence. Main - still sends `repos:changed` when at least one repo moved. -4. Both desktop SSH management surfaces record the evidence in the repo slice immediately after the - invoke resolves. The web preload returns an empty evidence list because target management is - desktop-only. -5. The repo slice merges duplicate evidence and keeps unresolved repo IDs pending. Every local, - targeted-runtime, and all-host catalog transaction reconciles against the latest pending set. -6. Reconciliation requires the exact new direct-SSH owner to exist before pruning the exact old - direct-SSH owner. Local rows, runtime-owned rows, unrelated SSH hosts, and same-UUID siblings are - not considered evidence. -7. When a repo row is pruned, its matching `ProjectHostSetup` is removed. The desktop catalog owns - local and direct-SSH setups; `runtime:` setups remain authoritative on the remote - Orca server. -8. Cached visible and detected worktree rows for the migrated repo are moved from the exact old SSH - host to the new host. If a new-host worktree row already arrived, it wins and the stale duplicate - is discarded. -9. A host-scoped worktree response is ignored if its captured repo/host owner no longer exists. - Repo catalog transactions also reapply pending worktree migration, covering responses that land - after evidence arrives but before the new repo row. - -```text -main: remove target -> tombstone -main: re-add matching identity -> reassign old target ID to new target ID -main: return exact (old target, new target, moved repo IDs) -renderer: retain evidence until catalog includes the exact new SSH owner -renderer: remove only the exact old SSH row/setup and migrate cached worktree ownership -``` - -## Safety Constraints - -- A local or runtime sibling with the same UUID never supersedes an SSH ghost. -- A mapping is not consumed until the exact new direct-SSH row exists. -- Runtime-owned SSH rows and `runtime:` setup rows remain untouched. -- Catalog transactions reconcile inside the Zustand updater so overlapping responses use the latest - repos and pending evidence. -- Missing, offline, or unhydrated target metadata is not deletion or migration evidence. - -## Test Plan - -- Persistence returns exact migrated repo IDs and leaves other hosts untouched. -- Tombstone re-adoption returns exact old/new/repo mappings for manual add and multi-host import. -- SSH IPC returns the evidence, sends `repos:changed`, and clears operation-local evidence. -- Pure renderer tests prove exact pruning, pending evidence, unrelated-host preservation, runtime - ownership preservation, and evidence deduplication. -- Store tests cover both event orders: catalog first and evidence first. -- Remove-without-re-add keeps a forgettable SSH row and setup when a local repo shares its UUID. -- Old direct-SSH setups are removed only for proven migrations; runtime setups remain. -- Old/new cached worktree duplicates collapse to the authoritative new-host row, and an old-only - worktree row migrates to the exact new host. -- A deferred old-host worktree response cannot restore old visible or detected ownership after the - new repo catalog consumes the migration evidence. -- Targeted runtime and all-host catalog transactions reconcile against the latest state. - -Run focused tests, typecheck, lint, `pnpm check:max-lines-ratchet`, and the production build required -by the repository. - -## Electron Validation - -Use an isolated profile and a throwaway Linux SSH target: - -1. Add a repo through the throwaway SSH target. -2. Remove the SSH target and re-add the same identity without restarting the renderer. -3. Confirm exactly one SSH project row remains and its worktree opens. -4. Confirm Explorer lists the remote files without an ambiguous-host routing error. -5. Repeat the cycle and confirm the stale row does not return. -6. Keep an adjacent local project visible and confirm it remains unchanged. - -There is no new control or copy. The behavioral evidence is the stable row, successful workspace -routing, and deterministic store assertions. - -## Performance And Scope - -Renderer reconciliation indexes direct SSH repo owners once, then processes each migrated repo ID -once. Setup cleanup is linear in current repos and setups. The change adds no polling, listeners, -subprocesses, or persistent renderer state. IPC payload growth is bounded by the repo IDs actually -migrated during the user-triggered add/import operation. - -Windows and POSIX paths do not participate in reconciliation. SSH identity matching remains the -existing strict alias or host/user/port logic, and git-provider identity is not used. diff --git a/docs/reference/project-ordering-mode.md b/docs/reference/project-ordering-mode.md deleted file mode 100644 index 4cdc159551b..00000000000 --- a/docs/reference/project-ordering-mode.md +++ /dev/null @@ -1,108 +0,0 @@ -# Project Ordering Mode - -## Problem - -The sidebar can group workspaces by project, but project header order is currently coupled to workspace sorting instead of having its own user choice. - -- `src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx:59` defines one `Sort by` control for workspaces; it has no separate project-order setting. -- `src/renderer/src/store/slices/ui.ts:723` stores only `sortBy`, and `src/shared/types.ts:2543` persists the same workspace sort field. -- `src/renderer/src/components/sidebar/worktree-list-groups.ts:36` derives `ProjectGroupOrdering` from `sortBy`, so project headers follow the highest-ranked visible workspace in Recent/Smart and fall back to manual order otherwise. -- `src/renderer/src/components/sidebar/WorktreeList.tsx:3765` computes the ordered workspace ids, and `src/renderer/src/components/sidebar/WorktreeList.tsx:4060` feeds the derived project ordering into `buildRows(...)`. -- `src/renderer/src/components/sidebar/WorktreeList.tsx:826` only enables project-header drag when project grouping is manual and there are no Project Groups. -- `src/renderer/src/components/sidebar/project-header-drag.ts:1` already implements pointer-based project header dragging for virtualized rows, and `src/renderer/src/store/slices/repos.ts:699`, `src/main/ipc/repos.ts:1029`, and `src/main/persistence.ts:2497` already persist whole-repo manual reorders. - -Result: users cannot choose Manual vs Recent ordering specifically for projects, the default is not Manual, and changing workspace sort can unexpectedly reorder projects. - -## Goal - -Add a project-only ordering preference with two modes: - -- `Manual`, the default. Project headers render in the persisted manual project order, and users can drag project headers to reorder them. -- `Recent`. Project headers render by the most recent visible workspace activity in each project. - -This must affect only project header order in `groupBy: 'repo'`. Worktree/workspace rows inside each project must continue to use the existing workspace `sortBy`, filtering, pinning, lineage, status, and manual-order behavior. - -## Non-goals - -- Do not change `sortBy` semantics or the workspace `Sort by` control. -- Do not change worktree `manualOrder`, `sortOrder`, `lastActivityAt`, or `buildWorktreeComparator(...)`. -- Do not reorder worktrees inside a project when the project ordering mode changes. -- Do not change Project Group header order; groups continue to use `ProjectGroup.tabOrder`. -- Do not add GitHub-specific behavior. Repo/project ordering must remain provider-neutral and work for GitLab, folder projects, local repos, SSH repos, and runtime environments. -- Do not add telemetry in this pass unless a later product requirement asks for it. - -## Design - -1. Add a persisted project-order preference. - - Introduce `ProjectOrderBy = 'manual' | 'recent'` in `src/shared/types.ts` or a concrete sidebar ordering module. - - Add `projectOrderBy: ProjectOrderBy` to `PersistedUIState`, defaulting to `'manual'` in `getDefaultUIState()` (`src/shared/constants.ts:387`). - - Add `projectOrderBy` and `setProjectOrderBy` to the UI slice next to `sortBy` (`src/renderer/src/store/slices/ui.ts:723`). Like `setSortBy`, this setter is a bare `set({...})` — it does not persist on its own. - - Persist through the debounced UI writer, not the setter. `sortBy`/`groupBy` reach disk only via the explicit field list in `App.tsx`'s `window.api.ui.set({...})` effect (`src/renderer/src/App.tsx:1019`) plus its dependency array (`src/renderer/src/App.tsx:1042`). Add `projectOrderBy` to both, or the value lives only in memory and silently resets to `'manual'` every restart. - - Normalize persisted values in `src/main/persistence.ts` the same way `sortBy` is normalized at `src/main/persistence.ts:323`, and wire the normalizer into both `getUI()` (`src/main/persistence.ts:3116`) and `updateUI()` (`src/main/persistence.ts:3147`); invalid or missing values resolve to `'manual'`. - - Hydration in `src/renderer/src/store/slices/ui.ts:1862` should read the normalized value without migrating existing `sortBy`. - -2. Expose the choice in the sidebar options menu. - - In `SidebarWorkspaceOptionsMenu`, keep the existing `Sort by` submenu as workspace sorting. - - Add a second radio submenu labeled `Project order` with `Manual` and `Recent`. - - Show it only when `groupBy === 'repo'`, because project ordering has no visible effect in `none`, `workspace-status`, or `pr-status`. - - Use existing `DropdownMenuRadioGroup` and sidebar/menu styling from `docs/STYLEGUIDE.md`; no new tokens or custom colors. - - Copy should make the scope clear: Manual description `Drag projects to arrange them`; Recent description `Most recent workspace activity`. - -3. Decouple project header ordering from workspace sorting. - - Replace `getProjectGroupOrdering(groupBy, sortBy)` with a project-order resolver that depends on `groupBy` and `projectOrderBy`, not workspace `sortBy`. - - Update `buildRows(...)` so `groupBy !== 'repo'` ignores project order, `projectOrderBy === 'manual'` uses persisted manual ranks, and `projectOrderBy === 'recent'` sorts project header entries by a per-repo recent timestamp. - - Recent must be timestamp-based, not first-encounter. Today's `'visible-worktree-order'` path (`src/renderer/src/components/sidebar/worktree-list-groups.ts:548`) works only because the caller pre-sorts the worktree array by recency when `sortBy` is `recent`/`smart`. Decoupling from `sortBy` removes that guarantee — the incoming array may be name- or manual-sorted — so the Recent resolver must explicitly compute `max(lastActivityAt)` per repo rather than relying on encounter order. - - Recent project rank should be the maximum `lastActivityAt` among that repo's visible, unpinned worktrees passed into `buildRows(...)`. Empty placeholder projects and imported-worktree-card-only projects fall back to `Repo.addedAt`, then manual rank, then label. - - Do not reorder `group.items`; only reorder the project header entries before `appendOrderedGroups(...)`. This preserves the existing `orderMainWorktreeFirst(...)` and workspace row ordering at `src/renderer/src/components/sidebar/worktree-list-groups.ts:632`. - - With Project Groups, keep group headers ordered by `tabOrder`; apply Manual/Recent only to project entries within each group or the ungrouped bucket. - -4. Make Manual drag/drop project-scoped (no-Project-Groups case only in v1). - - Enable project header dragging when `groupBy === 'repo' && projectOrderBy === 'manual'`, regardless of workspace `sortBy`. Today `canReorderRepoHeaders` is additionally gated on `!hasProjectGroups` (`src/renderer/src/components/sidebar/WorktreeList.tsx:827`); the only change here is dropping the `sortBy`-derived `projectGroupOrdering` input in favor of `projectOrderBy === 'manual'`. The `!hasProjectGroups` gate stays. - - Keep using pointer events from `project-header-drag.ts` unchanged; the virtualized-row reasoning at `src/renderer/src/components/sidebar/project-header-drag.ts:3` still applies. For the no-Project-Groups case it already does exactly what we need: a flat whole-list permutation committed through `reorderRepos(...)`, preserving current behavior and IPC rejection semantics. - - Continue to suppress click-to-collapse only after a promoted drag, matching `src/renderer/src/components/sidebar/project-header-drag.ts:142`. - - Defer grouped (within-Project-Group) drag to a follow-up. `useRepoHeaderDrag` is built for a single flat `orderedRepoIds` permutation: `endDrag` (`src/renderer/src/components/sidebar/project-header-drag.ts:130`) computes `fromIndex`/`insertAt` over one array and calls `onCommit` → `reorderRepos`, and `onHandlePointerDown` (`src/renderer/src/components/sidebar/project-header-drag.ts:300`) snapshots every on-screen `[data-repo-header-id]` with no notion of group boundaries. Supporting in-group reorder needs new hook logic — bucket-aware drop targets, rejecting drops outside the source sibling bucket, computing a midpoint between neighbor `projectGroupOrder` values, and a second commit mode that calls `moveProjectToGroup(projectId, sameGroupId, order)` (`src/renderer/src/store/slices/repos.ts:138`) instead of `reorderRepos`. This is its own change and is out of scope for v1. - - In v1, Project-Groups users keep the project actions menu as the move surface (it already calls `moveProjectToGroup`). Manual project order still applies to their headers via the row-builder sort (step 3); only drag-to-reorder is unavailable inside groups. - - Do not turn a project reorder drag into a cross-group move in any version. The existing project actions menu remains the cross-group move surface. - -5. Keep persistence and runtime parity intact. - - Local no-group manual reorder continues through `repos:reorder` (`src/main/ipc/repos.ts:1029`) and `Store.reorderRepos(...)` (`src/main/persistence.ts:2497`). - - Remote no-group manual reorder continues through `repo.reorder` (`src/renderer/src/store/slices/repos.ts:723`). - - Grouped manual reorder via drag is deferred (see step 4); the follow-up would route through `projectGroup.moveProject` / `moveProjectToGroup(...)`, which already handles local and runtime-environment calls. In v1, grouped projects move only through the existing actions menu. - - The project-order preference is renderer UI state, not repo metadata. It persists through the `App.tsx` debounced `window.api.ui.set({...})` writer (step 1), not through any repo record. - -6. Tests. - - Add row-builder tests proving `projectOrderBy: 'manual'` orders project headers by `repoOrder` without changing workspace row order. - - Add row-builder tests proving `projectOrderBy: 'recent'` orders project headers by max visible `worktree.lastActivityAt` while preserving each project's child row order. - - Add Project Group tests for Manual and Recent within groups, plus unchanged `ProjectGroup.tabOrder`. - - Rewrite, do not extend, the existing Recent/`getProjectGroupOrdering` tests. The current cases at `src/renderer/src/components/sidebar/worktree-list-groups.test.ts:639` ("orders repo headers by first encounter…"), `:663` ("…highest-ranked visible child"), and the `getProjectGroupOrdering` block at `:752` all assert the old first-encounter coupling and are semantically incompatible with timestamp-based Recent. Replace them with tests for the new resolver and the `max(lastActivityAt)` ordering. - - Add UI slice/persistence normalization tests for default Manual, invalid value fallback, and hydration. Include a persistence-writer test (or note) that `projectOrderBy` is part of the `App.tsx` `ui.set` payload so it actually round-trips across restart. - - Grouped manual drag persistence tests are deferred with the grouped-drag feature (step 4). - -## Edge cases - -- Existing users with no `projectOrderBy` get Manual, even if their workspace `sortBy` is Recent. -- Changing workspace `sortBy` must not change project header order unless `projectOrderBy === 'recent'` and the visible worktree set/activity data changes. -- Changing `projectOrderBy` must not mutate any worktree `manualOrder` or repo order by itself. -- Manual drag while a project is added or removed can race. Whole-repo reorder (the only v1 drag path) keeps the existing permutation rejection and refetch behavior. (Grouped midpoint writes and their refetch-on-failure handling come with the deferred grouped-drag follow-up.) -- In Recent mode, projects with no visible worktrees should still render when they are placeholders or imported-worktree-card candidates; they sort after projects with activity. -- Pinned worktrees remain in the Pinned section. They should not make their project jump in Recent ordering unless an unpinned visible workspace in that project is also recent. -- Filters and hidden sleeping/default-branch workspaces affect the visible worktree set. Recent project order should reflect the rows the user can currently see. -- Project Group collapse state must not change when the order mode changes. -- Dragging a project inside a collapsed Project Group is impossible because its repo headers are not mounted; no special handling is needed. -- Dragging across Project Group boundaries should not silently move the project; cross-group moves stay on the actions menu. (In v1 there is no in-group drag at all — see step 4 — so this only constrains the deferred grouped-drag follow-up.) -- SSH/runtime projects must use the same store actions as local projects. No local filesystem path assumptions are needed. -- Folder projects have synthetic worktrees and should participate through the same `lastActivityAt` and manual repo order paths. -- Multi-window or external mutations are last-writer-wins through existing persistence. A rejected whole-repo permutation refetches repos (existing behavior); a failed grouped move would do the same once grouped drag lands. -- Behavior change on upgrade: existing users on `sortBy: recent`/`smart` currently see project headers bubble to follow workspace activity. After this change they default to Manual project order, so headers stop bubbling until they pick Recent in the new submenu. This is intended (matches the "no `projectOrderBy` → Manual" edge case) but is a visible change worth calling out in release notes. - -## Rollout - -1. Add `ProjectOrderBy` types/defaults/normalization, the UI slice setter, the `App.tsx` debounced-writer field, and persistence `getUI()`/`updateUI()` wiring. -2. Add the `Project order` submenu in `SidebarWorkspaceOptionsMenu`. -3. Update `WorktreeList` to read `projectOrderBy`, pass it to row construction, and enable project drag only in Manual project order (no-Project-Groups case, keeping the `!hasProjectGroups` gate). -4. Update `worktree-list-groups.ts` to order project headers by Manual or Recent independently from workspace `sortBy`, with Recent using `max(lastActivityAt)` per repo. -5. Add focused row-builder and UI persistence/round-trip tests; rewrite the incompatible first-encounter/`getProjectGroupOrdering` tests. -6. Run targeted Vitest for sidebar row ordering and repo slice tests, then `pnpm typecheck` and `pnpm lint`. -7. Validate in Electron: default startup shows Manual project order, the choice survives restart, project drag/drop reorders project headers only (ungrouped), Recent project order follows workspace activity, and worktree rows inside each project do not change when toggling project order. - -Deferred follow-up (separate change): extend `useRepoHeaderDrag` for grouped sibling buckets — bucket-aware drop targets and a second commit mode using `moveProjectToGroup(...)` midpoint ordering inside Project Groups — plus its grouped-drag persistence tests. diff --git a/docs/reference/reliability-gates-implementation-plan.md b/docs/reference/reliability-gates-implementation-plan.md deleted file mode 100644 index 818bdb175e1..00000000000 --- a/docs/reference/reliability-gates-implementation-plan.md +++ /dev/null @@ -1,783 +0,0 @@ -# Reliability Gates Implementation Plan - -Date: 2026-07-02 - -Source context: [`docs/reference/reliability-pain-points-2026-06-30.md`](./reliability-pain-points-2026-06-30.md). - -## Current Status - -Last updated: 2026-07-03. - -The first repo-side reliability batch is useful, but it should be treated as a first-layer regression net, not a finished terminal reliability program. Before final merge claims, rebase or retarget the stack onto fresh `origin/main` and verify it still includes the protections that landed there after the branch was cut. - -Stack topology facts the rest of this plan depends on (verified 2026-07-02): - -- `brennanb2025/fix-terminal-reliability` is simultaneously the integration worktree and the head branch of #7001, and it is the base branch of all five child PRs (#7004-#7008). Committing broad product changes to it would turn #7001 into the single large terminal PR this plan forbids and would silently change every child PR's diff. -- The committed branch contains only the manifest, checker, checker tests, and these docs (a 5-gate manifest as committed). Everything else this plan describes — the expanded manifest, product hardening, and new or updated tests across roughly 39 modified files plus 3 untracked files — exists only as uncommitted working-tree changes in this worktree. No evidence run below is reproducible from any pushed ref until the split branches are committed and pushed. -- The branch's merge-base with `origin/main` is roughly 97 commits stale. Fresh main renamed `terminal-webgl-paste-recovery.{ts,test.ts}` to `terminal-webgl-atlas-recovery.{ts,test.ts}` in #6949, so the current `xterm-addon.boundary-containment` manifest entry and its documented command break on rebase; see Fresh-Eyes Corrections. -- The manifest is the single source of truth for gate counts and per-gate status. Where this document records counts they are dated snapshots; `pnpm run check:reliability-gates` prints the current gate total. - -Known Orca PR stack: - -- [#7001](https://github.com/stablyai/orca/pull/7001), `Add reliability gate manifest`: merged on 2026-07-03. The manifest, checker, and plan docs are on main; [#7295](https://github.com/stablyai/orca/pull/7295) hardens the checker policy and registers the merged #7133/#7148/#7173/#7192 regression tests as partial gates with fresh main-side evidence. -- [#7005](https://github.com/stablyai/orca/pull/7005), `Add terminal snapshot freshness contract gate`: depends on #7001 and protects the stale-liveness/newborn-PTY class behind `terminal-session.snapshot-freshness`. -- [#7008](https://github.com/stablyai/orca/pull/7008), `Expand provider session replay ownership gate`: depends on #7001 and protects the agent/provider ownership class behind `agent-session.provider-ownership`. -- [#7004](https://github.com/stablyai/orca/pull/7004), `Contain xterm addon failures to terminal panes`: depends on #7001. The current branch has a first executable xterm containment slice for addon load, link-provider, search, and WebGL failure containment; it still needs live typed input/output survival before promotion. -- [#7006](https://github.com/stablyai/orca/pull/7006), `Prove visible terminal size convergence`: depends on #7001. The current branch now has a first deterministic renderer/main contract slice for post-spawn reconcile, split-right 0x0 recovery, applied `pty:getSize`, and resume-time size reassertion; it still needs live shell, SSH/remote, and Windows ConPTY geometry proof before promotion. -- [#7007](https://github.com/stablyai/orca/pull/7007), `Add persisted session upgrade fixture gate`: depends on #7001. The current manifest entry is still commandless in this branch, so treat it as a registered startup-upgrade gap until immutable fixture commands are wired. - -Merged related product hardening: - -- [#7054](https://github.com/stablyai/orca/pull/7054), `Prevent hidden terminal TUI overlap`: merged on 2026-07-01 with `verify`, Wayland terminal input, golden Linux E2E, golden mac E2E, and community tracking checks passing. This fixes the hidden regular-terminal TUI overlap class by scheduling bounded post-parse WebGL atlas recovery for risky hidden TUI redraws while keeping hidden bytes on the background write path. -- [#7133](https://github.com/stablyai/orca/pull/7133), `Fix stale terminal frames on worktree return`: merged on 2026-07-02 as a high-priority regression fix, root-caused on a live repro. Hidden-output restore skipped the destructive clear for alternate-screen snapshots, `?1049h` is a no-op on a pane already on the alt screen, and serialized frames skip blank cells, so the pre-hide frame bled through the final frame until a select or resize. The fix writes an alt-only `\x1b[?1049h\x1b[2J\x1b[H` preamble before alt-screen snapshot data (normal-buffer scrollback untouched, still no `3J`) and hardens the WebGL reveal path: dispose-on-bail with a single-addon invariant, refresh-always atlas reset, and a settled-frame pane-scoped repaint on tab reveal, worktree resume, and window wake. It supersedes the reverted #7058 approach and closes the alt-screen hole that #7054's hidden-output skip machinery made load-bearing. Its deterministic tests — the alt-only-clear-before-snapshot assertions in `pty-connection.test.ts`, plus `pane-webgl-renderer.test.ts`, `pane-reveal-repaint.test.ts`, and `terminal-visibility-resume.test.ts` — are the seed assertions for `terminal-output.scrollback-restore` and the WebGL side of `xterm-addon.boundary-containment` once the stack rebases. - -- [#7148](https://github.com/stablyai/orca/pull/7148), `Match main terminal mirror character widths to the renderer`: merged on 2026-07-02, hours after #7133. Root cause of the residual post-#7133 bottom-row tears: the renderer xterm measures with Unicode 11 + ZWJ joining while the daemon headless mirror used default-width tables, so every positioned write after an emoji lands shifted and the tears are baked into the very mirror that restores replay — #7133's clear made restores faithful to the mirror and exposed it. The fix moves the unicode provider to `src/shared/terminal-unicode-provider.ts` and activates it in the headless emulator. Its red-green width test, `src/main/daemon/headless-emulator-unicode-width.test.ts` on main, is the seed assertion for the new `terminal-mirror.parser-parity` gate, and the shared provider plus `pane-terminal-options.ts` are the shared-construction seams that gate must assert on. #7148 also touches `pane-lifecycle.ts`, which both this worktree and #7004's branch modify — another confirmed rebase-conflict site. -- [#7173](https://github.com/stablyai/orca/pull/7173), `Fix Codex pane output tearing after app occlusion`: merged on 2026-07-03. A delayed background-origin Codex chunk carrying a stateful query could take the hidden live-write path after a newer snapshot restore had already rebuilt the renderer, overwriting newer state; an adversarial review pass added seq-restart guards so revived sessions (hibernation wake, kill plus cold-restore under the same PTY id) cannot silently freeze hidden output against a stale seq high-water mark. Its three red-proven tests in `pty-connection.test.ts` on main — reference-parse buffer equality over the ordered raw stream plus both revival guards — are exactly the exactness-oracle shape this plan prescribes and seed the hidden/live interleaving slice of `terminal-output.scrollback-restore`. -- [#7192](https://github.com/stablyai/orca/pull/7192), `Keep runtime mirror sized with desktop terminal resizes`: merged on 2026-07-03. Desktop `pty:resize` updated the PTY and renderer but never the runtime mirror, so the mirror parsed all subsequent bytes at its birth width, soft-wrapped TUI repaints into a corrupted ladder, and baked that corruption into every restore snapshot; the fix fans accepted desktop resizes to the mirror and serializes mirror reflow with queued output writes on the per-PTY write chain. This exposed a plan gap now fixed: the runtime mirror is a geometry authority in its own right, and renderer-vs-mirror convergence checks cannot see corruption upstream of the mirror. Its red-proven tests in `orca-runtime.test.ts` and `pty.test.ts` on main seed the mirror-geometry slice of `terminal-geometry.visible-convergence`. - -Factory and skill status: - -- [orca-internal #491](https://github.com/stablyai/orca-internal/pull/491), `Require reliability gates in factory reviews`: merged on 2026-07-01. This updates the factory review flow so terminal/session/provider/startup/release changes must name the relevant reliability class, invariant, gate or accepted gap, and validation evidence. -- [orca-internal #493](https://github.com/stablyai/orca-internal/pull/493), `Add Playwright reliability test skill`: as last reviewed, this still needed rebase/regeneration before merge because the reliability plan depends on Playwright tests being evidence-based, low-flake, and tied to named invariants. - -Remaining work: - -- Rebase or retarget the reliability stack on fresh `origin/main` before making final merge claims. Current main already contains terminal fixes that the stack must absorb, including hidden TUI overlap, input-lag, scrollback replay, Windows ConPTY keyboard reset, resume width flicker, remote mirror polling, and the #7133 stale alt-screen frame fixes. As part of the rebase, mechanically verify every declared `testFiles` entry in the manifest exists on fresh `origin/main`; the #6949 WebGL rename is a confirmed break. #7133 (merged 2026-07-02) touches `pty-connection.{ts,test.ts}` and `use-terminal-pane-global-effects.test.ts`, which this worktree also modifies, and `pane-webgl-renderer.{ts,test.ts}`, which #7004's branch also edits — expect real conflicts there. #7148 (merged 2026-07-02) additionally touches `pane-lifecycle.ts` and `headless-emulator.ts`, so the worktree's and #7004's pane-lifecycle edits must absorb its shared unicode-provider wiring. #7173 (merged 2026-07-03) rewrites the hidden live-write path in `pty-connection.{ts,test.ts}` and #7192 (merged 2026-07-03) touches `pty.test.ts` — both carry heavy worktree modifications, so the conflict surface keeps widening the longer the rebase waits. This worktree's replay/SSH/backpressure changes and #7004's containment changes must be rebased over #7133's alt-clear preamble, #7148's width fix, #7173's seq filtering, #7192's mirror resize fan-out, and the reveal-repaint hardening without weakening any side. -- Materialize the split: commit each uncommitted working-tree slice to its owning branch per the file assignment in the PR Split Plan, reset diverged child branches from the worktree versions of their files, and push. Only manifest, checker, and doc changes may be committed to `brennanb2025/fix-terminal-reliability` itself. -- Wire the aggregated non-blocking `reliability-gates` CI job (see Evidence Provenance And CI Wiring) so runtime and flake history accrue with machine provenance instead of hand-entered evidence. -- Split the current integration branch into focused PRs before final review and merge. Do not land this as one large terminal reliability PR; each PR should have its own invariant, evidence command, residual gaps, dependency note, and follow-up relationship to the rest of the stack. -- #7001 merged on 2026-07-03. Merge the gate-specific PRs only after resetting each from the worktree slice per the file assignment. -- Before merging each child PR, recheck it against the latest #7001/main because the shared manifest file can create normal stack drift or conflicts even when the PR currently reports clean. -- After merge, run the registered gate commands in soak and record runtime/flake evidence before promoting any gate to blocking. -- Update the factory skills again after the repo-side gate commands and CI jobs are stable, so agents can invoke exact commands instead of only requiring reliability evidence. -- Keep #7054's accepted gap visible: deterministic Claude-like TUI streams validated the renderer path, but live Claude Code was not part of that PR's validation. #7133 has since closed the alt-screen snapshot-restore hole in the same hidden-output machinery, and its buffer-merge root cause is exactly the class the `terminal-output.scrollback-restore` gate must make hard to reintroduce; the live-Claude validation gap remains. - -## PR Split Plan - -The current `brennanb2025/fix-terminal-reliability` branch is an integration branch, not the desired final review shape. It should be split into smaller PRs so reviewers can verify one reliability class at a time, bisect failures, and avoid merging a broad terminal lifecycle change without understanding which protection each file belongs to. - -Branch and manifest ownership rules: - -1. `brennanb2025/fix-terminal-reliability` is #7001's head branch. Only manifest, checker, checker-test, and plan-doc changes may be committed to it. Split items 2-6 go to their own branches, stacked on this branch and retargeted to `main` after #7001 merges. -2. #7001 may only declare gates whose `testFiles` all exist on `origin/main`. The checker's declared-file rule makes this self-enforcing: a manifest entry whose test files land in a later PR cannot pass CI inside #7001. -3. Every later PR owns its own manifest entries. It adds or upgrades the gates it implements — entry, `evidenceRuns`, and `assertionRefs` — in the same PR as the tests and hardening they reference. The manifest file is shared, but each entry has exactly one owning PR, and manifest conflicts are resolved by entry ownership, never by hand-merging divergent copies of an entry. -4. Commandless registered-gap entries (`protection: "none"`, no test files) may ride in #7001 because they reference nothing that can drift. -5. The integration worktree is the single authoritative copy of every overlapping file. Child branches that have diverged (for example, `resume-sleeping-agent-session.ts` on #7008's branch is ~156 diff lines behind this worktree) must be reset from the worktree slice, not reconciled by hand. - -Recommended split: - -1. Reliability gate manifest and plan docs. - This PR owns `config/reliability-gates.jsonc`, the manifest checker hardening, checker tests, and this plan document. It should merge first because later PRs reference gate ids, evidence metadata, and promotion rules from the manifest. It should not include terminal product behavior changes. - -2. Provider ownership and session replay safety. - This PR owns the agent/provider identity invariant: activation, restore, sleep, hibernate, dedupe, clearing, and reconnect paths must not replay a provider session that is already owned, queued, pending, retained, or live in the workspace. It should include the provider-session ownership tests, repeat-activation lower-layer proofs, automatic resume claim coverage, same-session/wrong-session hook coverage, and any narrowly required product hardening. - -3. Startup hydration, provider fanout, and targeted liveness. - This PR owns startup-adjacent PTY registry hydration, targeted `pty.hasPty(id)` liveness, no-hot `pty:listSessions()` paths, Resource Manager polling scoping, SSH/provider unknown-liveness semantics, degraded-daemon fail-closed behavior, and related tests. It should depend on the manifest PR and should explicitly say that live SSH, WSL, remote-runtime, and production startup reconcile remain follow-up gates unless implemented in the same PR. - -4. Terminal lifecycle observability. - This PR owns compact anomaly breadcrumbs and bounded lifecycle traces for terminal lifecycle diagnosis. It should make failures easier to debug without claiming to prevent them. It should prove that diagnostics are compact, deduped or bounded, privacy-conscious, and included in the right local crash/diagnostic surfaces. Diagnostics-bundle export and live forbidden-transition artifacts should remain visible follow-ups until proven. - -5. PTY output, IPC, and performance hardening. - This PR owns daemon stream batching, main-to-renderer pending output caps, ACK/backpressure behavior, replay queue bounds, runtime/mobile stream byte budgets, and related perf-sensitive contracts. It needs the hardest review because small behavior changes here can cause input latency, hidden-output, memory, or ordering regressions. It should include explicit runtime budgets and should not promote any perf gate to blocking without soak evidence. - -6. Live local PTY and E2E reliability slice. - This PR owns the first live Electron/local PTY liveness gate. It should stay small: spawn a real terminal, prove one active PTY, type through focused xterm, verify process-visible output, hide/restore the workspace without duplicating or losing the PTY, resize and verify applied size, then exit and verify cleanup. It should remain `experimental` until CI/runtime history proves it is not flaky. Tab switch, app restart, SSH, WSL, Windows ConPTY, and scrollback restore should be explicit follow-ups unless included with equally strong oracles. - -Mapping to the open stack and file assignment: - -The six split items and the five open child PRs overlap; this mapping is authoritative. Every working-tree file must land in exactly one PR below. Four files contain changes from multiple reliability classes and must be split by hunk, with the earlier PR in the merge order owning its hunks first: `config/reliability-gates.jsonc` (per-entry ownership), `src/main/ipc/pty.ts` and its test, `src/renderer/src/components/terminal-pane/pty-connection.ts` and its test, and `src/renderer/src/store/slices/terminals.ts`. - -1. #7001 (split item 1, manifest and plan docs): `config/scripts/check-reliability-gates.mjs`, `config/scripts/check-reliability-gates.test.mjs`, `docs/reference/reliability-gates-implementation-plan.md`, and the manifest restricted to gates whose test files exist on `origin/main` plus commandless gap entries. -2. #7008 (split item 2, provider ownership and replay safety): `src/renderer/src/lib/resume-sleeping-agent-session.ts` and test, plus its ownership slice of `terminals.ts`. Reset the branch from the worktree slice; keep the PR number for review continuity. -3. #7005 (snapshot freshness and targeted renderer liveness): `terminal-dead-session-reconcile.ts` and test, `use-terminal-pane-lifecycle.ts` and test, `use-terminal-pane-global-effects.test.ts`, and the `pty.hasPty` preload/API plumbing (`src/preload/api-types.ts`, `src/preload/index.ts`, `src/renderer/src/web/web-preload-api.ts`, the `hasPty` slice of `src/main/ipc/pty.ts`). -4. New PR (split item 3, startup hydration, provider contracts, no-hot listing): `hydrate-local-pty-registry.ts` and test, `daemon-pty-adapter.ts` and test, `daemon-pty-router.ts` and test, `degraded-daemon-pty-provider.ts` and test, `pty-session-id.test.ts`, `src/shared/pty-session-id-format.ts`, `src/main/providers/ssh-pty-provider.ts`, `ResourceUsageStatusSegment.tsx`, `resource-manager-session-polling.test.ts` (currently untracked), `store-session-cascades.test.ts`, and the SSH/liveness slices of `pty.ts`, `pty-connection.ts`, and `terminals.ts`. -5. New PR (split item 4, lifecycle observability): `terminal-lifecycle-diagnostics.ts` and `terminal-lifecycle-diagnostics.test.ts` (currently untracked). -6. New PR (split item 5, PTY output/IPC/perf hardening): `daemon-stream-data-batcher.ts` and test, `terminal-output-batching.test.ts`, `terminal-subscribe-buffer.test.ts`, and the pending-output-cap and replay/ACK slices of `pty.ts` and `pty-connection.ts`. -7. #7004 (xterm addon containment, outside the six split items): keep as its own PR; reset `pane-lifecycle.ts` and test from the worktree versions, and adopt fresh main's renamed WebGL test files during rebase. -8. #7006 (visible geometry, outside the six split items): keep as its own PR; any geometry hunks in `pty-connection.ts` belong to it. -9. #7007 (persisted upgrade fixtures): the gate is commandless with `protection: "none"`, so its manifest entry rides in #7001 under rule 4. Keep #7007 open only if it will deliver the actual fixture corpus and command; otherwise close it as absorbed. -10. New PR (split item 6, live local PTY E2E slice): `tests/e2e/terminal-live-pty-liveness.spec.ts` (currently untracked). - -Merge order: #7001, then #7008, then #7005, then startup hydration/provider contracts, then lifecycle observability, then PTY output/IPC/perf hardening, then #7004, then #7006, then the live E2E slice. #7007 can merge any time after #7001 or be closed. The stack stays flat on #7001; each child rebases onto the merged state immediately before its own merge, and manifest conflicts are resolved by entry ownership. - -If one later PR reveals a missing invariant in an earlier PR, update the earlier manifest entry or PR description before promotion rather than silently broadening the later PR's claim. The split is part of the reliability plan because reviewability, dependency clarity, and targeted rollback are themselves reliability controls. - -Each split PR should follow Brennan's PR process before it is considered ready: write a scoped design doc, run delegated design review, implement from the reviewed doc, verify completeness against the original request and reliability gate, audit headline behavior, run `perf`, run the code-review/fix loop, run merge-confidence validation, open an unmerged PR, verify pushed state, and stabilize CodeRabbit/check findings. The PR text should describe that process as "Brennan's PR process"; do not mention the internal skill name in PR-facing text. - -## PR Proof Contract - -Every split PR must include a short proof section in its PR description before it can be treated as ready. This section should use direct evidence, not confidence language. Avoid absolute claims such as "this guarantees terminal reliability" or "this cannot regress performance." The valid claim is narrower: "this PR is safe to merge because the affected invariant, performance budget, and residual gaps were checked with the evidence below." - -Required PR description sections: - -- **Reliability invariant:** the class-level rule this PR protects and the recent issue, PR, or accepted gap that motivated it. -- **Material impact:** why this will meaningfully reduce escaped regressions, including which known bug class becomes harder to reintroduce. -- **Product change type:** docs/tooling, test-only gate, product hardening, diagnostics, perf hardening, or mixed. -- **Performance risk inventory:** whether the PR touches typing, focus, tab/workspace switch, visibility resume, resize, render, startup, provider listing, hidden output, PTY output, store subscribers, git/worktree scans, SSH, WSL, Windows, or mobile/relay paths. -- **No-regression evidence:** the exact count test, deterministic contract test, metric artifact, typecheck, or live run that proves the PR did not add unbounded scans, polling, hidden-pane wake loops, startup awaits, subprocess churn, renderer jank, or output buffering growth. -- **Correctness evidence:** the exact oracle and command proving the invariant. Tests that only prove implementation shape are not enough. -- **Provider/platform coverage:** local, daemon, SSH, WSL, remote-runtime, mobile/relay, macOS, Linux, and Windows must be marked covered, unaffected, or accepted-gap. -- **Residual gaps:** what this PR still does not prove. These gaps must remain in the manifest until a later PR removes them. -- **Promotion status:** whether the gate is `none`, `partial`, or `active`, and why it is not promoted beyond its current maturity. -- **Rollback or demotion rule:** what signal should cause the PR's gate to be demoted, disabled, or followed up. - -Performance rule: - -- A reliability PR is incomplete if it adds polling, global session listing, provider fanout, hidden-pane renderer wakeups, startup-critical awaits, repeated resize IPC, subprocess churn, unbounded output queues, broad store subscriptions, or uncapped diagnostics without a deterministic count test or metric budget. -- `pty:listSessions()` remains a global management/diagnostic operation, not a hot-path liveness primitive. Typing, focus, tab/workspace switch, visibility resume, resize, render, and per-pane liveness paths should use targeted APIs or cached ownership unless a PR provides measured proof that the broader call is safe. -- Any PR touching git/worktree scans, SSH git providers, WSL paths, cleanup, or startup repository enumeration must include the `git-crash-perf` style proof: bounded subprocesses, timeout/abort behavior, provider parity, and no startup-critical scan. -- Any PR touching terminal/session/provider/startup code must use the terminal-session reliability contract plus a performance budget. A green reliability test without a perf proof is not enough. -- Any PR that changes PTY output, IPC batching, ACK/backpressure, replay, or hidden-output behavior (split item 5 especially) must include a before/after run of an existing terminal perf artifact — `test:e2e:terminal-perf:scale:report` or the typing-latency specs — on the same machine, with both results in the PR description. Deterministic byte/count contracts prove bounds; only the perf artifact proves interactive latency did not regress. -- The gates themselves are a performance surface. Every command-backed gate must declare a runtime budget in the manifest, unit-layer gate commands should stay in single-digit seconds, and the aggregated CI job in Evidence Provenance And CI Wiring must stay inside its own wall-clock budget so reliability coverage does not degrade the development loop it protects. - -Per-PR proof expectations: - -1. Reliability gate manifest and plan docs. - Performance proof: no runtime product path changes. Checker runtime should stay cheap and deterministic, and manifest validation should not depend on network calls or live providers. - Materiality proof: the checker prevents overclaiming by requiring evidence metadata, existing test files, command-backed assertions, covered scope, and clear `none`/`partial`/`active` status. - -2. Provider ownership and session replay safety. - Performance proof: activation and replay checks must be bounded by indexed workspace-local state, not repeated provider listing or O(records x pending-startups) scans. Tests should include repeat activation and bounded-work assertions where scans are introduced. - Materiality proof: this blocks the class where an inactive or hidden but valid provider session is mistaken for unowned and resumed again. - -3. Startup hydration, provider fanout, and targeted liveness. - Performance proof: startup hydration must not block first usable UI, must expose bounded counters, and must not multiply provider/git scans across repos and worktrees without a budget. Hot interaction paths must prove zero broad `pty:listSessions()` calls. - Materiality proof: this prevents stale global snapshots or failed provider observations from closing or replacing the wrong terminal, while reducing session-listing fanout. - -4. Terminal lifecycle observability. - Performance proof: breadcrumbs and traces must be capped, primitive-only, deduped or ring-buffered, and must not serialize terminal output, filesystem trees, scrollback, or large state. Tests must prove caps and sanitization. - Materiality proof: this does not itself prevent regressions, but it materially shortens future diagnosis by preserving the lifecycle facts needed to debug ownership, liveness, restore, resize, and replay failures. - -5. PTY output, IPC, and performance hardening. - Performance proof: this PR must carry the strongest perf evidence in the stack: byte caps, queue caps, ACK/backpressure ordering, active-output priority, hidden-output behavior, and no unbounded renderer/main buffering. It should not become blocking until soak data shows stable runtime and no unexplained flakes. - Materiality proof: this protects the freeze/input-lag/memory-growth class where background or high-volume terminal output harms the active terminal or the app. - -6. Live local PTY and E2E reliability slice. - Performance proof: the Playwright gate must be small, serial where needed, artifact-producing, and timed. It should prove the user path without becoming a broad slow/flaky suite. Runtime and flake history are required before promotion. - Materiality proof: this covers the layer that fake providers cannot prove: real Electron terminal spawn, keyboard input, process-visible output, hide/restore, resize, and cleanup. - -## Factory And Skill Follow-Up - -The current factory skills are close enough that the next step should be targeted edits, not a large new skill stack. The terminal-specific skill already exists, and Brennan's PR process already requires design review, perf review, code review, validation, pushed-state checks, and PR stabilization. The missing factory behavior is that reviewers and validators should explicitly fail PRs that omit the PR Proof Contract above. - -Recommended skill/factory updates: - -1. Update `terminal-session-reliability`. - Add the PR Proof Contract as a required output for terminal/session/provider/startup PRs. The skill should require reliability invariant, material impact, performance risk inventory, no-regression evidence, provider/platform coverage, residual gaps, promotion status, and rollback/demotion rule. - -2. Update `review-code`. - Make missing proof-contract sections a review finding for P0-capable terminal/session/provider/startup/release surfaces. Reviewers should not accept "tests pass" as enough when the PR lacks a named invariant, perf budget, provider/platform matrix, or accepted-gap statement. - -3. Update `brennan-test-changes`. - Require validation reports to say whether the relevant reliability gate command ran, whether the evidence proves the user-visible invariant, whether the test layer is sufficient, and whether any skipped SSH, WSL, Windows, remote-runtime, mobile, or live-Electron path is an accepted gap. - -4. Update `playwright-reliability-tests`. - Add the live-terminal reliability bar directly: Playwright terminal tests must prove real input/output or visible degraded state, use deterministic event oracles instead of blind sleeps, record runtime/artifacts, and stay `experimental` until flake history supports promotion. - -5. Update `perf`. - Add the PR Proof Contract's no-regression evidence language so perf reviewers require deterministic count tests or metric artifacts for polling, global session listing, provider fanout, hidden-pane wakeups, startup awaits, subprocess churn, output queue growth, and broad store subscriptions. - -6. After the repo-side gate commands stabilize, update factory prompts to invoke exact gate ids and commands. - Do this after the PRs land or after their commands are stable enough to reference. Before then, the factory should require the gate class and evidence, but should not hard-code commands that may still split or rename during PR extraction. - -Do not add a separate broad "terminal reliability reviewer" skill unless the existing skills fail to enforce this contract after these edits. Too many overlapping skills can make agents diffuse responsibility; the better shape is one terminal reliability contract plus required calls from design, review, perf, Playwright, and validation stages. - -## Coverage Reality Check - -This first stack is useful, but it must not be described as "terminal reliability is covered." The executable protection in this branch covers a deliberately small first slice of recent escaped classes: - -- stale local/daemon liveness snapshots closing newer PTY bindings; -- provider-session replay ownership for several active, inactive, queued, pending, live, retained, same-session hook, and wrong-session hook records, with real hook timing through Electron still explicitly partial; -- targeted `hasPty` liveness after visibility resume instead of hot-path global `listSessions`; -- degraded daemon fallback fail-closed behavior for restored worktree-scoped and legacy/non-scoped ids; -- SSH provider listing/`hasPty` failures treated as unknown rather than destructive ownership evidence, plus mocked provider/renderer coverage for deferred SSH attach, passphrase cancellation, transient deferred reattach failure, and expired deferred relay fallback; -- remote replay coalescing, FIFO ordering, and bounded burst-tail coalescing at the renderer unit layer; -- visible geometry contract behavior for delayed split layout settle, 0x0 split-right recovery, applied-vs-requested `pty:getSize`, and resume-time size reassertion; -- boot-time local PTY registry hydration counters for repo/worktree enumeration, skipped SSH repos, adapter/session listing, registration/skips, duration, and failure phase; -- terminal lifecycle anomaly breadcrumbs recorded into renderer crash diagnostics with dedupe and compact identity fields; -- live local Electron PTY spawn, active PTY listed exactly once, focused xterm keyboard input, process-visible output, repeated workspace hide/restore of the same PTY, actual resize propagation, and exit cleanup. - -Those gates should catch re-regressions in those classes, and the tests are intentionally deterministic. They mostly use renderer-unit, renderer-state, provider-contract, or fixture layers because those layers provide precise oracles with low flake risk. That is a strength for the classes they cover. - -The manifest also contains commandless registered gaps for persisted-session upgrade fixtures, Windows ConPTY, startup color-query handling, and other future gates. Those entries are useful planning structure, not executable protection. Visible geometry convergence, xterm addon containment, IME/native text forwarding, runtime/mobile streams, and startup hydration counters now have first executable contract slices, but they remain `experimental` until they have repeatable CI/runtime history and red-green evidence. Output backpressure now has daemon stream and main pending-output contract slices, but the broader live perf budget remains experimental until it has repeatable metric artifacts, runtime history, and red-green evidence. The live local PTY gate now has a first executable Playwright slice that includes repeated workspace hide/restore of the same PTY, but it remains `experimental` until it has repeatable CI/runtime history and red-green evidence. - -The critique that this stack is still shallow is fair. The current gates do not yet prove: - -- tab-switch restore, scrollback after restore, app restart persistence, and non-local live providers; -- Windows ConPTY behavior, including shell resolution, cursor restore, rewrite repaint, CJK/IME edge cases, kitty/input protocol behavior, and activation hangs; -- terminal input latency, throughput, hidden-output pressure, renderer CPU, and store/subscriber hot paths under load; -- IME and synthetic input forwarding across macOS, Windows, Linux/fcitx/Sogou, Vietnamese, CJK, Arabic, paste, and modifier paths; -- focus recovery after browser/terminal handoffs, tab switches, app restart, and non-local provider restore; -- daemon degraded spawns, stale bootstrap, remote-runtime startup, SSH/WSL provider boundaries, and mirror polling; -- scrollback clearing/restoration correctness across hidden output, snapshot replay, and restore. - -Those missing classes already appear in the full pain-points report, but this implementation plan should keep them visible so the first stack does not become false confidence. - -## Fresh-Eyes Corrections - -The follow-up reviews changed the plan in several concrete ways: - -- Windows/IME/CJK are not covered by the current executable stack. Orca has useful unit coverage and product logic for local Windows ConPTY compatibility, including withholding Kitty keyboard protocol for local native Windows panes, but no live Windows ConPTY gate proves post-agent ordinary keys, shell parity, CJK repaint, cursor restore, resize readback, or real IME composition. -- SSH restore is better covered than the first manifest draft said, but only at store/provider/main/renderer contract layers. The current gate asserts deferred wake metadata, deferred attach/passphrase/expired-relay contracts, and unknown-liveness behavior with fake transports; it does not prove a live SSH relay, WSL, remote-runtime mirror polling, false-live visual state, or real network reconnect timing. -- Perf/backpressure is currently deterministic byte-accounting proof, not live responsiveness proof. The branch asserts daemon `write(false)`/`drain` behavior, cross-session drain priority for flush-immediate output, pure-backpressure cleanup, and main pending-output caps; it does not yet prove renderer parse pressure, event-loop delay, active key latency, or live hidden-output flood behavior. -- Daemon startup reconcile remains a mechanism, not a production-proven startup behavior. Either wire it with dry-run diagnostics and prior-worktree aliases, or keep it as an accepted gap with a named owner. -- Gate evidence is still too easy to hand-enter. The checker now prevents obvious metadata overclaims, but it does not prove the registered command was run in CI, preserve artifacts, or verify that each declared assertion executed. Add machine-checkable artifact/CI-run provenance before promoting gates to blocking. -- Manifest evidence in this worktree is not yet durable. Three referenced test files are untracked (`resource-manager-session-polling.test.ts`, `terminal-lifecycle-diagnostics.test.ts`, `tests/e2e/terminal-live-pty-liveness.spec.ts`) and roughly 39 more files carry uncommitted modifications. Until each slice is committed and pushed to its owning split branch, the manifest references proof that CI cannot run and that no one can reproduce from a ref. -- Fresh `origin/main` renamed `terminal-webgl-paste-recovery.{ts,test.ts}` to `terminal-webgl-atlas-recovery.{ts,test.ts}` and added `pane-webgl-context-recovery.test.ts` and `pane-webgl-renderer.test.ts` in #6949. The `xterm-addon.boundary-containment` gate and its documented command reference the old filename and will fail the checker after rebase; the gate should adopt the renamed and new tests during the rebase. #7133 (merged 2026-07-02) further modified `pane-webgl-renderer.{ts,test.ts}` and added `pane-reveal-repaint.{ts,test.ts}` and `terminal-visibility-resume.{ts,test.ts}`; the containment gate should evaluate those reveal-hardening tests for adoption in the same sweep. This is a confirmed instance of the stale-branch risk, and it shows the fresh-main review must include a mechanical testFiles-vs-main sweep, not only behavioral spot checks. -- This document itself drifted from the manifest it describes (a prior revision said 23 gates, 13 partial/10 none, while the manifest held 24 gates, 15 partial/9 none). Treat the manifest as authoritative and this document's counts as dated snapshots. -- The Resource Manager status segment no longer polls broad `pty:listSessions()` for its closed-popover badge in this branch. Broad session inventory is now scoped to the open Resource Manager and explicit kill/restart refresh paths; the no-hot-listing plan still needs status/diagnostic budgets and broader Electron counters before promotion. -- Boot-time local PTY registry hydration now exposes a debug snapshot and deterministic tests for retry state, SSH skip-before-worktree-enumeration, local registration counters, adapter/session listing counts, recoverable adapter-list failures, fatal hydration failures, router adapter fanout, duration, and large daemon lists. This is still only a startup-hydration counter slice; it does not prove focus, typing, tab switch, workspace switch, render, high-session provider fanout, or real Electron startup budgets. -- Terminal lifecycle anomaly diagnostics now record compact crash breadcrumbs in addition to console warnings. This is useful support evidence, not yet the full pane lifecycle trace buffer requested in the pain-points report. - -## Fresh Main Architecture Findings - -These findings came from focused reviews of current terminal lifecycle, renderer/input/perf, and provider/platform/startup code against freshly fetched `origin/main`. They are not all regressions introduced by the reliability-gate branch. They are the places the plan must cover before claiming broad terminal reliability. - -Current coverage already exists in more places than the first gate stack showed: - -- `terminal-dead-session-reconcile.test.ts`, `resume-sleeping-agent-session.test.ts`, and `pty-connection.test.ts` have substantial deterministic lifecycle coverage. A focused run of those files passed 292 tests. -- Existing E2E/perf tests include terminal typing latency, hidden TUI visual restore, long-table scroll restore, daemon slow-init PTY restore, SSH localhost, and terminal tab-switch SIGWINCH restore. -- Existing scripts include terminal perf report commands such as `test:e2e:terminal-perf`, `test:e2e:terminal-perf:scale`, `test:e2e:terminal-perf:scale:report`, and `test:e2e:terminal-perf:check-report`. - -The problem is that these tests are not yet organized into reliability gates with named invariants, owners, maturity, runtime budgets, flake history, red/green evidence, and promotion rules. Some also need review before promotion because a useful regression test can still be too broad, too slow, or too flaky to be a blocking gate. - -Local validation evidence from this review pass: - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts \ - src/renderer/src/lib/resume-sleeping-agent-session.test.ts \ - src/renderer/src/components/terminal-pane/pty-connection.test.ts -``` - -Result on 2026-07-01: 3 test files passed, 292 tests passed, duration 6.04s. This asserts the current deterministic lifecycle/unit layer is healthy in this worktree; it does not prove the broader live/platform/provider/perf gaps. - -The provider-session ownership gate now includes the queued/pending resume bridge from fresh `origin/main`: automatic resumes thread `resumeProviderSession` through `pendingStartupByTabId`, claim `automaticAgentResumeClaimsByTabId`, and treat pending startup, time-bounded runtime claims, and live agent statuses as provider-session ownership. The queued/pending claim scan is indexed once per activation so replay protection does not rescan every pending startup for every sleeping record. It also asserts live hook evidence claims only the matching provider session; a wrong-session hook cannot claim a replayed sleeping record; and stale automatic bridge claims cannot suppress recovery forever. - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/renderer/src/lib/resume-sleeping-agent-session.test.ts -``` - -Result on 2026-07-01: 1 test file passed, 34 tests passed, duration 7.86s. This focused command asserts repeat activation or replayed sleeping records do not launch a second resume for the same provider session while the first resume is still pending, queued pending-startup provider-session ids are read once per activation rather than once per replayed record, a live hook for the same provider session owns the replay, and a wrong-session hook does not. It does not yet prove real hook timing through Electron or a full Electron workspace activation loop. - -Structural manifest validation also passed locally: - -```sh -pnpm run check:reliability-gates -``` - -Result on 2026-07-01 after the first expansion: reliability gate manifest check passed for 22 gates. This validated the manifest schema/metadata shape only; it did not prove that the registered commands covered the full terminal runtime. - -After the broader architecture review and fresh-agent review, the manifest was expanded further, including a dedicated WSL restore contract instead of burying WSL under SSH/remote coverage, and later the three convergence/escape-detection gaps registered after the #7133/#7148 saga. As of 2026-07-03, main's manifest holds 27 gates split 9 `partial` (gates whose evidence exists on main itself, including the #7133/#7148/#7173/#7192 regression tests) and 18 `none`; the pending stack raises more gates to `partial` as its slices land. These counts are dated snapshots, and the manifest itself is authoritative. The new entries are intentionally `experimental`; a few now have first executable slices, while others are still commandless gap markers. They are not active protection until each gate gets an implemented command, red/green proof, runtime evidence, and promotion history. - -The manifest checker now enforces the review lessons that were easy to miss manually: - -- declared `testFiles` must exist; -- every declared executable `testFiles` entry must be referenced by at least one gate command, so a manifest cannot borrow credibility from a file the registered command does not run; -- command-backed gates cannot use title selectors such as `-t`, `--grep`, or `--testNamePattern`, because title drift silently changes coverage; -- every gate must declare `protection: none | partial | active`; -- commandless gates must declare `protection: "none"`, while command-backed gates must declare at least `protection: "partial"`; -- `protection: "active"` is reserved for blocking gates with stable flake history and complete red/green evidence; -- `partial` and `active` gates must include at least one passed structured `evidenceRuns` entry whose command exactly matches the gate command; -- `none` gates must have no evidence runs, so planning gaps cannot look like tested coverage; -- `partial` and `active` gates must include `assertionRefs` that point to declared test files and name the invariant assertions inside those files; -- `none` gates must have no assertion refs, so planning gaps cannot borrow credibility from unrelated tests; -- every gate must split risk scope from covered scope with `coveredPlatforms`, `coveredProviders`, and `coverageNotes`; -- `coveredPlatforms` and `coveredProviders` must be inside the declared risk scope, and passed evidence-run platforms must appear in `coveredPlatforms`; -- `soak` and `blocking` gates must have soaking/stable flake evidence and complete red/green evidence. - -How to read the manifest: - -- `platforms` and `providers` describe the affected risk scope for an invariant. They do not mean the current command exercises every listed platform/provider. -- `coveredPlatforms`, `coveredProviders`, and `coverageNotes` describe the scope actually covered by current evidence. Empty `coveredProviders` on a partial gate usually means renderer/state/RPC contract coverage, not a real provider run. -- `protection: "none"` means a registered gap only; `protection: "partial"` means useful executable coverage that is not blocking protection yet; `protection: "active"` means the gate is blocking and has stable evidence. -- `evidenceRuns` are factual run records: date, runner, platform, exact command, result, duration, and summary. A local passed run supports `partial` coverage; it does not imply CI stability or flake history. -- `assertionRefs` explain which assertion groups in the listed test files carry the gate. This is especially important for shared files like `pty-connection.test.ts`, where the whole file passing is not the same as the whole file being relevant. -- `oracle`, `redGreenEvidence`, and `knownGaps` are the authoritative source for what is actually proved today. -- Commandless `experimental` gates are registered gaps, not active protection. - -New or expanded manifest entries: - -- `terminal-platform.live-pty-liveness` -- `terminal-platform.windows-conpty-liveness` -- `terminal-performance.no-hot-list-sessions` -- `terminal-performance.output-backpressure-budget` -- `terminal-performance.input-throughput` -- `terminal-performance.daemon-stream-backpressure` -- `terminal-performance.store-and-git-hot-paths` -- `terminal-provider.daemon-startup-degraded-contract` -- `terminal-provider.ssh-remote-reattach-contract` -- `xterm-addon.boundary-containment` -- `terminal-output.scrollback-replay-fifo` -- `terminal-output.scrollback-restore` -- `terminal-input.ime-and-synthetic-forwarding` -- `terminal-input.windows-conpty-keyboard-reset` -- `terminal-render.windows-cjk-repaint` -- `terminal-shell.windows-resolution-parity` -- `terminal-capability.startup-color-query` -- `terminal-runtime.mobile-stream-budget` -- `terminal-mirror.parser-parity` -- `terminal-observability.restore-convergence-selfcheck` -- `terminal-render.pixel-refresh-repair` - -The visible geometry gate now has its first deterministic renderer/main contract slice: - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts \ - src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts \ - src/renderer/src/components/terminal-pane/pty-connection.test.ts \ - src/main/ipc/pty.test.ts -``` - -Result on 2026-07-02: 4 test files passed, 481 tests passed, duration 5.98s. This focused command asserts delayed hidden split-layout settles are forwarded instead of stopping on a fixed frame budget, unmeasurable frames do not count as settled, visible 0x0 split-right panes get a nonzero recovery size while hidden background 0x0 panes are not forced to desktop size, `pty:getSize` reports applied size when a provider can expose it, and visibility resume reasserts only real PTY/xterm drift without falling back to `listSessions`. It does not yet prove shell-visible `stty`, SSH/remote geometry, Windows ConPTY geometry/readback, or full Electron geometry under real split/tab/app-restart workflows. - -The live local PTY gate now has its first executable Electron slice: - -```sh -pnpm run ensure:electron-runtime && npx playwright test \ - tests/e2e/terminal-live-pty-liveness.spec.ts \ - --config tests/playwright.config.ts --project electron-headless --workers=1 -``` - -Result on 2026-07-02 after adding two workspace hide/restore cycles, exact active-PTY listing, and actual resize convergence: 1 Playwright test passed, test body 8.6s, full command 58.6s including build/setup. This one local macOS run asserts a real Electron local terminal binds a PTY, lists the active PTY id exactly once, accepts keyboard input through focused xterm, renders process output markers, preserves the same PTY id through repeated worktree switch-away/switch-back cycles, accepts additional keyboard input after each restore, applies an actual `pty:getSize` change after viewport resize, reports the same process-visible size, exits the probe, exits the shell, and removes the PTY id from `pty:listSessions`. It does not yet prove tab switch within the same worktree, scrollback after restore, app restart persistence, daemon, SSH, WSL, remote-runtime, Linux CI stability, or Windows ConPTY behavior. - -The IME/native-text gate now has its first deterministic renderer-unit slice: - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts \ - src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts \ - src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts -``` - -Result on 2026-07-02: 3 test files passed, 63 tests passed, duration 887ms. This registers existing native-text, input-source, and paste/runtime forwarding contracts as a reliability gate slice. It does not prove real OS IME automation, Windows ConPTY post-agent keyboard reset, Arabic/RTL, JIS-yen, or the full CJK/Vietnamese/platform matrix. - -The xterm addon containment gate now has its first deterministic renderer-unit slice and product hardening for core addon load failures: - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts \ - src/renderer/src/lib/pane-manager/terminal-link-provider-guard.test.ts \ - src/renderer/src/components/terminal-search-safe-find.test.ts \ - src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts \ - src/renderer/src/components/terminal-pane/terminal-webgl-paste-recovery.test.ts -``` - -Result on 2026-07-02: 5 test files passed, 39 tests passed, duration 370ms. This focused command asserts a core addon `loadAddon` throw is pane-scoped and later addons still load, link provider throws degrade to no-link results, search decoration positive-integer crashes return `false` instead of escaping, and WebGL attach/refresh/recovery failures stay contained. It does not yet prove typed PTY input/output survives a live addon failure in Electron, nor WebGL dispose/reset throws across active, hidden, and resumed panes. - -The runtime/mobile stream budget gate now has its first deterministic runtime-RPC slice: - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/main/runtime/rpc/terminal-subscribe-buffer.test.ts \ - src/main/runtime/rpc/terminal-output-batching.test.ts \ - src/main/runtime/rpc/terminal-multiplex.test.ts -``` - -Result on 2026-07-02: 3 test files passed, 29 tests passed, duration 2.78s. The covered contracts assert mobile initial snapshots downgrade until they fit the 512KB budget, requested binary snapshots downgrade until they fit the 2MB budget, binary live output queued while the initial snapshot is serializing stays within 256KB while preserving the newest tail, large binary output is split into 48KB-or-smaller frames, output bursts are coalesced before emission, aborted subscribes do not register stale listeners, and stale mobile resize re-stream completions are dropped. It does not yet decide JSON subscribe fallback parity/deprecation. - -The replay/scrollback gate now has its first executable slice: - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/renderer/src/components/terminal-pane/pty-connection.test.ts -``` - -Result on 2026-07-02: 1 test file passed as part of the focused pty-connection validation, with 255 tests in that file. The relevant replay assertions cover four replay invariants: remote replay payloads that overlap before parsing starts intentionally coalesce to the latest payload, a replay whose xterm parsing has already started is not overwritten by a newer replay, multiple replay notifications accepted after parsing starts drain FIFO, and burst tails are bounded while keeping the newest snapshot. It does not yet prove fresh-main `clearBeforeReplay` metadata, metadata-only replay, or hidden-output/live-output interleaving. - -The no-hot-`listSessions` gate now has its first executable slice and product hardening. The renderer/preload/main path exposes `pty.hasPty(id)` and uses targeted liveness on visibility resume and first input after resume, instead of calling global `pty:listSessions()` from that hot path. The resize-on-resume slice also asserts it uses targeted `getSize` plus `resize` rather than broad session listing. Light tab switches and visible active-state resume now assert they do not call `pty:listSessions`, `pty.hasPty`, or `pty.getSize`; a representative active-PTY case proves the scheduler hint `setActiveRendererPty` is the only allowed PTY API call there. Resource Manager broad session inventory is now scoped to the open popover instead of polling for its closed badge. - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts \ - src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts \ - src/renderer/src/components/terminal-pane/pty-connection.test.ts \ - src/renderer/src/components/status-bar/resource-manager-session-polling.test.ts \ - src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts -``` - -Result on 2026-07-02: 5 test files passed, 321 tests passed, duration 6.34s. The focused no-hot command asserts visibility resume prefers targeted `hasPty` even when `listSessions` is available, first input after visibility resume calls targeted `hasPty` once, resize re-assertion after visibility resume uses `getSize`/`resize` without `listSessions`, light tab switches and visible active-state resume avoid `listSessions`/`hasPty`/`getSize` fanout while still allowing the active PTY scheduler hint, SSH/remote broad listing is skipped, Resource Manager broad session inventory polling is scoped to the open popover, panes close only on authoritative `false`, and unknown liveness keeps panes alive. It does not yet count raw focus, workspace switch, render, high-session typing, or real provider fanout in Electron. - -The store/git hot-path gate now has its first boot-hydration counter slice. This is not broad interactive perf coverage; it is the narrow startup-adjacent part that previously had no measurable budget. - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/main/memory/hydrate-local-pty-registry.test.ts -``` - -Result on 2026-07-02: 1 test file passed, 8 tests passed, duration 0.342s. The focused command asserts provider-unavailable hydration does not scan repos/worktrees/providers and remains retryable, SSH repos are skipped before worktree enumeration, local daemon sessions are registered with debug counters for repo/worktree/adapter/session/register/skip/duration fields, recoverable adapter-list failures are counted without failing the whole hydration pass, fatal hydration failures record failed phase and error message, router-backed hydration records multi-adapter fanout counts, pre-existing pid rows are not clobbered, and large daemon session lists hydrate without spreading into argument-limit failures. It does not yet count store recomputes, git status requests, provider listings, focus, typing, tab switch, workspace switch, render, or high-session Electron fanout. - -The degraded-daemon provider contract now has its first executable slice and product hardening. Existing-session operations fail closed when ownership is unknown, and restored daemon session ids, including legacy/non-worktree-scoped ids, cannot silently spawn a local fallback PTY under the old id. Fresh degraded-mode PTY creation still intentionally routes through fallback when the caller marks it as new, and process inspection returns benign defaults for unknown ownership instead of leaking fail-closed routing errors into window-close flows. - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/main/daemon/degraded-daemon-pty-provider.test.ts \ - src/main/daemon/daemon-pty-adapter.test.ts \ - src/main/daemon/daemon-pty-router.test.ts -``` - -Result on 2026-07-02: 3 test files passed, 107 tests passed, duration 2.54s. Focused provider validation asserts discovered daemon sessions route to the daemon, fresh degraded-mode PTYs route to fallback only when marked new, router spawn discovers uncached existing sessions before choosing an adapter, router spawn fails closed instead of falling through to current when legacy ownership cannot be listed or a known session has exited, targeted `hasPty` discovery caches legacy ownership before later operations, real daemon adapter `listProcesses()` discovery seeds targeted `hasPty` liveness, folder and floating terminal workspace ids survive startup reconcile when valid, restored worktree-scoped and legacy/non-scoped ids do not fall back through spawn after ownership is lost or unknown, unknown or exited existing ids fail closed for routing operations, startup reconcile can dry-run orphan detection without killing live daemon sessions, and process inspection returns benign defaults for unknown ownership. It does not yet prove production startup reconcile wiring, prior-worktree aliases, or real daemon process restart behavior. - -The SSH/remote provider contract now has its first executable provider/main/renderer slice. Provider observation failures are treated as unknown instead of destructive evidence: a failed SSH `listProcesses()` call does not clear previously learned ownership, and a rejected SSH `hasPty()` probe returns unknown liveness (`null`) rather than dead. The focused renderer/provider tests also assert that saved SSH sessions reattach through relay `pty.attach`, expired relay attach does not silently fresh-spawn inside the provider, deferred passphrase cancellation does not auto-reconnect, saved leaf/tab session ids are used after connection, transient deferred reattach failure preserves the saved session id without clearing pane/tab bindings or fresh-spawning, deferred SSH no-result cleanup clears the pending serializer without consuming the saved restore id, and expired deferred relay state clears stale pane/tab bindings before one fresh replacement spawn. - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/main/providers/ssh-pty-provider.test.ts \ - src/main/ipc/pty.test.ts \ - src/renderer/src/components/terminal-pane/pty-connection.test.ts \ - src/renderer/src/store/slices/store-session-cascades.test.ts -``` - -Result on 2026-07-02: 4 test files passed, 546 tests passed, duration 6.48s. Focused validation asserts store wake-hint metadata for disconnected SSH relay ids, provider attach/expired-attach behavior, main-process ownership and targeted-liveness failure semantics including async provider rejection, mocked-renderer deferred SSH attach/transient-failure/expired-relay fallback, and deferred SSH no-result cleanup that clears the pending serializer without consuming the saved restore id. It does not yet prove WSL, remote-runtime mirror polling, a live SSH relay, pending-vs-attached UI status, or real network reconnect timing. - -API-surface validation after adding `pty.hasPty(id)`: - -```sh -pnpm run typecheck:node -pnpm run typecheck:web -``` - -Result on 2026-07-02: both typechecks passed. - -The output-backpressure budget gate now has a deterministic provider/IPC/renderer slice: - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/main/daemon/daemon-stream-data-batcher.test.ts \ - src/main/ipc/pty.test.ts \ - src/renderer/src/components/terminal-pane/pty-connection.test.ts -``` - -Result on 2026-07-02: 3 test files passed, 469 tests passed, duration 9.35s. The focused command asserts daemon socket `write(false)`/`drain` ordering, cross-session interactive output priority, pure-backpressure cleanup, bounded daemon queued bytes, main pending renderer caps, active pending-output protection, replay/backlog slices, and ACK-gated in-flight bounds. It does not yet prove live hidden-output floods, renderer parse pressure, event-loop delay, active key latency, or full Electron perf artifacts. - -Combined focused terminal/provider validation: - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/main/daemon/degraded-daemon-pty-provider.test.ts \ - src/main/ipc/pty.test.ts \ - src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts \ - src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts \ - src/renderer/src/components/terminal-pane/pty-connection.test.ts \ - src/renderer/src/lib/resume-sleeping-agent-session.test.ts -``` - -Result on 2026-07-01: 8 test files passed, 625 tests passed, duration 6.55s. This was focused terminal/provider validation, not structural manifest validation. - -Reliability manifest checker validation is separate: - -```sh -pnpm run check:reliability-gates -``` - -Result on 2026-07-02: reliability gate manifest check passed for 27 gates. This validates the manifest shape and referenced files; it does not prove the registered commands cover the full terminal runtime. - -The highest-risk architecture boundaries are: - -- Terminal/session ownership is spread across tab PTY ids, leaf PTY ids, last-known relay ids, pending reconnect maps, deferred SSH maps, sleeping records, and provider ownership. The plan needs a derived ownership snapshot oracle for tests/logging so attach, reattach, exit, clear, hibernate, and restore can prove all authorities agree. -- Queued or pending provider sessions must count as owned. This branch now restores the pending startup and automatic resume claim bridge and covers same-session/wrong-session hook evidence, but real hook timing and repeat activation through Electron still need coverage. This is the class-level invariant motivated by #6800. -- Stale branch risk is real. The reliability branch previously lacked some protections present on fresh `origin/main`, including queued automatic agent resume claims and exact `hasPty` missing-session reconciliation. This branch has restored those two deterministic slices; the full stack still needs final rebase/retarget verification before landing. -- Main PTY ownership historically had convenient fallback behavior for unknown ownership. This branch now hardens daemon-router and degraded-provider paths so discovered legacy ownership is cached and unknown existing-session operations fail closed, but SSH/remote/diagnostic listing paths still need live coverage. -- Startup daemon reconciliation is present as a mechanism but not wired as production startup behavior. Without it, the daemon can still have a session while the adapter no longer knows it owns that session. -- Degraded daemon routing on fresh `origin/main` can fall back to local for unknown existing-looking sessions. This branch now hardens operation plus restored worktree-scoped and legacy/non-scoped spawn paths to fail closed and registers a focused provider contract, but startup reconcile, prior-worktree aliases, and real daemon restart behavior still need coverage. -- `pty:listSessions` both observes and mutates provider ownership. This branch now asserts failed SSH observation does not clear previously learned ownership and mocked deferred SSH attach/transient-failure/expired-relay fallback behaves correctly, but WSL, remote-runtime mirror polling, live SSH reconnect timing, and false-live tab behavior remain unproved. -- Resource Manager broad session inventory is now limited to popover-open and explicit management refreshes. It still needs a status/diagnostic budget proving open-popover polling is bounded with many providers and does not become a hot-path substitute. -- Boot hydration (`hydrateLocalPtyRegistryAtBoot`) is fire-and-forget, and this branch now records startup phase counters for repo/worktree enumeration, skipped remote repos, adapter/session listing, registrations/skips, duration, and failures. It can still create startup-adjacent git subprocess and daemon-listing churn across many local repos, so do not treat startup reliability as covered until those counters are budgeted in CI/runtime evidence. -- Deferred SSH restore intentionally marks a saved PTY id before actual attach. This branch now covers the mocked renderer contract for passphrase cancellation, attach after connection, transient attach failure preserving retry state, and expired relay fallback; remaining work is false-live visual state, live SSH relay timing, and duplicate-spawn prevention under real reconnect churn. -- Geometry truth is incomplete across SSH/remote paths. Requested size, renderer size, provider-applied size, shell-visible size, and the runtime mirror's parse dimensions must be separated instead of treated as interchangeable. #7192 proved the mirror can silently stay at birth dimensions while every other authority resizes, corrupting every later restore snapshot, and that mirror reflow must stay ordered with queued output writes. -- Production diagnostics are still incomplete. This branch now records compact crash breadcrumbs for explicit terminal lifecycle anomalies, but real user reports still need a bounded pane lifecycle trace for ordinary transitions and a diagnostics-bundle artifact proving those breadcrumbs/traces are included. - -The lifecycle breadcrumb gate now has its first executable slice: - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/renderer/src/components/terminal-pane/terminal-lifecycle-diagnostics.test.ts -``` - -Result on 2026-07-02: 1 test file passed, 2 tests passed, duration 0.593s. The focused command asserts `warnTerminalLifecycleAnomaly` preserves its console warning, records a compact `terminal_lifecycle_anomaly` renderer crash breadcrumb with terminal identity/provider/PTY/reason fields, and dedupes repeated anomalies by lifecycle identity. It does not yet prove a full pane lifecycle trace buffer, diagnostics-bundle export, or forbidden-transition assertions across live Electron/provider flows. - -Specific current-source evidence checked in this pass: - -- Fresh `origin/main` has queued provider-session protections in `src/renderer/src/lib/resume-sleeping-agent-session.ts`: `resumeProviderSession`, `claimAutomaticAgentResume`, and `automaticAgentResumeClaimsByTabId`. -- Fresh `origin/main` has exact liveness reconciliation in `src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.ts` and the `pty:hasPty` IPC handler in `src/main/ipc/pty.ts`. -- Fresh `origin/main` still uses a single `pendingReplayData` slot in `src/renderer/src/components/terminal-pane/pty-connection.ts` (re-verified after #7133 merged; #7133 changed the same file's `applyMainBufferSnapshot` alt-screen restore path, not replay draining). This branch changes replay draining to coalesce snapshots before xterm parsing starts, queue accepted snapshots FIFO once parsing is in progress, and coalesce burst tails to the newest snapshot once the in-flight queue is full. The broader scrollback gate still needs metadata-only replay, `clearBeforeReplay`, and hidden/live-output interleaving coverage. -- Fresh `origin/main` still has `DaemonPtyAdapter.reconcileOnStartup` with the explicit note that it has no production caller yet. This branch adds a dry-run mode so future startup wiring can audit would-kill sessions before destructive cleanup, but production startup-persistence remains a real gap until wired with prior-worktree aliases or tracked as an accepted gap with a gate. -- Fresh `origin/main` still lets `DegradedDaemonPtyProvider.providerFor()` fall back to local when an existing-looking session id is unmapped and not rediscovered by `hasPty`. This branch changes existing-session operation paths and restored worktree-scoped plus legacy/non-scoped spawn paths to fail closed, adds contract tests, and makes real daemon adapter `listProcesses()` discovery seed targeted `hasPty` liveness. Startup reconcile wiring and live daemon-restart validation are still required before the full provider lifecycle is covered. -- Fresh `origin/main` let the daemon router spawn an unknown existing `sessionId` on the current daemon when routing cache/discovery missed its real owner. This branch now probes/discovers all adapters first, requires `isNewSession` for intentional replacement after an authoritative exit event, and fails closed if legacy ownership cannot be listed or the known id has exited, so a stale existing id cannot silently mint over another daemon's history on current. -- Fresh `origin/main` let canonical daemon-worktree restores treat a non-minted stale/local PTY id as restorable metadata. This branch now rejects non-minted restored ids when the workspace owner is canonical (`repo::path`, `folder:`, or `global-floating-terminal`), clears the stale pane/tab binding, and fresh-spawns instead of calling daemon reattach or attaching that stale id. Shared parsing also recognizes folder and floating minted ids so startup reconcile does not falsely reap valid non-repo terminal sessions. -- Fresh `origin/main` still lets `pty:listSessions` rebuild provider ownership while SSH listing failures become empty lists. This branch adds store/main/provider/renderer contracts proving disconnected SSH relay ids are retained as deferred reconnect metadata and sidebar wake hints rather than attached PTY proof, failed SSH observation does not clear previously learned ownership, `hasPty` failure is unknown, saved SSH sessions attach through relay `pty.attach`, deferred passphrase cancellation does not auto-reconnect, transient deferred reattach failure preserves the saved session id, deferred SSH no-result cleanup clears pending serializer state without consuming retry metadata, and expired deferred relay state clears stale pane/tab bindings before one fresh replacement spawn. Live SSH, WSL, remote-runtime mirror polling, false-live visual state, and real reconnect timing remain unproved. -- Fresh `origin/main` has strong Windows/unit coverage around ConPTY packaging, shell resolution, size validation, keyboard reset, complex-script classification, and Windows PTY compatibility, but the real Windows Electron/ConPTY path is still not covered as a reliable PR gate. -- Fresh `origin/main` still exposes global `pty:listSessions`; this branch now also uses targeted `hasPty` for visibility/input liveness, but gates should keep typing, focus, tab switch, visibility resume, resize, and render paths from reintroducing broad daemon/provider listing. -- Fresh `origin/main` has main-to-renderer ACK/backpressure, renderer output-scheduler caps, terminal perf scripts, and a daily/manual terminal perf workflow. This branch adds daemon stream batcher assertions that socket `write(false)` pauses further stream events until `drain`, flush-immediate output from another session drains before unrelated background backlog, global cleanup clears clients that only have backpressured writes, and queued daemon stream bytes stay bounded by preserving priority output plus the newest tail under sustained pressure; it also caps main pending renderer output per PTY and in total, trims background pending output before active pending output under total pressure, and asserts `seq`/`rawLength` metadata stays correct for the surviving tail. Live hidden-output pressure, active input latency, and full Electron perf artifacts remain unproved. -- Fresh `origin/main` has binary runtime/mobile terminal stream caps, but the legacy JSON subscription path has weaker buffering/backpressure guarantees. Either gate both paths or explicitly deprecate/accept the JSON gap. - -Existing E2E/perf tests that should be reviewed for promotion: - -- `tests/e2e/daemon-slow-init-pty-gate.spec.ts`: strong daemon live-session restore signal; already has a tight user-visible invariant. -- `tests/e2e/daemon-live-session-preservation.spec.ts` and `tests/e2e/daemon-slow-health-check-preservation.spec.ts`: likely useful daemon liveness/preservation gates after oracle and flake review. -- `tests/e2e/terminal-typing-latency.spec.ts`, `tests/e2e/terminal-codex-local-typing-latency.spec.ts`, and `tests/e2e/terminal-history-size-typing-latency.spec.ts`: useful perf signals, but should be tied to stable budgets and artifacts before blocking promotion. -- `tests/e2e/terminal-output-scheduler.spec.ts`, `tests/e2e/artificial-opencode-terminal-load.spec.ts`, and hidden-pressure scenarios: useful for output/backpressure, but should become metric-gates rather than visual-only broad E2Es. -- `tests/e2e/terminal-hidden-tui-visual-restore.spec.ts`, `tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts`, and `tests/e2e/terminal-tab-switch-visual-restore.spec.ts`: useful rendering recovery coverage; must keep deterministic text/buffer oracles and limit screenshots to diagnostics. -- `tests/e2e/terminal-long-table-scroll-restore.spec.ts` and `tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts`: useful scrollback/rendering regression evidence, but they contain timed waits and should be reviewed before blocking promotion. -- `tests/e2e/ssh-localhost.spec.ts`, `tests/e2e/ssh-docker-relay-perf.spec.ts`, and SSH Codex replay/reconnect specs: useful SSH coverage, but should be separated into deterministic provider contracts plus a small number of environment-dependent live SSH soaks. -- `tests/e2e/terminal-windows-shell-paste-ownership.spec.ts` and Windows env/icon tests: useful Windows path coverage, but not a substitute for a Windows ConPTY live liveness gate. - -Promotion split for existing E2Es: - -- Promote after soak: a smaller derivative of `terminal-typing-latency.spec.ts` as the live local PTY input/output gate. -- Promote after soak: daemon preservation specs, especially slow-init and stale launch identity, as daemon warm-reattach gates. -- Promote targeted: paste ownership tests where the oracle is visible output plus exactly-one PTY write. -- Keep in soak/nightly: artificial OpenCode load, terminal scale perf report, hidden real-PTY pressure, and SSH Docker perf. -- Keep as release evidence: raw emoji/golden rendering, long-table rendering, WebGL recovery, and Codex/OpenCode artifact repros. -- Rewrite before blocking: column desync specs need resize-applied acknowledgement or an in-PTY `SIGWINCH` marker instead of post-resize sleeps. -- Rewrite before blocking: SSH Codex artifact repros and headful dead-terminal repros need deterministic fixtures/events instead of long waits and screenshots as primary proof. - -The first live PTY gate is deliberately small: - -- File: `tests/e2e/terminal-live-pty-liveness.spec.ts`. -- Command: `pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-live-pty-liveness.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1`. -- Invariant: a newly opened local terminal has one active PTY id listed exactly once, accepts real keyboard input through focused xterm, delivers process output visibly, keeps the same PTY id through repeated workspace hide/restore cycles, accepts input after restore, applies an actual size change, and exits without leaving a stale binding. -- Oracle: start a deterministic Node raw-mode probe, wait for `LIVE_PTY_READY_`, require the active PTY id to appear exactly once in `pty:listSessions`, type through `.xterm-helper-textarea`, require ordered per-key markers in serialized terminal content, switch to another worktree and back twice, require the active pane to keep the original PTY id, type again after each restore, resize and require `pty:getSize` to change plus a matching process-visible size marker, then exit the probe and shell and require the old PTY id to disappear from `pty:listSessions`. -- Wait rule: no blind sleeps. Wait only on PTY binding, active worktree changes, ready marker, key markers, resize marker, and list-session absence. -- Budget: target p95 under 75s on Linux including e2e build/setup, hard test timeout 90s. Start blocking on Linux only after soak; run macOS/Windows nightly until each platform has its own history. -- Failure artifacts: PTY id, provider classification, pid if available, size snapshots, key latencies, final `listSessions`, trace, and screenshot. -- Red/green proof: intentionally break xterm focus, PTY write forwarding, resize forwarding, and exit cleanup in separate local runs and confirm the gate fails for each class. -- Local run on 2026-07-02: 1 Playwright test passed in 58.6s total, with an 8.6s test body, on this macOS worktree. This is one green local run for the local live PTY path, not enough flake/runtime history for blocking promotion. - -## Gate Coverage Matrix - -This is the intended shape after rebasing the stack. The first two columns are what we have now; the last two are what must be added or promoted before the plan earns broader reliability claims. - -| Reliability class | Existing evidence | Gap | Next gate or action | -| --- | --- | --- | --- | -| Stale liveness snapshots closing newer PTYs | `terminal-session.snapshot-freshness`; deterministic unit coverage | No full Electron tab-survival/input echo proof | Add `terminal-platform.live-pty-liveness` and keep snapshot gate as lower-layer guard | -| Provider-session replay ownership | `agent-session.provider-ownership`; renderer state tests; queued/pending resume claim proof; bounded queued-claim index proof; same-session and wrong-session hook proof | Needs real Electron repeated activation/hook timing and bounded hook/status scan assertions if those grow | Add Electron activation proof | -| Exact PTY liveness | Fresh main has targeted `hasPty` path | Current branch must not lose it; provider failures must be unknown, not dead | Rebase/restore exact liveness reconciliation and register it as a gate | -| Store/session ownership coherence | Many focused tests touch pieces | No single derived oracle across tab, leaf, relay, reconnect, deferred SSH, and sleeping maps | Add ownership snapshot helper for tests/logging | -| Terminal lifecycle diagnostics | `terminal-observability.lifecycle-breadcrumbs` now records compact anomaly breadcrumbs into crash diagnostics | No full pane lifecycle trace buffer or diagnostics-bundle artifact proof | Add bounded trace buffer and bundle artifact test | -| Xterm addon/search/WebGL containment | `xterm-addon.boundary-containment`; deterministic core addon load, link-provider, search, and WebGL containment tests; #7054 hidden TUI product hardening | Needs live Electron typed input/output survival after addon failure, and WebGL dispose/reset throw coverage across active/hidden/resumed panes | Add focused component/live input echo follow-up; promote hidden TUI E2E after flake review | -| Visible terminal geometry | #7006 renderer/provider-style gate; #7192's merged mirror-resize and reflow-ordering red tests on main | SSH/remote and shell-visible truth not fully covered; mirror-geometry slice unregistered until rebase | Add live PTY size readback and provider-applied-size contract; register #7192's mirror-authority tests | -| Persisted session upgrade | #7007 fixture gate | Needs broader old-version corpus and daemon/SSH cases | Build immutable fixture corpus with startup timing | -| Live local PTY lifecycle | `terminal-platform.live-pty-liveness` now has a focused Electron Playwright slice covering local spawn, single active PTY listing, xterm input, PTY output, repeated workspace hide/restore, actual resize propagation, and cleanup | Needs flake/runtime history, red-green evidence, tab switch, scrollback after restore, app restart persistence, and non-local provider coverage | Promote first slice after soak; add tab-switch/scrollback/restart follow-ups | -| Windows ConPTY lifecycle | Recent fixes on main, some unit/platform logic | No required Windows live gate for ConPTY echo/resize/cursor/CJK/IME | Add `terminal-platform.windows-conpty-liveness` | -| Windows keyboard reset | Unit coverage and recent #6999/#6858 fixes | No live proof that Enter/Backspace/Arrow return to normal after agent/TUI exit | Add `terminal-input.windows-conpty-keyboard-reset` | -| Windows CJK/repaint | Complex-script and compatibility tests plus recent CJK repaint fixes | No Windows visual/pixel oracle for wide-glyph in-place redraws | Add `terminal-render.windows-cjk-repaint` | -| Windows shell parity | Shell resolution unit coverage and packaging fixes | No local-vs-daemon parity gate for PowerShell 5/7, cmd, Git Bash, WSL, startup command delivery | Add `terminal-shell.windows-resolution-parity` | -| Input latency/perf | Existing terminal perf scripts and typing-latency spec | Not registered, not tied to budgets/owners/promotion | Add `terminal-performance.input-throughput` | -| Hot interaction listing | Recent #7002 fix, targeted `hasPty` path, deterministic visibility/input/resize no-listing assertions, light tab/active-state resume no-provider-fanout assertions, and closed Resource Manager broad-listing avoidance | No count gate proving `pty:listSessions` stays out of raw focus/workspace-switch/render/high-session Electron scenarios; open Resource Manager polling still needs an explicit budget | Extend `terminal-performance.no-hot-list-sessions` | -| Daemon stream backpressure | Batching/chunking plus new batcher contract proof for `write(false)`/`drain`, cross-session flush-immediate drain priority, pure-backpressure cleanup, and bounded queued tails | No live hidden-output flood proof or active input latency artifact | Extend `terminal-performance.output-backpressure-budget` | -| Renderer/main ACK and backlog | Main ACK gates, renderer scheduler caps, focused unit coverage, and bounded main pending tails exist | No live metric artifact tying hidden restore, active key latency, queued chars, and dropped backlogs to promotion | Extend `terminal-performance.output-backpressure-budget` | -| Runtime/mobile terminal stream | `terminal-runtime.mobile-stream-budget`; runtime-RPC tests assert mobile initial snapshot <=512KB, requested binary snapshot <=2MB, pending live output <=256KB while snapshot loads, output frames <=48KB, output coalescing, abort cleanup, and stale resize re-stream suppression | Legacy JSON path parity/deprecation is undecided | Decide/gate/deprecate JSON fallback | -| Store/git hot paths around terminal changes | Perf/git-crash skills exist; git status limits/coalescing exist; `terminal-performance.store-and-git-hot-paths` now has a boot-hydration counter slice | No Electron count gate for terminal-adjacent store projection, git status, focus, typing, tab/workspace switch, render, or high-session provider fanout | Extend `terminal-performance.store-and-git-hot-paths` with interactive counters | -| IME and synthetic input | Input forwarding code and scattered coverage | No representative matrix or byte/cell oracle | Add `terminal-input.ime-and-synthetic-forwarding` | -| Daemon degraded/startup provider contract | Mechanisms exist; daemon slow-init E2E helps | Startup reconcile is unwired; degraded fallback needs fail-closed/probe proof | Add daemon degraded and startup reconcile contract gates | -| SSH/remote restore and mirror polling | SSH localhost and remote fixes exist; this branch has store/provider/main/renderer proof for deferred SSH wake metadata, SSH attach, listing failure, deferred attach, passphrase cancellation, transient deferred reattach failure, and expired deferred relay fallback | Remote mirror polling, live SSH relay behavior, false-live visual state, pending-vs-attached UI status, and real reconnect timing remain unproved | Add live SSH soak and remote mirror polling gates | -| WSL restore and launch identity | `terminal-provider.wsl-restore-contract` is now registered as a first-class gap | No WSL provider contract or live Windows WSL smoke yet proves cwd/path identity, startup command delivery, targeted liveness, or restore ownership | Add deterministic WSL provider contract, then one focused Windows WSL smoke | -| Scrollback/replay restore | Recent #7012 fix, long-table restore coverage, this branch's remote replay ordering proof, #7133's merged alt-only-clear tests, and #7173's merged ordered-seq interleaving and session-revival tests on main | Normal-buffer clear semantics and metadata-only replay still need deterministic lower-layer proof; the merged #7133/#7173 tests are unregistered until rebase | Add `terminal-output.scrollback-restore` as a dirty-state exactness contract, seeded with #7133's and #7173's tests | -| Renderer/mirror parser parity | #7148 (merged 2026-07-02) fixed the first known width divergence; its red-green width test exists on main | Parser configs can still drift silently on any axis besides widths; no parity gate command is registered | Add `terminal-mirror.parser-parity` built on the shared `terminal-unicode-provider.ts`/`pane-terminal-options.ts` seams | -| Post-restore convergence detection in production | Anomaly-breadcrumb machinery exists in this branch | Nothing measures renderer-vs-mirror divergence after real restores; #7133-class corruption stays silent until a user notices | Add `terminal-observability.restore-convergence-selfcheck` probe plus telemetry | -| Stale pixels after reveal (render-level) | #7133's reveal hardening and the live repro harness's refresh-repair oracle | Buffer oracles are blind to buffer-clean, pixels-stale variants by definition | Add `terminal-render.pixel-refresh-repair` to the release-blocking terminal-rendering-golden suite | - -## Convergence Oracles And Escape Detection - -The #7133 saga is the design input for this section. One user-visible symptom — stale terminal frames on worktree return — decomposed into five distinct mechanisms: the skipped alt-screen clear (#7133 primary fix), the WebGL render-model fossil (#7133 hardening), the daemon mirror unicode width divergence (#7148), the stale hidden-chunk ordering hole with seq-restart data loss (#7173), and desktop resizes never reaching the runtime mirror (#7192). None of the three was predictable in advance, and seventeen faithful fresh-window repro attempts missed the class because the bugs only exist in dirty, long-lived pane states. Mechanism-enumeration testing cannot catch this family. Layer-convergence assertion can. - -Orca holds four copies of what a terminal shows: the PTY application's intended screen, the main-process headless mirror buffer, the renderer xterm buffer, and the WebGL-rendered pixels. Every bug in this family is two adjacent copies silently disagreeing. The strategy is two layers that map to the program's two goals — catching issues nobody predicted, and catching regressions to issues already fixed. - -Catch novel escapes: - -1. `terminal-observability.restore-convergence-selfcheck`: after every hidden-to-visible reveal settles — whether or not a restore was triggered — compare a bounded row-hash sample of the renderer buffer against the current main-mirror state; on mismatch, record a compact content-free `terminal_lifecycle_anomaly` breadcrumb (machinery already in this branch) and count it in telemetry. Per-reveal scope is load-bearing: #7173's frozen-output variant restores faithfully and then silently drops later hidden output, so a restore-scoped probe would never fire. This turns silent corruption into a measurable signal — #7133 and #7173 would have appeared in anomaly counts weeks before the user reports — and after each fix the anomaly rate is the in-production regression detector. Budget: one bounded comparison per reveal, no polling, no content capture beyond hashed row samples. -2. `terminal-render.pixel-refresh-repair`: the render-level variant is invisible to every buffer oracle by definition (buffer clean, pixels stale). The content-independent oracle: screenshot the revealed pane, force a full model-invalidating repaint, screenshot again; a material diff means something rendered stale, whatever the mechanism. Productize the existing live repro harness with this oracle plus a JS-level render-model-vs-buffer comparison, and land it in the release-blocking `terminal-rendering-golden` suite that `release-cut.yml` already runs against every release tag. Use nightly runs only as the interim phase to stabilize the per-platform diff threshold; golden-set membership is what makes "the next release does not ship this class" directly checkable. -3. Session-corpus replay: record real agent-session byte streams (a Claude run is exactly the #7133 shape — banner, prompt echo, final frame with blank regions) as fixtures and replay them through the hide/skip/restore machinery under the convergence oracles. Real corpora contain escape-sequence conjunctions nobody writes by hand. This is fixture strategy for the gates above, not a separate gate. - -Catch regressions to known fixes: - -4. `terminal-mirror.parser-parity`: feed identical byte corpora to the renderer xterm configuration and the main headless mirror and assert cell-identical buffers, with both parsers constructed from one shared configuration module. #7148's divergence (Unicode 11 + ZWJ in the renderer, default width tables in the mirror) existed since the mirror was born and was catchable by a millisecond-scale unit test; this gate also blocks the next one-sided config drift. -5. The dirty-state restore exactness contract as `terminal-output.scrollback-restore`'s command: apply snapshots onto adversarially dirty panes and assert the resulting buffer exactly equals the snapshot frame. Exactness is load-bearing — presence-of-marker oracles pass on merged frames. Seeded with #7133's merged alt-only-clear tests and #7148's positioned-overwrite width oracle. -6. Manifest registration of every escape: each fixed escape's red-green test becomes gate evidence, and per the Operating Rules the fix PR must also strengthen the relevant convergence oracle, so each escape permanently shrinks the space for the next one. - -Seam placement is part of the design: convergence must be asserted at every adjacent pair of copies, not at one seam. #7192 proved this the hard way — a renderer-vs-mirror check is blind to corruption inside the mirror itself, because the restore is perfectly faithful to a corrupted source. That is why the geometry gate owns the runtime mirror's dimensions and reflow ordering as a first-class size authority, upstream of the seam this section's self-check watches. - -The honest limit stands: this shrinks the escape space rather than zeroing it. The first four mechanisms in the saga are caught by items 1, 2, 4, and 5 without predicting any of them; the fifth (#7192) escaped every renderer-side oracle as originally specced and was closed by adding the mirror to the geometry authority list — evidence that the escape-driven loop (item 6) is not optional. The three new gates and the strengthened `terminal-output.scrollback-restore` entry are registered in the manifest as `protection: "none"` gaps, so the checker, factory reviews, and this plan cannot claim them as coverage before their commands exist. - -## Immediate Fix Candidates - -These are the concrete pieces to prioritize before declaring the plan implemented: - -Three mechanical prerequisites gate everything below: - -- Materialize the split: commit each working-tree slice to its owning branch per the file assignment in the PR Split Plan, reset the diverged child branches (#7004, #7005, #7006, #7008) from the worktree versions of their files, and push. Until then, every evidence run in this plan and in the manifest refers to unreproducible state. -- During the rebase, run the testFiles-vs-fresh-main audit and fix the confirmed #6949 WebGL rename by adopting `terminal-webgl-atlas-recovery.test.ts` and evaluating `pane-webgl-context-recovery.test.ts` and `pane-webgl-renderer.test.ts` for the containment gate. -- Open the aggregated non-blocking `reliability-gates` CI job so promotion evidence starts accruing with machine provenance. - -1. Rebase or retarget the existing reliability PR stack onto fresh `origin/main`, then verify the stack still contains queued automatic resume claims and exact `hasPty` missing-session reconciliation. This branch has restored both deterministic slices, but final stack verification is still required. -2. Extend the provider-session ownership test from the current queued/pending, bounded-index, same-session hook, and wrong-session hook proof to a real Electron repeated-activation test, plus bounded-work assertions for any new hook/status scans. -3. Add or restore exact liveness tests: `hasPty(id) === false` tears down only the matching local PTY, while `true`, `null`, rejection, SSH/remote unknown states, and stale pre-bind responses do not close panes. -4. Add a small derived terminal ownership snapshot helper for tests and diagnostics. It should report whether each live PTY is represented once by the intended tab/leaf and absent from stale maps after attach, reattach, exit, clear, hibernate, and restore. -5. Keep the replay FIFO/burst fix and tests in the stack, rebased over #7133's alt-clear restore preamble in the same file. This branch now asserts that multiple replay notifications arriving during an async drain cannot overwrite one another, cannot reorder accepted replay snapshots, and cannot grow an unbounded replay queue under bursty snapshots; remaining replay work is normal-buffer clear semantics (alt-screen clear landed in #7133), metadata-only replay, and hidden/live-output interleaving. -6. Wire `DaemonPtyAdapter.reconcileOnStartup`, or explicitly create a provider-contract gate and accepted gap if wiring is not safe yet. -7. Keep the degraded daemon fail-closed contract in place and extend it to startup/restart coverage. This branch now prevents unknown existing ids from silently routing operation calls or restored worktree-scoped and legacy/non-scoped spawn calls to fallback; the remaining work is production startup reconcile, prior-worktree aliases, and live daemon-restart validation. -8. Keep the daemon-router targeted-discovery fix in place. This branch now caches ownership discovered by `hasPty()` before later write/resize-style operations and fails closed for operations on unknown existing session ids, preventing legacy-daemon sessions from silently routing to the current daemon. -9. Extend provider listing failure contract tests. This branch now asserts disconnected SSH relay ids are retained as deferred reconnect metadata and sidebar wake hints rather than attached PTY proof, a rejected SSH listing does not clear previously learned ownership, thrown SSH `hasPty` is unknown, provider expired attach does not silently fresh-spawn, deferred SSH passphrase cancellation does not reconnect, deferred attach uses saved session ids, transient deferred reattach failure preserves retry metadata instead of fresh-spawning, and expired deferred relay fallback clears stale pane/tab bindings before one fresh spawn. Remaining work is empty-but-ambiguous provider snapshots, WSL, remote-runtime mirror polling, live SSH relay timing, false-live visual state, pending-vs-attached UI status, and pane-close behavior. -10. Extend the new lifecycle anomaly breadcrumbs into a bounded structured lifecycle trace outside E2E-only mode for reattach, expired sessions, provider-list failure, unknown ownership, fallback routing, replay, and terminal input recovery. -11. Extend the hot-interaction listing count gate. The targeted `hasPty`, resize-on-resume, light tab/active-state resume, and closed Resource Manager polling slices are implemented; next it should prove `pty:listSessions()` calls are zero during raw focus, workspace switch, render, high-session Electron scenarios, and separately budget open status/diagnostic polling. -12. Add a cheaper Resource Manager session-status path or an explicit budget for open-popover `pty:listSessions()` polling. The closed-popover poll is removed, but the visible Resource Manager inventory still fans out to providers and mutates ownership, so it should have a measured ceiling. -13. Budget the new startup hydration counters for `hydrateLocalPtyRegistryAtBoot` in CI/runtime evidence, including repo/worktree enumeration count, git subprocess count proxy, daemon session-list duration, and confirmation that no startup-critical path awaits the scan. -14. Extend the output backpressure proof. The daemon stream batcher now pauses after `write(false)`, prioritizes flush-immediate output from another session ahead of unrelated background backlog on `drain`, clears pure-backpressure queues on global cleanup, bounds queued stream bytes, and resumes on `drain`; main pending renderer output is capped per PTY and in total. Next, hidden-output floods and active key latency need metric artifacts under the perf budget. -15. Add Windows platform gates rather than relying on Linux/macOS live PTY evidence: ConPTY liveness, keyboard reset, CJK repaint, shell resolution parity, and IME/native-text forwarding. -16. Decide whether the legacy JSON runtime terminal subscribe path is supported. If supported, gate its snapshot/live-output byte caps; if not, mark it as an accepted gap with a deprecation path. -17. Implement the dirty-state restore exactness contract as `terminal-output.scrollback-restore`'s first command, seeded with #7133's merged alt-only-clear tests and #7173's ordered-seq interleaving and session-revival tests: apply snapshots onto already-alt-screen panes with stale content in cells the new frame leaves blank, include revived sessions with restarted PTY seq counters in the dirty-state matrix, and assert exact buffer equality. -18. Implement the parser-parity seed during the rebase, now that #7148 has merged: register `headless-emulator-unicode-width.test.ts` plus a shared parser-construction assertion over `terminal-unicode-provider.ts` and `pane-terminal-options.ts` as `terminal-mirror.parser-parity`'s first command. The test file exists on fresh main but not at this branch's stale merge-base, so the command cannot be registered before the rebase. -19. Implement the per-reveal convergence self-check behind the existing anomaly-breadcrumb machinery — comparing the renderer buffer against current mirror state on every reveal, not only after restores, with a strict per-reveal budget and no content capture — and productize the stale-render repro harness into `terminal-render.pixel-refresh-repair` targeting the release-blocking terminal-rendering-golden suite. -20. Register #7192's merged mirror-geometry red tests as the mirror-authority slice of `terminal-geometry.visible-convergence` during the rebase, covering both accepted-resize fan-out to the runtime mirror and reflow-vs-queued-output ordering. - -Next gates to add before claiming broad terminal reliability coverage: - -1. `terminal-platform.live-pty-liveness`: first focused Electron/live-PTY slice is implemented for local Linux/macOS-style terminals. One local macOS run asserts bind, single active PTY listing, focus, keyboard input, PTY echo, repeated workspace hide/restore of the same PTY, post-restore input, actual size propagation, and exit cleanup. Follow up with scrollback-after-restore, tab switch, app restart, and non-local providers before broad live-terminal claims; keep it `experimental` until it has flake/runtime and red-green evidence. -2. `terminal-platform.windows-conpty-liveness`: Windows-specific gate for ConPTY spawn/activation, PowerShell resolution, cursor/rewrite repaint, CJK/IME bytes, and no stuck `stale_bootstrap`. Use lower-level provider contracts where possible and one focused Windows Electron smoke for the real ConPTY path. -3. `terminal-input.windows-conpty-keyboard-reset`: Windows local ConPTY gate proving Enter, Backspace, Arrow, paste, and ordinary shell submission behave normally after an agent or TUI exits. Oracle should include PTY input logs proving stale Kitty/CSI-u mode is not still applied to standard keys. -4. `terminal-render.windows-cjk-repaint`: Windows local ConPTY wide-glyph redraw gate. Oracle should combine xterm buffer/text evidence with screenshot/canvas/pixel evidence and a bounded refresh count. -5. `terminal-shell.windows-resolution-parity`: local and daemon providers must resolve equivalent shell path, args, cwd, env, startup command delivery, and fallback behavior for PowerShell 5/7, cmd, Git Bash, WSL, and missing `pwsh`. -6. `terminal-performance.input-throughput`: non-blocking perf gate for typing latency, hidden-output pressure, renderer CPU, terminal output throughput, resize churn, and store subscriber work. This should be metric-based, not screenshot-based. -7. `terminal-performance.no-hot-list-sessions`: deterministic count gate proving `pty:listSessions()` is not called from typing, focus, tab/workspace switch, visibility resume, resize, render, or per-pane liveness paths. -8. `terminal-performance.daemon-stream-backpressure`: provider/daemon contract gate proving daemon socket writes respect backpressure under output floods and do not starve active input. -9. `terminal-performance.output-backpressure-budget`: main-to-renderer ACK and renderer scheduler budget gate. Suggested ceilings: peak renderer in-flight bytes <=8MB total, <=512KB per PTY plus active reserve, renderer queued chars <=2MB, dropped renderer backlogs 0 in normal perf scenarios, and hidden restore <=1000ms. -10. `terminal-runtime.mobile-stream-budget`: runtime/mobile stream gate proving binary terminal snapshots stay <=512KB mobile or <=2MB requested, live pending output stays <=256KB while snapshot loads, chunks stay <=48KB, and batches stay <=64KB. -11. `terminal-input.ime-and-synthetic-forwarding`: representative IME/input matrix with deterministic byte/cell oracles at the lowest reliable layer, plus platform-specific manual/soak coverage where real IMEs are required. -12. `terminal-provider.remote-daemon-contract`: provider contract for daemon degraded spawn, SSH/remote unknown liveness, mirror polling, reconnect, and path/cwd authority. -13. `terminal-provider.wsl-restore-contract`: Windows WSL provider contract proving host/guest cwd identity, shell launch args, startup command delivery, targeted liveness semantics, and restore ownership, followed by one focused live WSL smoke. -14. `terminal-output.scrollback-restore`: deterministic scrollback/snapshot/replay gate for hidden output, restore, clearing, and no cross-tab or stale-output overlap. Seed it with #7133's merged assertions — the alt-only clear preamble written before alt-screen snapshot data (and no `3J`) in `pty-connection.test.ts`, plus the reveal-repaint ordering tests — then extend to normal-buffer clear semantics, metadata-only replay, and hidden/live-output interleaving. #7133's buffer-merge escape (pre-hide frame bleeding through blank cells) is the motivating regression for this gate. -15. `terminal-capability.startup-color-query`: startup OSC 10/11 replies are answered out of band at startup only, do not leak into shell/renderer streams, and ordinary OSC color queries remain renderer-handled. -16. `terminal-mirror.parser-parity`: renderer xterm and the main headless mirror parse identical byte corpora into cell-identical buffers, with both parsers constructed from one shared configuration module. Seed with #7148's merged width test (`headless-emulator-unicode-width.test.ts`) plus a shared-construction assertion over `terminal-unicode-provider.ts` and `pane-terminal-options.ts`; extend with recorded agent-session corpora. -17. `terminal-observability.restore-convergence-selfcheck`: production per-reveal probe comparing a bounded row-hash sample of the renderer buffer against current main-mirror state — on every reveal, not only after restores, because #7173's frozen-output variant never triggers another restore — recording deduped content-free anomaly breadcrumbs and telemetry counts. This is the in-production escape detector for the whole restore family; corruption upstream of the mirror itself is owned by the geometry gate. -18. `terminal-render.pixel-refresh-repair`: content-independent refresh-repair oracle (screenshot, force a model-invalidating repaint, screenshot, bounded diff) plus a render-model-vs-buffer comparison, productized from the existing stale-render repro harness into the release-blocking `terminal-rendering-golden` suite; nightly runs only until the diff threshold is stable. - -Specific near-term hardening before promotion: - -- #7008 now includes a bounded queued-claim assertion plus same-session and wrong-session hook cases for provider-session ownership checks. It should still be followed by a real Electron repeated-activation proof and any additional bounded-work assertions if hook/status ownership scans grow. -- Each gate PR should keep its residual gaps in the PR description and manifest until a real follow-up gate removes them. Do not let a green lower-layer gate imply live Electron, SSH, WSL, Windows ConPTY, or IME coverage. - -Promotion rule for these additions is unchanged: do not make any of them blocking until they have red/green proof, stable runtime history, zero unexplained flakes in the promotion window, a clear owner, and a demotion rule. A non-blocking gate is useful evidence and documentation; it becomes protection only after promotion criteria are met. - -## Goal - -Prevent the recent terminal, tab, session, startup, provider, and performance regressions from escaping again by turning each high-risk reliability class into a small executable gate with explicit maturity, owner, runtime budget, flake history, and promotion evidence. - -This plan is broad in scope but staged in rollout. The first milestone exercises the operating model end to end; later milestones add live Electron, Windows ConPTY, SSH/remote, daemon, IME, scrollback, and performance gates without turning the suite into noisy sprawl. - -## Operating Rules - -- Add gates only when they protect a named invariant tied to a real issue, PR, or accepted gap. -- Prefer deterministic unit or provider-contract tests over Electron UI tests when the lower layer proves the user-visible invariant. -- Never promote a gate to blocking until it has red/green evidence, stable runtime evidence, and a named demotion rule. -- Keep stress/torture runs non-blocking unless they have deterministic oracles and flake history. -- Include a performance or crash-safety budget whenever the protected change touches terminal throughput, hidden panes, persistence, startup, polling, subprocesses, git/worktree scans, SSH, WSL, or Windows paths. -- Restore, replay, reattach, and snapshot gates must start from adversarially dirty initial states — already-alt-screen panes, stale content occupying cells the new frame leaves blank, scrollback present, wide/emoji glyphs — never only fresh terminals. Fresh-state-only testing is why the #7133 class survived seventeen faithful repro attempts. -- Restore and replay oracles must assert frame exactness or stale-content absence. Marker-presence oracles pass on merged frames and are not acceptable correctness evidence for restore paths. -- Every escaped terminal bug's fix PR must ship both the narrow mechanism regression test and a strengthening of the relevant convergence oracle (parser parity, restore exactness, convergence self-check, or pixel refresh-repair), so each escape permanently shrinks the space for the next one. - -## Milestone 1 - -Milestone 1 creates the reliability-gate loop with the two highest-leverage escaped classes: - -1. `terminal-session.snapshot-freshness` - A stale local/daemon liveness snapshot cannot close a newer PTY binding. - -2. `agent-session.provider-ownership` - Workspace activation, restore, sleep, hibernate, dedupe, clearing, or reconnect code cannot replay or resume a provider session already owned, queued, pending, or live in the workspace. - -Both gates already have targeted tests in the repo. Milestone 1 makes them reviewable factory artifacts by registering their invariant, command, owner, maturity, known gaps, and promotion criteria in `config/reliability-gates.jsonc`. - -## First Commands - -Run the structural manifest check: - -```sh -pnpm run check:reliability-gates -``` - -Run the first two gate tests: - -```sh -pnpm exec vitest run --config config/vitest.config.ts \ - src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts \ - src/renderer/src/lib/resume-sleeping-agent-session.test.ts -``` - -Run the existing terminal perf report gate before promoting terminal lifecycle gates to blocking: - -```sh -pnpm run test:e2e:terminal-perf:scale:report -``` - -## Promotion Criteria - -A gate can move from `experimental` to `soak` when: - -- it is registered in the manifest; -- it has a deterministic oracle with no blind sleeps; -- it has a cheap command that can run repeatedly; -- it names the failure artifact reviewers should inspect; -- it has a clear owner and demotion rule. - -A gate can move from `soak` to `blocking` when: - -- it has red/green evidence against the old behavior, a regression fixture, or an intentionally broken variant; -- it has at least 100 consecutive passing soak runs or 14 days of required-platform CI history, whichever is more appropriate for the gate; -- that history comes from the aggregated `reliability-gates` CI job with machine-recorded results (see Evidence Provenance And CI Wiring), not from hand-entered local runs; -- p95 runtime is within the manifest budget; -- there are zero unexplained flakes in the promotion window; -- any required perf/git-crash budget has measured evidence. - -For visual and live release-assurance gates, `blocking` means membership in the release-blocking `terminal-rendering-golden` suite that `release-cut.yml` runs against every release tag — the existing golden rail, not a new checkpoint mechanism, and never PR-blocking. Deterministic unit and contract gates stay on the PR path, where catching a regression is strictly cheaper than catching it at release time. - -The manifest also allows `accepted-gap` for explicitly deferred reliability work with a named owner and `deprecated` for gates superseded by a stronger invariant or removed product surface. Those levels should stay visible in the manifest so factory review can distinguish intentional gaps from missing coverage. - -## Factory Contract - -`brennan-yolo-lite`, `review-code`, `perf`, and `git-crash-perf` should treat the manifest as the source of truth. - -For any PR touching a P0-capable surface, the agent or reviewer should: - -- identify the touched reliability class; -- name the matching manifest gate or accepted gap; -- run the gate command or explain why it does not apply; -- require a new manifest entry when the PR introduces a new reliability class; -- invoke `perf` or `git-crash-perf` when the touched surface matches their risk scope. - -## Evidence Provenance And CI Wiring - -Hand-entered evidence is the weakest link in this plan: the checker validates metadata shape, but nothing yet proves a registered command actually ran against the code it claims to cover. Reliability gates become protection only when they run automatically, against pushed commits, with recorded history. Requirements: - -1. Every `evidenceRuns` entry must record the pushed commit SHA it ran against. A run against uncommitted working-tree state is a development note, not promotion evidence; it must be re-run and re-recorded once the owning PR's branch is pushed. All evidence runs currently in the manifest predate the split and must be refreshed this way. -2. Add one aggregated non-blocking `reliability-gates` CI job that runs `check:reliability-gates` plus every command-backed gate command, and uploads a machine-readable per-gate result artifact (gate id, command, result, duration, commit SHA, runner platform). Run it nightly and on PRs that touch terminal/session/provider/startup paths. Because every gate command except the live Playwright slice is a focused vitest run measured in seconds (a combined run of all 28 unit-layer gate test files completed in 10.2s with 928 tests on 2026-07-02), the job's cost is dominated by setup, not by gate count. -3. Promotion to `active`/blocking requires history from that CI job — the 100-consecutive-run or 14-day criteria in Promotion Criteria — not local runs. Flakes recorded by the job must be triaged before the affected gate can be promoted, and an unexplained flake on an `active` gate is the demotion signal. -4. Checker follow-up: once the CI artifact exists, extend the checker so `active` gates require at least one CI-provenance evidence run, and so evidence-run commit SHAs are verified to be ancestors of the branch under check. -5. Rebase audit: before any merge claim, mechanically verify every declared `testFiles` entry exists on fresh `origin/main` (or on the rebased tree) rather than trusting memory. The #6949 WebGL rename is the standing example of why. - -## Milestone 2 - -After Milestone 1 works, add the next three gates as experimental entries with deterministic harnesses: - -- `terminal-geometry.visible-convergence` -- `xterm-addon.boundary-containment` -- `startup-upgrade.persisted-session-corpus` - -These should not become blocking until the team has red/green, runtime, and flake evidence. The goal is fewer useful gates, not a larger noisy suite. - -## Milestone 3 - -Milestone 3 is where the plan stops being only a lower-layer regression net and starts exercising the real terminal runtime: - -- Register existing terminal perf and E2E tests as experimental manifest gates only after reviewing their oracles, waits, artifacts, and flake history. -- Add the live local PTY gate with a tight oracle: spawn a shell, print a unique token, switch hidden-visible, assert the same PTY/session id, verify visible buffer content, type again, and prove no duplicate tab or blank pane appeared. -- Add Windows ConPTY coverage on Windows CI for echo, resize/readback, cursor visibility, CJK/emoji output, keyboard reset, shell resolution parity, and activation after restore. -- Add daemon degraded/startup reconcile contracts before relying on daemon fallback behavior. -- Add live SSH, WSL, remote-runtime mirror polling, and provider-listing edge contracts before treating remote/SSH restore as broadly covered; the current mocked SSH deferred-reattach and expired-relay tests are useful lower-layer proof, not live remote coverage. -- Add perf gates with explicit budgets for input latency, event-loop delay, hidden-output pressure, daemon stream backpressure, no hot `listSessions`, pending bytes, renderer ACK behavior, store hot paths, and runtime/mobile stream byte caps. -- Add IME/synthetic-input coverage at the lowest deterministic layer, with platform-specific manual or soak coverage only where real IMEs are required. - -Milestone 3 gates should begin as `experimental`. They become `soak` only when the command is stable enough to run repeatedly and the failure artifact tells an engineer exactly what broke. They become `blocking` only after the promotion criteria above are satisfied. diff --git a/docs/reference/reliability-pain-points-2026-06-30.md b/docs/reference/reliability-pain-points-2026-06-30.md deleted file mode 100644 index 8049e3e7191..00000000000 --- a/docs/reference/reliability-pain-points-2026-06-30.md +++ /dev/null @@ -1,712 +0,0 @@ -# Reliability Pain Points and Improvement Plan - -Date: 2026-06-30 - -## Scope - -This note summarizes the recent reliability review across: - -- Recently merged reliability PRs, especially terminal/tab/startup/session work from 2026-06-26 through 2026-06-30. -- GitHub issues labeled `P0` and `P1`. -- Local git history for authorship when issue text or PR bodies named a regressing change. - -The goal is to identify where Orca is hurting, what engineering improvements would reduce repeat incidents, and what can fairly be said about attribution. - -## Issue Snapshot - -At the time of review: - -- Open `P0`: 9 -- Total `P0`: 14 -- Open `P1`: 191 -- Open `P1` split: 90 bugs, 86 enhancements, 15 other - -Last seven days reviewed: 2026-06-23 through 2026-06-30. - -- Merged PRs in the window: 386 -- `P0` issues created in the window: 8 -- `P1` issues created in the window: 83 - -The last-seven-day issue set is the strongest signal for the current pain. It includes the new-tab autoclose `P0`, Windows terminal activation hangs, renderer CPU/input lag, Windows startup visibility failure, Codex config corruption on Windows, a large terminal/IME/rendering cluster, WSL/remote/provider drift, and agent/sidebar/session identity drift. - -Open `P0` issues: - -- [#6877](https://github.com/stablyai/orca/issues/6877): macOS 1.4.106 nested `simcam` dylib is unnotarized and rejected by Gatekeeper. -- [#6874](https://github.com/stablyai/orca/issues/6874): Windows `AppHangB1` when opening or activating a terminal session; runtime stuck in `stale_bootstrap`. -- [#6873](https://github.com/stablyai/orca/issues/6873): likely duplicate or near-duplicate of #6874, though the issue metadata says macOS while the title/body describe Windows. -- [#6795](https://github.com/stablyai/orca/issues/6795): app became very slow after an update; typing can lag by 2-5 seconds. -- [#6773](https://github.com/stablyai/orca/issues/6773): cannot create a new terminal tab or agent; the tab opens and closes immediately. -- [#6655](https://github.com/stablyai/orca/issues/6655): high `Orca Helper (Renderer)` CPU on Mac Mini M4 causing severe UI/input lag. -- [#5787](https://github.com/stablyai/orca/issues/5787): whole Windows window freezes; input dies in every pane; sessions close on click. -- [#5356](https://github.com/stablyai/orca/issues/5356): session loss on first launch after upgrading from 1.4.65 to 1.4.68. -- [#5314](https://github.com/stablyai/orca/issues/5314): Windows close button and Alt+F4 do nothing. - -Closed `P0` issues worth keeping in the reliability history: - -- [#6233](https://github.com/stablyai/orca/issues/6233): Windows window fails to appear while the process runs in the background. -- [#6163](https://github.com/stablyai/orca/issues/6163): Orca-managed Codex `config.toml` duplicate key. -- [#5377](https://github.com/stablyai/orca/issues/5377): native watcher main-process crash on macOS. -- [#5144](https://github.com/stablyai/orca/issues/5144): Windows close-confirmation deadlock. -- [#5109](https://github.com/stablyai/orca/issues/5109): sidebar agent status mismatch. - -## P1 Reliability Clusters - -The `P1` set is too large to treat as a flat list. The high-signal reliability clusters are: - -### Terminal Rendering and Input - -- [#6901](https://github.com/stablyai/orca/issues/6901): garbled text. -- [#6764](https://github.com/stablyai/orca/issues/6764): Windows terminal issues. -- [#6632](https://github.com/stablyai/orca/issues/6632): display artifacts in Claude Code full-screen TUI. -- [#6144](https://github.com/stablyai/orca/issues/6144): laggy Claude Code terminal scroll. -- [#5969](https://github.com/stablyai/orca/issues/5969): remote-over-SSH Codex display artifacts. -- [#5345](https://github.com/stablyai/orca/issues/5345): Windows/WSL scrolling causes duplicated or garbled text and content overlap. - -### IME and International Text - -- [#6905](https://github.com/stablyai/orca/issues/6905): Vietnamese IME input broken in terminal. -- [#6765](https://github.com/stablyai/orca/issues/6765): Linux Sogou Pinyin/fcitx only commits the first CJK character. -- [#6698](https://github.com/stablyai/orca/issues/6698): Vietnamese Telex input loses characters. -- [#5921](https://github.com/stablyai/orca/issues/5921): CJK characters duplicated in Orca terminal on Windows. -- [#5262](https://github.com/stablyai/orca/issues/5262): Arabic text rendering appears reversed or disconnected in terminal input. - -### Paste and Clipboard - -- [#5365](https://github.com/stablyai/orca/issues/5365): terminal freezes for seconds when pasting long text on Windows. -- [#5358](https://github.com/stablyai/orca/issues/5358): Windows paste preview collapses long text and cannot expand it. -- [#5919](https://github.com/stablyai/orca/issues/5919): generic paste reliability issue. -- [#5960](https://github.com/stablyai/orca/issues/5960): bad screen result when pasting links. -- [#6364](https://github.com/stablyai/orca/issues/6364): pasting images into an agent CLI does not work using Remote Host. - -### SSH, Remote Runtime, WSL, and Provider Boundaries - -- [#6106](https://github.com/stablyai/orca/issues/6106): SSH terminal loses pre-TUI shell output after Codex tab restore. -- [#6846](https://github.com/stablyai/orca/issues/6846): remote PTY sessions do not stay alive on disconnect. -- [#6908](https://github.com/stablyai/orca/issues/6908): WSL project added through UI does not discover branches/base refs, while CLI repo add works. -- [#6907](https://github.com/stablyai/orca/issues/6907): Codex launched in a WSL worktree does not appear in agent status/sidebar. -- [#6916](https://github.com/stablyai/orca/issues/6916): OMP launched in a WSL worktree has no live agent status. -- [#6032](https://github.com/stablyai/orca/issues/6032): Remote Orca Servers can lose host-scoped repo and project state. -- [#6688](https://github.com/stablyai/orca/issues/6688): viewing a diff on remote host errors. -- [#6753](https://github.com/stablyai/orca/issues/6753): remote-host GitHub merge errors. - -### Agent, Tab, Session, and Sidebar State Drift - -- [#6910](https://github.com/stablyai/orca/issues/6910): active agent tabs duplicate UI sessions when switching repositories. -- [#6803](https://github.com/stablyai/orca/issues/6803): multiple working agents thrash their order in the sidebar. -- [#6072](https://github.com/stablyai/orca/issues/6072): mobile keeps showing old agent rows after terminals are closed. -- [#5913](https://github.com/stablyai/orca/issues/5913): Pi compact agent row remains after closing its terminal tab. -- [#5718](https://github.com/stablyai/orca/issues/5718): OMP sessions not showing up in Agent Session History or filter options. -- [#5404](https://github.com/stablyai/orca/issues/5404): false "agent running" notifications persist after starting Claude Agents on VSSH/WSL. - -### Mobile Terminal and Tabs - -- [#5421](https://github.com/stablyai/orca/issues/5421): iOS workspace tabs do not open. -- [#6756](https://github.com/stablyai/orca/issues/6756): iOS terminal renders in cursive/italic font and continuously flickers. -- [#5628](https://github.com/stablyai/orca/issues/5628): mobile resizing feature request, closely related to terminal fit/reflow reliability. - -### Browser, Profile, and Embedded Runtime - -- [#6923](https://github.com/stablyai/orca/issues/6923): browser profiles do not isolate storage. -- [#6760](https://github.com/stablyai/orca/issues/6760): floating browser cannot load any page. -- [#6268](https://github.com/stablyai/orca/issues/6268): embedded-browser cookie/session limitation prevents Auth0 login. -- [#6875](https://github.com/stablyai/orca/issues/6875): cannot copy Google Chrome cookies database. -- [#6652](https://github.com/stablyai/orca/issues/6652): browser zoom broken. - -### Startup, Install, and Platform Hangs - -- [#5657](https://github.com/stablyai/orca/issues/5657): macOS startup PATH probe can hang under Endpoint Security agents. -- [#5107](https://github.com/stablyai/orca/issues/5107): Orca opens very slowly on Windows 11 and leaves a residual process. -- [#5989](https://github.com/stablyai/orca/issues/5989): Windows Defender blocks application during installation. - -## Recent Reliability PR Context - -The late-June reliability work clustered around the same few subsystems: - -- New terminal tabs opening and immediately closing: [#6796](https://github.com/stablyai/orca/pull/6796), [#6801](https://github.com/stablyai/orca/pull/6801). -- Stale/frozen/blank terminals: [#6514](https://github.com/stablyai/orca/pull/6514), [#6800](https://github.com/stablyai/orca/pull/6800), [#6833](https://github.com/stablyai/orca/pull/6833), [#6830](https://github.com/stablyai/orca/pull/6830), [#6866](https://github.com/stablyai/orca/pull/6866). -- PTY size and layout desync: [#6644](https://github.com/stablyai/orca/pull/6644), [#6649](https://github.com/stablyai/orca/pull/6649), [#6684](https://github.com/stablyai/orca/pull/6684), [#6725](https://github.com/stablyai/orca/pull/6725), [#6785](https://github.com/stablyai/orca/pull/6785), [#6853](https://github.com/stablyai/orca/pull/6853). -- Renderer/xterm crash hardening: [#6852](https://github.com/stablyai/orca/pull/6852), [#6872](https://github.com/stablyai/orca/pull/6872), [#6855](https://github.com/stablyai/orca/pull/6855), [#6868](https://github.com/stablyai/orca/pull/6868), [#6856](https://github.com/stablyai/orca/pull/6856), [#6857](https://github.com/stablyai/orca/pull/6857). -- Windows shell/spawn/input: [#6537](https://github.com/stablyai/orca/pull/6537), [#6876](https://github.com/stablyai/orca/pull/6876), [#6858](https://github.com/stablyai/orca/pull/6858), [#6890](https://github.com/stablyai/orca/pull/6890). -- Hidden/agent startup behavior: [#6824](https://github.com/stablyai/orca/pull/6824), [#6798](https://github.com/stablyai/orca/pull/6798), [#6836](https://github.com/stablyai/orca/pull/6836). - -## Last-Seven-Day Regression Origin Map - -This section maps recent regressions to the place where the system failed, and to the gate that should catch the same class next time. - -| Issue | Symptom | Regression point | Fix / related PR | Missing gate | -| --- | --- | --- | --- | --- | -| [#6773](https://github.com/stablyai/orca/issues/6773) | New terminal/agent tab opens and closes immediately | Dead-session reconciliation could treat a fresh PTY binding as absent from a stale `listSessions()` snapshot. This likely traces to the local dead-session reconcile path introduced for hidden exited panes in [#6514](https://github.com/stablyai/orca/pull/6514). | [#6796](https://github.com/stablyai/orca/pull/6796), [#6801](https://github.com/stablyai/orca/pull/6801) | Newborn terminal lifecycle test: create shell and agent tabs while liveness reconciliation is in flight; assert the tab cannot be closed by an older snapshot. | -| [#5356](https://github.com/stablyai/orca/issues/5356) | First launch after upgrade loses active floating/agent terminal sessions | Cold-restore relied on state that only versions after the fix could write, so users upgrading from pre-fix versions had no migration source. Issue text explicitly names [#5234](https://github.com/stablyai/orca/pull/5234) and [#5240](https://github.com/stablyai/orca/pull/5240). | Not fully closed in current open `P0` set | Upgrade fixture test: boot current app with persisted state from the last affected production version and assert active terminal/agent sessions are preserved or visibly recoverable. | -| [#5319](https://github.com/stablyai/orca/issues/5319) | Linux/Wayland terminals render but stop accepting keyboard/scroll input | Wayland-aware GPU safeguard was removed in [#1344](https://github.com/stablyai/orca/pull/1344), leaving eager GPU channel setup able to wedge terminal input on affected systems. | [#6557](https://github.com/stablyai/orca/pull/6557) | Platform GPU matrix test: Linux Wayland launch with terminal WebGL/input smoke and assertion that GPU flags preserve input, not only rendering. | -| [#6233](https://github.com/stablyai/orca/issues/6233) | Windows process runs but no window appears | Startup window reveal depended on Electron `ready-to-show`; if that event stalled, there was no fallback reveal or user-visible failure. | [#6462](https://github.com/stablyai/orca/pull/6462) | Startup watchdog test: simulate missing `ready-to-show` on Windows and assert the app either reveals or exits with a clear recovery path. | -| [#6163](https://github.com/stablyai/orca/issues/6163) | Orca-managed Codex `config.toml` fails with duplicate `hooks.state` keys on Windows | Two TOML writers serialized the same Windows path key differently, and Orca deduped textually rather than by decoded key. | [#6318](https://github.com/stablyai/orca/pull/6318), follow-up [#6388](https://github.com/stablyai/orca/pull/6388) bug-scan fix | Cross-writer config round-trip test: seed config with Codex-written literal Windows paths, run Orca hook sync twice, then parse with a TOML parser and assert one decoded key. | -| [#6244](https://github.com/stablyai/orca/issues/6244) | Headless-launched automation terminal showed only a bash prompt until unmount/remount | Hidden/background automation worktrees were not mounted early enough for output/replay to hydrate on first view. | [#6568](https://github.com/stablyai/orca/pull/6568) | Headless automation mount test: launch background automation, wait for output, first-focus the tab, and assert output is already visible. | -| [#6331](https://github.com/stablyai/orca/issues/6331) | WSL workspaces with UNC cwd hit `DaemonProtocolError` | Windows/WSL path handling crossed provider boundaries and daemon spawn assumptions. | [#6536](https://github.com/stablyai/orca/pull/6536) | WSL UNC provider contract: add/open/delete workspace whose root is `\\wsl.localhost\...`; assert daemon and file operations use the right host/path semantics. | -| [#6336](https://github.com/stablyai/orca/issues/6336) | Monorepo subfolder import opened the repo root instead of the selected subfolder | Git-root normalization leaked into the first-terminal cwd/user-facing workspace root behavior. | [#6574](https://github.com/stablyai/orca/pull/6574) | Add-project matrix: repo root, monorepo subfolder, folder workspace, remote runtime; assert file tree root and first terminal cwd separately. | -| [#6147](https://github.com/stablyai/orca/issues/6147) | Chinese full-width punctuation became half-width ASCII in terminal | IME/composition forwarding lacked coverage for macOS full-width punctuation and composition-owned text. | [#6417](https://github.com/stablyai/orca/pull/6417) | IME matrix test: macOS CJK full-width punctuation, Vietnamese Telex, Linux fcitx/Sogou, Windows CJK; assert committed bytes and displayed cells. | -| [#5656](https://github.com/stablyai/orca/issues/5656) / [#5653](https://github.com/stablyai/orca/issues/5653) | Windows Claude Code prompt showed phantom/overwritten characters until resize | Rewrite-style terminal output did not reliably force visible row repaint for CR/CHA/erase updates. | [#6544](https://github.com/stablyai/orca/pull/6544), [#6449](https://github.com/stablyai/orca/pull/6449) | Terminal redraw fixture: feed CR, CHA, backspace, erase-line/screen and assert visible cells after each chunk without resize. | -| [#5161](https://github.com/stablyai/orca/issues/5161) | PowerShell terminal failed to spawn with Windows error code 5 | Windows shell resolver handed ConPTY a bare `pwsh.exe`, which could resolve to an App Execution Alias stub. | [#6537](https://github.com/stablyai/orca/pull/6537), [#6876](https://github.com/stablyai/orca/pull/6876) | Windows shell resolution contract: auto and explicit PowerShell selections must resolve to real executables and skip WindowsApps aliases. | -| [#6270](https://github.com/stablyai/orca/issues/6270) | Restoring agent sessions was not working for SSH/runtime use cases | Resume logic and runtime host restrictions did not cover SSH workspaces. | [#6685](https://github.com/stablyai/orca/pull/6685) | Agent resume provider matrix: local, daemon, SSH, remote runtime, Windows shell; assert resume command is queued on the configured host/provider. | -| [#6852](https://github.com/stablyai/orca/pull/6852) / [#6872](https://github.com/stablyai/orca/pull/6872) | Terminal search could crash terminal surface on narrow viewports | xterm search decorations received invalid dimensions and threw through the terminal surface. | [#6852](https://github.com/stablyai/orca/pull/6852), [#6872](https://github.com/stablyai/orca/pull/6872) | xterm addon safety test: narrow viewport, zero/near-zero columns, wrapped matches, keyboard next/previous; assert addon errors are caught and pane remains usable. | -| [#6855](https://github.com/stablyai/orca/pull/6855) | xterm web-link provider `RangeError` could kill the Windows renderer | Link provider ran synchronously inside xterm with no guard around pathological wrapped lines. | [#6855](https://github.com/stablyai/orca/pull/6855) | Addon boundary policy: all xterm addon callbacks are guarded and failure disables only that addon for that pane. | -| [#6853](https://github.com/stablyai/orca/pull/6853) | Split-right PTY stayed at `0x0` and rendered a white screen | Deferred spawn measured an unlaid-out pane; post-spawn reconcile could terminate before forwarding a usable size. | [#6853](https://github.com/stablyai/orca/pull/6853) | Split spawn invariant: visible panes must never keep PTY size `0x0`; fallback size must be forwarded if measurement is unavailable. | - -The repeated pattern is not one person repeatedly making the same mistake. It is that Orca does not yet have enough executable contracts around lifecycle, provider boundaries, terminal sizing, xterm safety, platform shell behavior, and upgrade state. - -## PR Process Evidence - -Reading the PR descriptions for the confirmed or likely regression chains shows that the failure was usually not "no process." The failure was that the process accepted plausible local validation without requiring the invariant that would have protected the escaped user path. - -### #6514: Dead-Session Reconcile Introduced a Newborn-Tab Hazard - -[PR #6514](https://github.com/stablyai/orca/pull/6514) was a Brennan-authored reliability fix for frozen terminal panes after a backgrounded agent exits. The PR body shows a substantial process: design review, completeness verification, headline behavior status, perf audit, code-review loop, Electron validation, and targeted unit tests. The stated non-goal explicitly excluded SSH/remote reconciliation, and the implementation revalidated pane/PTY identity at apply time. - -What still got through: - -- The PR protected "stale dead pane should close" but did not name the dual invariant "a fresh or newly bound pane cannot be closed by a liveness snapshot requested before that binding existed." -- The tests covered live/dead/remote/idempotency paths, but not the TOCTOU case where `listSessions()` is requested, a new PTY binds before the response returns, and the stale response omits the newborn id. -- Reviewers accepted "healthy panes are untouched" as a semantic claim, but the executable gate did not prove that claim under delayed session enumeration. - -Skill implication: - -- `brennan-yolo-lite`, `auto-design-review-fix`, `review-code`, and `brennan-test-changes` all need to force lifecycle changes to state the inverse safety property, not only the intended cleanup behavior. -- For terminal lifecycle changes, the review checklist should ask "what newer state can this async observation accidentally destroy?" - -### #5234 and #5240: Session-Restore Fixes Missed Old-Version Upgrade Fixtures - -[PR #5234](https://github.com/stablyai/orca/pull/5234) and [PR #5240](https://github.com/stablyai/orca/pull/5240) were Jinwoo-authored fixes for slow-startup daemon PTY restoration and quit-time agent session persistence. Their PR descriptions show AI review, negative controls, targeted tests, and high-quality e2e coverage against the newly modeled behavior. - -What still got through: - -- The tests proved the new behavior once the new data and startup gates existed. -- They did not appear to boot the current app against serialized persisted state from the affected production versions before the fix. -- The escaped class is an upgrade/migration class: a fix can be correct for state written after the fix while still losing users who upgrade from state written before it. - -Skill implication: - -- Startup, restore, daemon, and persistence PRs need an "old state corpus" gate: seed data from the last affected production version, not only state produced by current code. -- Negative controls should include "old production fixture fails before fix and is preserved after fix" when the bug is upgrade-related. - -### #1344: GPU Rendering Parity Missed Linux Wayland Input Failure - -[PR #1344](https://github.com/stablyai/orca/pull/1344) replaced global GPU opt-ins with VS Code-style GPU startup flags and terminal GPU acceleration settings. The PR description cites reference behavior and local tests, but it does not show the later, stricter Brennan PR process markers used in the June reliability fixes. - -What still got through: - -- The change removed or bypassed the Wayland-aware GPU safeguard later identified by [PR #6557](https://github.com/stablyai/orca/pull/6557). -- Validation covered configuration and pane lifecycle tests, but not a Linux Wayland terminal input smoke where rendering appears live while keyboard/scroll input is wedged. -- One TypeScript command was reported as failing on existing project-include issues, which made the PR less clean as a release-risk artifact. - -Skill implication: - -- Cross-platform rendering/startup changes need an explicit platform matrix tied to the failure mode: not just "renders," but "renders and accepts input." -- Reference implementations are useful context, but factory review must still require Orca-specific platform hazards to be named and tested or accepted. - -### Regression Lessons From Fresh Review - -Fresh reviewer passes on the recent regression chains pointed to these reusable misses. These are class-level gates, not new blame findings. - -- [#6514](https://github.com/stablyai/orca/pull/6514) -> [#6773](https://github.com/stablyai/orca/issues/6773), fixed by [#6796](https://github.com/stablyai/orca/pull/6796) and [#6801](https://github.com/stablyai/orca/pull/6801): the original PR correctly proved the forward cleanup case, where a hidden local/daemon PTY exits and should reconcile on resume while live listed panes survive. The missed invariant was the inverse lifecycle property: a destructive liveness snapshot can only close state that existed before the snapshot was requested. The escaped path was a macOS local new shell or agent tab where `listSessions()` was requested, a new PTY bound before the response returned, the stale response omitted the newborn id, and the renderer routed the healthy binding through exit teardown. The next gate is a Terminal Snapshot Freshness and Ownership Gate: a fake daemon holds `listSessions()` at `t0`, binds a newborn PTY at `t1`, resolves the stale `t0` snapshot without that PTY, and asserts the tab/pane survives, still owns the same PTY, accepts input, and renders output. A later post-bind snapshot may close a genuinely dead PTY, but only when provider, pane, PTY id, and binding epoch still match. -- [#6800](https://github.com/stablyai/orca/pull/6800), with [#5240](https://github.com/stablyai/orca/pull/5240), [#6411](https://github.com/stablyai/orca/pull/6411), [#6514](https://github.com/stablyai/orca/pull/6514), and [#6833](https://github.com/stablyai/orca/pull/6833) as related context: the original fix correctly proved that active worktree-sleep records should not be claimed merely because old tab/layout state exists; visible connecting panes with restorable identity can own wake, stale hidden panes fresh-resume, and completed hibernation evidence stays passive. The missed invariant was provider-session ownership, not "pane connects now." For each provider-session claim key, exactly one durable owner must exist: a live hook/status for that same provider session, a queued/sent resume command for that same provider session, or an explicit retained record because spawn/connect failed. Layout match, PTY wake hints, replayed terminal bytes, and cold-restored screen snapshots are display evidence, not ownership evidence. The next gate is an Agent Provider Session Ownership Matrix covering active focused tabs, inactive tabs, visible non-focused splits, live/quit/worktree-sleep origins, replay-only/cold-restore-only/wrong-session/no-hook/delayed-hook cases, and repeated workspace activation. -- [#5234](https://github.com/stablyai/orca/pull/5234) / [#5240](https://github.com/stablyai/orca/pull/5240) -> [#5356](https://github.com/stablyai/orca/issues/5356): the original PRs correctly proved slow-daemon restoration and newly written quit-origin provider-session persistence. The missed invariant was first-open-after-upgrade compatibility: startup/restore fixes must preserve or explicitly recover user sessions from the last affected production persisted-state schema, not only from state written by the fixed build. The escaped path was first launch after a 1.4.65 -> 1.4.68 upgrade, including Windows 10 active floating shell and Claude panes, where the old version had no `sleepingAgentSessionsByPaneKey` payload to consume. The next gate is a Persisted Session Upgrade/Restore Contract: boot current Orca against immutable user-data fixtures from the last affected version, including active and inactive tabs, visible and hidden splits, old tab-level PTY wake hints, missing `sleepingAgentSessionsByPaneKey`, live/quit/worktree-sleep/legacy origins, repeat activation, and a second restart after current code writes upgraded state. The oracle rejects blank replacement panes, duplicate resume tabs, silent session loss, and "works only after current code writes new state." -- [#1344](https://github.com/stablyai/orca/pull/1344) -> [#5319](https://github.com/stablyai/orca/issues/5319) / [#6557](https://github.com/stablyai/orca/pull/6557): the original PR correctly attempted to replace broad GPU opt-ins with VS Code-style GPU channel flags and prove terminal GPU/WebGL fallback at unit level. The missed invariant was terminal platform liveness, not visual readiness. A terminal is healthy only if it renders output, receives focus, delivers keyboard input to the correct PTY, echoes process output back to the xterm buffer, scrolls, and survives GPU/renderer startup on the target platform. The next gate is a Terminal Platform Liveness Gate across Linux Wayland GPU paths, headless Weston plus at least one scheduled/manual headful Wayland run, control Linux/macOS/Windows paths when renderer policy is shared, active and inactive tabs, visible and hidden panes, first mount, tab switch, repeated workspace activation, local PTY, and SSH/remote PTY. Screenshots alone never pass. -- [#6644](https://github.com/stablyai/orca/pull/6644) / [#6649](https://github.com/stablyai/orca/pull/6649) / [#6725](https://github.com/stablyai/orca/pull/6725) / [#6785](https://github.com/stablyai/orca/pull/6785) / [#6794](https://github.com/stablyai/orca/pull/6794) / [#6853](https://github.com/stablyai/orca/pull/6853) / [#6939](https://github.com/stablyai/orca/pull/6939): the fixes correctly proved pieces of the PTY geometry class: first-mount column desync, single-frame insufficiency, split-mount convergence, requested-size versus applied-size drift, mobile-owned PTY exceptions, visible split `0x0` fallback, and visible geometry reassertion after stable fit. The missed invariant was cross-layer terminal geometry authority. A visible desktop-owned terminal is not valid until xterm `cols/rows`, fit/proposed dimensions, applied PTY winsize, shell-visible `stty`/`stdout.columns`, and echo-wrap behavior converge after layout settles. Hidden/inactive panes may defer resize, and mobile/remote providers may intentionally diverge, but every transition into visible desktop ownership must converge. The next gate is a terminal geometry matrix with fault injection for first `0x0` fit, delayed layout settle, dropped/delayed resize acknowledgement, requested-size-versus-applied-size drift, SSH-style unknown applied size, hidden-to-visible activation, split create/collapse, restore, repeated workspace activation, and no redundant hidden SIGWINCH. -- [#6852](https://github.com/stablyai/orca/pull/6852) / [#6872](https://github.com/stablyai/orca/pull/6872) / [#6855](https://github.com/stablyai/orca/pull/6855), with [#6868](https://github.com/stablyai/orca/pull/6868), [#6856](https://github.com/stablyai/orca/pull/6856), and [#6857](https://github.com/stablyai/orca/pull/6857) as nearby WebGL/renderer-risk hardening: the fixes correctly moved toward safe wrappers for search dimensions, keyboard search navigation, link providers, WebGL addon patches, and renderer/GPU crash fallback. The missed invariant was xterm addon boundary containment. Optional addons are untrusted boundary code: search decorations, link providers, WebGL attach/reset/dispose, renderer fallback, hover callbacks, and stale async callbacks must not throw across the pane, React, or window boundary. The next gate is an xterm-addon-boundary harness that injects throwing addon behavior under active tabs, inactive tabs, visible and hidden splits, hidden-to-visible resume, floating tab switch, repeated workspace activation, and pane destroy/recreate; the oracle is pane-scoped addon disable/degrade with one breadcrumb, no React/window crash, and surviving focus, input, PTY echo, and rendering. - -Concrete gate shapes from the fresh passes: - -- **Snapshot freshness harness**: defer and reorder `listSessions()` / `listTerminals()` responses. Request a snapshot at `t0`, bind a new PTY at `t1`, resolve the `t0` response without the new PTY, and assert the pane survives with PTY id, tab ownership, and input/output proof. Then request a fresh `t2 > t1` snapshot for a genuinely killed PTY and assert teardown works. Run active/inactive tabs, visible/hidden splits, first-input probes, restore/reattach, repeat activation, and SSH/remote unknown-liveness variants. -- **Provider-session ownership matrix**: model provider-session ownership separately from terminal render output. For live, quit, active worktree-sleep, and completed worktree-sleep origins, prove each provider session is exactly one of owned, resuming, or intentionally retained. Cover active focused tabs, inactive hidden tabs, visible non-focused splits, delayed/missing/wrong hooks, duplicate legacy records, fresh-spawn failure, and repeat activation. Displayed replay must never count as ownership. -- **Old-version restore corpus**: keep immutable user-data fixtures from the last affected production versions, especially pre-#5232 session state. Boot current Orca against those fixtures and assert every persisted PTY/agent record is preserved, resumed once, warm-reattached once, or visibly marked unrecoverable. Include second restart after current Orca writes upgraded state. -- **Wayland platform liveness**: extend the Linux Wayland verifier so it proves terminal render, focus, keyboard input, PTY echo, scrollback movement, and GPU crash/stall absence across active/inactive tabs, visible/hidden splits, first mount, tab switch, restore, repeated activation, local PTY, and SSH/remote runtime. Screenshot-only evidence is not enough. -- **Terminal geometry convergence**: add an `assertTerminalGeometryConverged` oracle that compares xterm cols/rows, fit/proposed dimensions, applied PTY size when available, shell-visible `stty size` / `$COLUMNS`, and deterministic echo/wrap behavior. Inject dropped resize, delayed resize until visible, stale applied size, null SSH-style readback, initial `0x0`, and layout settling after more frames than old fixed budgets. -- **xterm addon boundary harness**: inject throwing search decorations, link providers, WebGL attach/reset/dispose, and stale async callbacks under active tab, inactive tab, visible split, hidden split, hidden-to-visible resume, floating tab switch, repeat activation, and pane destroy/recreate. Assert no `window.onerror` or React boundary hit, one structured breadcrumb, addon-scoped degradation, focus/input survival, PTY/output rendering, and WebGL fallback to DOM when needed. - -### Corrective PRs Show the Desired Direction - -The later fixes, especially [#6801](https://github.com/stablyai/orca/pull/6801), show the right shape: root-cause description, happens-before proof, explicit `auto-design-review-fix`, completeness verification, code-review loop, `brennan-test-changes`, and targeted tests for the stale snapshot/newborn binding race. [#6852](https://github.com/stablyai/orca/pull/6852), [#6853](https://github.com/stablyai/orca/pull/6853), and [#6855](https://github.com/stablyai/orca/pull/6855) also show good local invariant tests around xterm addon throws and PTY `0x0` sizing. - -The process improvement is to make that shape mandatory before the regression escapes, and to promote only the deterministic, red/green parts into blocking CI. - -## Core Pain Points - -### 1. Terminal Lifecycle Is Inferred Instead of Owned - -Many paths infer terminal state from a combination of pane visibility, provider session membership, stored pane layout, active leaf ids, sleeping-agent records, and xterm mount state. That makes it easy for a stale observation to tear down a fresh pane, or for a dead pane to look live. - -Common symptoms: - -- New tab opens and immediately closes. -- Hidden terminal exits but the visible pane later looks frozen and swallows input. -- Sleeping or hibernated agents get stranded in stale panes. -- Mobile and desktop disagree about which terminal/session still exists. - -### 2. PTY Size Authority Is Split Across Too Many Places - -`FitAddon`, `ResizeObserver`, deferred `requestAnimationFrame`, mobile-fit locks, hidden-pane guards, PTY locks, split layout equalization, and provider resize forwarding all compete to decide the active terminal size. - -Common symptoms: - -- PTY born at `0x0`. -- White split pane. -- First-mount column desync. -- TUI output wraps at the wrong width until manual resize. -- Mobile-size terminals fight desktop-size restoration. - -### 3. xterm Addons Are Unsafe Boundary Code - -The terminal surface trusts addon behavior too much. Recent failures came from search decorations, web-link providers, WebGL texture atlas resets, CJK/wide glyph rendering, and synchronized-output buffering. - -Any addon that can throw synchronously or produce invalid dimensions can take down the workbench unless Orca wraps it defensively. - -### 4. Provider Semantics Drift - -Local PTY, daemon PTY, SSH relay, remote runtime, WSL, Windows ConPTY, Linux Wayland, and mobile sessions do not share the same truth source for liveness, size, replay, cwd, shell, or reconnect behavior. - -Common symptoms: - -- Local-only liveness checks are accidentally applied to SSH/remote panes. -- Remote paths miss fixes made only in the renderer. -- WSL works through CLI add but not UI add. -- Daemon can be protocol-healthy while PTY spawn is degraded. - -### 5. Agent Identity Is Coupled to Terminal UI Details - -Agent status, sidebar rows, compact rows, mobile session tabs, sleeping-agent records, terminal helper identity, and pane reuse all share responsibility for "what agent is this?" - -Common symptoms: - -- Duplicate agent tabs. -- Stale sidebar rows after terminal close. -- Wrong agent icon/title. -- False running/completed notifications. -- Worktree switching changes agent ordering or ownership. - -Class-level invariant: - -- Agent/provider session replay must be ownership-safe. When code touches agent launch, restore, sleep, hibernate, dedupe, clearing, or workspace activation, it must prove Orca does not replay or resume a provider session id that is already owned, queued, pending, or live in that workspace. This applies across active and inactive tabs, visible and hidden panes, repeat activation of the same workspace, and session records whose origin is live, quit, or worktree sleep. -- Motivating example: the #6800 escape showed how code can conflate "this pane will connect right now" with "this pane/session owns this provider session." A valid inactive or hidden existing session can then look unowned, causing workspace activation to replay `agent resume ` instead of recognizing the session is already represented. The gate should protect the whole class, not only the #6800 path. -- Display evidence is not ownership evidence. Replayed terminal bytes, cold-restored screen snapshots, tab layout matches, and "will connect soon" state must never count as proof that a provider session id is already claimed. - -### 6. Upgrade and Startup Are Under-Tested as First-Class Flows - -Several failures only appear after an app update, daemon survival across versions, old persisted state, stale helper paths, or a first launch after migration. - -Common symptoms: - -- First launch after upgrade loses sessions. -- Daemon remains reachable but cannot spawn PTYs. -- App process runs but no window appears. -- Release packaging/notarization works at top level but fails for a nested binary. - -### 7. Reliability Fixes Can Regress Performance or Crash Safety - -Several reliability fixes add watchers, reconciliation loops, terminal replay, git scans, or richer diagnostics. Those are high-risk performance surfaces if they wake hidden panes, serialize large state, multiply subprocesses, or make cleanup scans wait for final results before showing progress. - -Common symptoms: - -- Terminal input becomes correct but slower under foreground TUI redraw or background PTY output. -- Hidden panes keep waking the renderer for data/title/spinner frames. -- Store subscribers or persistence writes serialize large terminal/worktree maps on common interactions. -- Git/worktree cleanup opens a modal that spins, duplicates rows, hangs on a subprocess, or crashes on Windows/WSL/SSH paths. -- Safety checks run unbounded git subprocesses or trust stale cleanup evidence before destructive removal. - -## Proposed Improvements - -### A. Build a Terminal Lifecycle State Machine - -Create a single source of truth for terminal lifecycle: - -- `newborn` -- `connecting` -- `live` -- `hidden-live` -- `exited-observed` -- `exited-unobserved` -- `hibernated` -- `restoring` -- `degraded` -- `closed` - -All teardown, restore, reconnect, hibernation, and liveness decisions should transition through this machine. Avoid having individual React effects independently infer lifecycle from partial state. - -### B. Make Liveness Reconciliation Epoch-Based - -Every pane/PTY binding should carry a monotonic renderer-local epoch or generation. Any async liveness snapshot must include the epoch it was requested against. - -Rules: - -- A snapshot requested before a bind cannot close that bind. -- A provider membership result can only prove death when the current binding still exactly matches the request context: pane id, provider id, connection id, PTY id, and epoch. -- Remote/SSH/WSL providers must supply provider-scoped liveness, or the reconciler must treat them as unknown. - -This generalizes the timestamp guard added for newborn terminal tabs and makes it harder to reintroduce the class. - -### C. Define a PTY Size Ownership Protocol - -Document and enforce one protocol for size authority: - -- Initial spawn size source. -- Hidden-pane spawn behavior. -- Mobile-fit lock behavior. -- Split layout settle behavior. -- Fallback size behavior. -- Provider resize acknowledgement. -- Applied PTY size reporting. -- Dropped, delayed, or skipped resize recovery. -- Hand-off from reconcile loop to live `ResizeObserver`. - -Add invariant tests that a visible terminal never remains `0x0`, and that xterm `cols/rows` converge with PTY-reported `cols/rows` under split, restore, reload, and mobile-fit scenarios. - -Class-level invariant: - -- A visible terminal pane must have one authoritative size path from layout measurement to xterm fit to provider resize to applied PTY size. `0x0` and unknown layout measurements are non-authoritative for visible panes. If a resize is skipped because of a mobile lock, PTY lock, hidden state, split layout settle, first mount, provider delay, or dropped acknowledgement, Orca must keep reconciling until the applied PTY size and xterm size converge or the pane enters an explicit degraded state. - -Required deterministic hooks: - -- Force first measurement to `0x0` during split-right spawn. -- Delay split layout settlement across frames. -- Hold and release mobile-fit or PTY locks while reconcile is active. -- Drop or delay one provider resize acknowledgement. -- Report applied PTY size separately from requested size. -- Switch hidden-to-visible or worktree-to-worktree while a resize is pending. - -Required oracle: - -- After the fault, assert visible xterm `cols/rows`, provider-reported applied PTY `cols/rows`, and process-observed `stdout.columns/rows` converge. -- Assert a width-sensitive TUI or long-line fixture renders without one-column wrapping. -- Assert typed input reaches the process and echoed output appears after convergence. -- Assert no test is promoted to blocking if it relies on blind `waitForTimeout()` instead of size/lifecycle events or controlled fault release. - -### D. Add Provider Contract Tests - -Start with a deterministic fake/fault-injection provider, then run the same lifecycle contract against real providers. - -The fake provider should be able to: - -- Delay and reorder `listSessions()` responses. -- Return `unknown` separately from empty/live/dead. -- Delay attach/replay and resize acknowledgements. -- Kill processes while panes are hidden. -- Drop or duplicate exit/replay/resize events. -- Simulate stale cwd/helper paths. - -After the fake provider is stable, run the contract against: - -- local PTY -- daemon PTY -- degraded daemon wrapper -- SSH relay -- remote runtime -- WSL path/shell combinations -- mobile session replay - -Required scenarios: - -- spawn -- hidden spawn -- visible restore -- hidden exit -- reconnect -- resize before/after mount -- cwd deleted -- helper binary moved after upgrade -- process dies while renderer is hidden - -### E. Create a Terminal Torture Harness - -Add a nightly and pre-release reliability suite that exercises: - -- rapid tab create/close -- agent create/close -- split right/down -- reload with split layout -- hidden-to-visible switching -- huge streaming TUI output -- CJK, Vietnamese, Arabic, and emoji input/output -- long paste, link paste, image paste -- browser/terminal focus handoffs -- hibernation wake -- daemon restart/degraded fallback -- mobile connect/disconnect during active terminal traffic - -This should record screenshots, terminal snapshots, PTY sizes, lifecycle transitions, and renderer CPU. - -Each scenario must also declare its oracle before it can be promoted beyond `experimental`: the observable pass condition, the failure artifact, and whether it is a discovery/stress run or a merge-blocking gate. Stress scenarios without deterministic oracles should stay nightly/pre-release only. - -### F. Wrap xterm Addons Behind Safe Adapters - -Search, links, WebGL, decorations, and input-protocol helpers should be treated as untrusted code: - -- Catch synchronous addon throws. -- Validate all rows/cols/decoration dimensions before calling xterm APIs. -- Disable or degrade an addon for a pane after repeated failures. -- Emit a structured breadcrumb with pane id, provider, terminal size, addon name, and sanitized error. - -Class-level invariant: - -- xterm addon failures must be contained to the addon and pane. A search, link, WebGL, decoration, input-protocol, or keyboard-navigation error must never unmount the terminal surface, crash the renderer, break focus, or stop typed input/output for the PTY. Invalid dimensions, wrapped-line ranges, zero columns, texture atlas resets, and synchronous callback throws should either be validated away before the addon call or caught and downgraded after the call. - -Required deterministic hooks: - -- Force zero or near-zero terminal columns. -- Force wrapped search and link ranges across line boundaries. -- Throw from search, link, decoration, and WebGL callback boundaries. -- Simulate WebGL texture/atlas reset or addon initialization failure. -- Invoke next/previous search navigation while the search addon reports an invalid match. - -Required oracle: - -- The terminal pane remains mounted and focused. -- The failing addon is disabled or downgraded only for the affected pane. -- A structured breadcrumb names the addon and sanitized failure. -- Typed input reaches the process and echoed output appears after the addon failure. - -### G. Add Upgrade Fixtures - -Keep serialized workspaces and daemon/session state from known old versions: - -- pre-#5232 session restore data -- stale daemon cwd -- stale node-pty helper path -- old hibernated agent records -- old mobile session snapshots -- old Windows shell settings - -CI should boot the current app against these fixtures and assert no session loss, no blank first pane, and no startup deadlock. - -Fixture rules: - -- Name the exact production version and build source. -- Store the pre-migration state immutably and copy it to a temp profile before boot. -- Document the expected pre-migration shape and post-migration behavior. -- Include stale daemon/helper/path state when that is the real user upgrade path. -- Treat silent blank replacement as a failure. If exact restoration is impossible, the accepted outcome must be an explicit recoverable/degraded state or user-facing migration notice, not silent session loss. - -### H. Add Platform Startup, Input, and Release Artifact Contracts - -Some failures look visually healthy while the product is broken. Platform contracts must prove the user action, not only the frame. - -Required contracts: - -- Terminal rendering/input: on Linux Wayland, Windows ConPTY, and macOS, assert the terminal renders, accepts typed input, echoes process output, scrolls, and preserves focus after GPU/renderer startup paths touched by the PR. -- IME/input: when terminal input handling changes, assert representative CJK, Vietnamese, Arabic, paste, and modifier paths at the lowest deterministic layer available. -- Startup/window lifecycle: packaged app reveals, close button and Alt+F4 work on Windows, startup watchdogs recover when `ready-to-show` stalls, and PATH/shell probes time out with a recoverable state. -- Release artifact: packaged builds include required nested binaries and pass signing/notarization checks, not only top-level app validation. -- Daemon degradation: daemon reachable-but-unable-to-spawn and stale helper/cwd states produce a visible degraded path instead of blank or frozen terminals. - -### I. Add an Agent Identity Contract - -Provider and terminal tests are not enough for agent/session identity drift. Add a contract for: - -- Pane reuse and tab close. -- Worktree switch and sidebar ordering. -- Sleep/wake and hibernation records. -- Provider session id capture and consumption. -- Mobile rows and desktop rows converging after terminal close. -- False running/completed notifications. -- Agent history/filter visibility for restored sessions. -- Provider session replay ownership: workspace activation, launch, restore, sleep/hibernate, dedupe, clearing, and reconnect code must not issue a second resume/launch for a provider session id already owned by an active tab, inactive tab, visible pane, hidden pane, queued pane, pending connection, or live session in the same workspace. -- Session record origins: the same invariant must be proved for live sessions, quit-restored sessions, and worktree-sleep records, including repeat activation of an already-active workspace. - -Required agent identity scenarios: - -- Activate a workspace with an existing visible live agent pane; assert no duplicate `resume ` is queued. -- Activate a workspace with an existing hidden/inactive live agent pane; assert the provider session is recognized as represented even before the pane visibly reconnects. -- Re-activate the same workspace repeatedly while agent restore/session discovery is pending; assert the pending ownership claim dedupes all replay attempts. -- Restore a quit-origin session and a worktree-sleep-origin session; assert each is claimed once, survives hidden-to-visible transitions, and does not produce duplicate sidebar/mobile rows. -- Clear or close stale panes while a same-session replacement is queued or pending; assert cleanup cannot erase the newer ownership claim or trigger a second replay. - -### J. Introduce Reliability Gates for Terminal/Session PRs - -For PRs touching terminal, agent status, sessions, startup, provider routing, or hibernation, require: - -- Provider matrix explicitly listed. -- Hidden-pane behavior listed. -- SSH/remote behavior listed. -- Windows/WSL behavior listed when shell, cwd, path, ConPTY, or process launch is touched. -- A regression test that fails against the old behavior. -- A manual or automated stress command if the change touches timing. -- PTY size authority proof when the PR touches terminal spawn, split layout, hidden/visible transitions, mobile fit, resize forwarding, PTY locks, provider resize acknowledgement, or xterm fit. The proof must cover `0x0` quarantine, requested versus applied PTY size, xterm/process size convergence, and a user-visible input/output oracle. -- Agent/provider replay ownership proof when the PR touches agent launch, restore, sleep/hibernate, dedupe, clearing, workspace activation, pane reuse, provider session id capture, sidebar/mobile agent rows, or session history. The proof must cover active versus inactive tabs, visible versus hidden panes, repeat workspace activation, and live, quit, and worktree-sleep session record origins. -- xterm addon containment proof when the PR touches search, links, WebGL, decorations, keyboard terminal shortcuts, synchronized output, or xterm addon registration. The proof must show addon throws and invalid geometry cannot crash the terminal surface, unmount the pane, or break typed input/output. -- The terminal/session checklist should explicitly ask: "Can this change make an already represented provider session look unowned, or replay/resume it a second time?" -- The terminal/session checklist should explicitly ask: "Can this change make a visible pane trust `0x0`, stale requested size, or renderer-only size instead of the size the PTY actually applied?" -- The terminal/session checklist should explicitly ask: "Can an xterm addon callback or invalid addon geometry throw through the terminal surface instead of being contained?" - -### K. Make Reliability Tests Predictable and Useful - -The goal is not "more tests." The goal is a small set of executable contracts that protect named invariants from real failures. - -Required shape for every new reliability gate: - -- Name the invariant it protects. -- Name the real issue, PR, or failure mode that motivated it. -- Prove red/green: the gate must fail against the old behavior, a regression fixture, or a small intentionally broken variant before it becomes a trusted blocker. -- Attach a promotion record: invariant id, motivating issue/PR, old-fail evidence, new-pass evidence, exact command, failure output or CI link, deterministic wait condition, owner, runtime budget, and demotion rule. -- Pick the cheapest deterministic layer: pure state-machine or provider-contract tests first, integration tests second, Electron/UI tests only for the small number of golden paths that require the real app shell. -- Avoid sleeps as proof. Wait for observable state, protocol events, lifecycle transitions, PTY size convergence, replay completion, or process exit. -- Record why the test is allowed to block merges: expected runtime, flake history, covered invariant, and owner. - -Test maturity levels: - -- `experimental`: local or nightly only; useful for shaking out the harness, not allowed to block merges. -- `soak`: runs repeatedly in CI but non-blocking; collects flake rate, runtime, and whether failures are actionable. -- `blocking`: promoted only after red/green proof, stable runtime, and low flake rate. - -Flake policy: - -- Blocking promotion should require numeric evidence, for example 100 consecutive soak runs or 14 days across required CI platforms, p95 runtime within the gate budget, and zero unexplained flakes. Tune the numbers later, but do not leave "stable" undefined. -- A blocking reliability test that flakes above the agreed threshold should be demoted or fixed quickly; rerun-only culture hides weak gates. -- CI should quarantine or demote a blocking test automatically when it exceeds the threshold over the configured window, open or update an owner issue, and require a fresh soak window before re-promotion. -- A flaky test should be treated as a product or harness bug with an owner, not as background noise. -- If a scenario cannot be made deterministic yet, keep it as soak/manual evidence and do not let it become required CI until the signal is trustworthy. - -Deletion policy: - -- Delete or demote tests that do not map to an invariant, duplicate a stronger gate, assert implementation details instead of behavior, only fail from timing/environment noise, or require blind sleeps. -- Prefer one high-signal contract test over several UI tests that all exercise the same happy path. -- Track regression coverage by invariant, not by raw test count. -- Maintain a reliability-gates manifest in the repo. Each gate should list invariant id, motivating issue/PR, layer, owner, maturity, runtime budget, flake history link, promotion record, and replacement gate if deprecated. Tests without current metadata should fail review or be demoted. - -How to know the program is working: - -- Every recent P0/P1 reliability class maps to an invariant and an executable gate or explicitly accepted gap. -- New gates carry red/green evidence. -- Blocking gate flake rate and runtime stay within budget. -- Future incidents identify either a missing invariant, a failed gate, or an explicit accepted gap instead of requiring archaeology. -- Accepted gaps have an owner, reason, affected platforms/providers, issue link, and expiry date. Expired gaps should block future high-risk PRs until renewed or closed. -- The dashboard tracks touched high-risk PRs, gates required, gates implemented, accepted gaps by age/owner, promotions, demotions, and escaped regressions by invariant. - -### L. Make Performance and Git Crash Safety First-Class Gates - -Correctness gates must include a performance budget when the fix touches terminal throughput, hidden panes, store subscribers, persistence, startup, polling, git/worktree scans, cleanup modals, subprocesses, SSH/WSL providers, or Windows git paths. - -Required performance contract: - -- Name the user-visible performance symptom that could regress: typing latency, scroll/resize jank, terminal throughput, startup delay, modal loading, CPU, memory, subprocess churn, or crash/reload. -- Name the resource being protected: renderer frame time, event-loop delay, PTY IPC count, xterm writes, serialized bytes, subprocess count, file descriptors, WebGL contexts, webviews, or retained buffers. -- Add a before/after measurement or deterministic count/leak test. Do not rely on "felt faster." -- Keep foreground input echo and control responses latency-sensitive while batching background or high-volume redraw streams. -- Prove hidden snapshot-capable panes do not wake the renderer per PTY chunk/title frame and restore from main-owned buffers when shown. -- Preserve `orca terminal read` pagination, cursor metadata, bounded preview behavior, partial-line handling, truncation flags, and total counts when changing terminal buffering. -- Prove subscriber, persistence, and publication paths avoid unchanged large slices and record serialized byte size when persisted payloads are touched. -- Keep stress/torture runs non-blocking until they have numeric runtime and flake evidence. - -Required git crash/perf contract: - -- Bound every git subprocess in scan/cleanup paths with a timeout or abort signal, and kill the process tree where applicable. -- Use bounded concurrency and in-flight dedupe; avoid one subprocess per repo x worktree x polling tick. -- Stream progress incrementally to the renderer and remove IPC listeners in `finally`. -- Verify close/reopen/refresh during active or just-settled scans clears loading state, preserves rows, and does not duplicate progress. -- Recheck cleanup candidates immediately before destructive delete, including dirty files, unpushed commits, unknown upstream/base, folder repos, main worktrees, pinned workspaces, running terminals, disconnected SSH, and WSL paths. -- Keep local, WSL, and SSH git providers at parity for timeouts, aborts, errors, and path handling. -- On Windows-risky cleanup changes, validate a real Electron flow when feasible: progress appears before final completion, close/reopen recovers, a clean old worktree delete does not crash, and no orphan Electron/git process remains. - -Promotion rule: - -- A reliability fix cannot become a blocking gate if it makes the product slower or crashier. The promotion record must include the perf command or measurement, budget, result, platform/provider scope, and any accepted perf gap. - -### M. Make the Factory Enforce Those Gates - -The factory should turn the reliability gate into default behavior for planning, review, and merge-confidence testing. This should be treated as part of the prevention plan, not as a substitute for the actual test harnesses and CI gates above. - -Factory changes to carry forward: - -- `brennan-yolo-lite` should require a Reliability regression gate in the design doc whenever terminal, tab, session, provider, startup, upgrade, persistence, packaging, shell, path, or release behavior is touched. -- The gate should force the agent to classify touched P0-capable surfaces: terminal lifecycle, tab creation/binding, dead-session reconciliation, hidden-to-visible resume, PTY identity/routing, PTY geometry including `0x0` quarantine, xterm addon boundaries, IME/paste/redraw behavior, local/daemon/SSH/remote/WSL/mobile provider contracts, attach/replay/resize/exit ordering, startup/upgrade persisted state, daemon survival, renderer/GPU crash containment, packaging/notarization, shell/PATH probing, terminal throughput, hidden-pane output, store subscriber hot paths, persistence payload size, git/worktree scan subprocess churn, cleanup modal progress, and Windows/WSL/SSH git crash risk. -- Completeness verification should block when a touched P0-capable surface has no implemented or explicitly accepted reliability gate. -- `review-code` should treat missing reliability tests or validation gates as findings for these surfaces, with the same review weight as lifecycle, provider, or cross-platform regressions. -- `review-code` should invoke the `perf` skill for slow UI, terminal throughput, high CPU, memory/resource leaks, polling, subprocess churn, startup latency, render churn, or persistence writes. -- `review-code` should invoke the `git-crash-perf` skill for worktree cleanup, git scans/status/listing, destructive removal, cleanup modal progress, SSH/WSL git providers, subprocess timeouts, or Windows git crash risk. -- `brennan-test-changes` should run or explicitly justify the relevant terminal torture, provider contract, startup/upgrade, persisted-state, release, cross-platform, perf, or git crash/perf matrix subset. - -Current implementation status: - -- Internal factory skill update PR: https://github.com/stablyai/orca-internal/pull/491 -- Scope of that PR: add the reliability gate to `brennan-yolo-lite` and expand `review-code` to inspect the recent terminal/session/provider/startup/release failure classes. -- Follow-up needed: once the concrete repo-side harnesses exist, update the skills again to name the exact commands and required CI jobs instead of only naming the expected reliability evidence. - -Additional factory follow-up: - -- `auto-design-review-fix`: treat missing reliability gates for P0-capable surfaces as design findings; require inverse invariants for cleanup/reconcile/init code. -- `review-code-fix-loop`: require the final loop report to name the touched reliability classes, invariant, gate, and whether both reviewers addressed them. A clean review that never discusses the required reliability class should not count as clean. -- `brennan-test-changes`: add merge-confidence sub-gates for stale snapshot/newborn binding races, hidden-to-visible resume, attach/replay/exit ordering, old-version persisted state, provider/platform matrices, and render-live/input-live checks. -- `pr-review`: classify P0-capable surfaces before review/fix and require the same invariant/test/platform matrix before approval. -- `yolo-lite` and `electron`: require terminal/runtime validation to prove input, scroll, focus, process output, and relevant platform behavior, not only screenshots or visible frames. -- `playwright-reliability-tests`: add a focused skill for writing and reviewing Playwright tests that are evidence-based, low-flake, and tied to named invariants, so browser/Electron tests do not become more noisy checklist coverage. -- `perf`: require measurement-backed review for terminal throughput, hidden pane output, store subscribers, persistence, startup, polling, subprocess churn, resource leaks, and memory growth. -- `git-crash-perf`: require bounded subprocesses, progress streaming, close/reopen recovery, fresh delete preflight, provider parity, and Windows Electron validation for git cleanup/worktree changes. - -### N. Add Pane Lifecycle Telemetry and Diagnostics - -Each terminal pane should be able to emit a compact lifecycle trace: - -- pane id, tab id, worktree id -- provider and connection id -- pty id and binding epoch -- visibility transitions -- spawn/connect timestamps -- resize measurements and forwarded sizes -- liveness snapshot request/response times -- teardown reason -- hibernation/restore reason - -This will make future reports much easier to attribute without guessing. It should also be test-consumable: CI should be able to fail on forbidden transitions, stale close attempts, stuck `0x0`, missing input echo, and startup watchdog expiry. - -## Attribution Methodology - -"Who caused it?" is not reliably answerable from labels alone. This review separates: - -- **Confirmed**: the issue body or fix PR explicitly names the introducing PR/change. -- **Likely**: the fix PR identifies the failing subsystem and local git history shows the regressing mechanism was introduced by a specific PR. -- **Area ownership**: the same people are repeatedly fixing or modifying the subsystem, but there is not enough evidence to say they caused a given issue. -- **Systemic**: no single introducer is evident; the problem is a missing invariant, provider contract, or test harness. - -## Attribution Findings - -### Confirmed Regression Chains - -- [#5356](https://github.com/stablyai/orca/issues/5356), session loss on first launch after upgrading from 1.4.65 to 1.4.68, explicitly names PRs [#5234](https://github.com/stablyai/orca/pull/5234) and [#5240](https://github.com/stablyai/orca/pull/5240). Local git history shows both PRs were authored by Jinwoo Hong. Count: Jinwoo 1 confirmed open `P0` regression chain. -- [#5319](https://github.com/stablyai/orca/issues/5319), Linux/Wayland terminal input freeze, is closed, but the fixing PR [#6557](https://github.com/stablyai/orca/pull/6557) says the Wayland-aware GPU path had been removed in [#1344](https://github.com/stablyai/orca/pull/1344). Local git history shows #1344 was authored by Neil. Count: Neil 1 confirmed closed `P1` regression chain. - -### Likely Regression Chains - -- [#6773](https://github.com/stablyai/orca/issues/6773), new terminal/agent tabs opening and immediately closing, was fixed by [#6796](https://github.com/stablyai/orca/pull/6796) and [#6801](https://github.com/stablyai/orca/pull/6801). Those PRs identify the dead-session reconciler as the cause. Local git history shows [#6514](https://github.com/stablyai/orca/pull/6514), which introduced the focused dead-session reconcile module for hidden exited panes, was authored by Brennan Benson. Count: Brennan 1 likely open `P0` regression chain. - -### Area Ownership Without Clear Causation - -The recent terminal reliability churn is concentrated among Brennan, Neil, and Jinwoo because they authored many of the fixes and nearby changes. That does not mean each issue was caused by them. It does mean the riskiest ownership surfaces are: - -- Terminal lifecycle/reconcile/session persistence: Brennan, Neil, Jinwoo. -- PTY size/layout/reflow: Neil, Jinjing, Jinwoo, Brennan. -- Daemon/degraded spawn/focus/remote lifecycle: Neil, Jinwoo. -- Windows terminal/shell/ConPTY/GPU recovery: Brennan, Neil, Jinwoo. -- Agent status/sidebar/session identity: Brennan, Neil, Jinwoo, Jinjing. - -### Current Conservative Count - -For currently open `P0`/`P1` issues with a clear or likely PR-caused chain: - -- Jinwoo: 1 confirmed open `P0` regression chain. -- Brennan: 1 likely open `P0` regression chain. -- Neil: 0 confirmed open `P0`/`P1` chains from the evidence pulled, plus 1 confirmed closed `P1` chain. - -Most open `P0`/`P1` issues do not identify an introducing PR. Treating those as individual blame would be misleading. The stronger conclusion is that Orca has systemic reliability gaps around terminal/session lifecycle, provider contracts, PTY sizing, and upgrade/startup flows. - -## Recommended Next Steps - -Implementation plan: [`docs/reference/reliability-gates-implementation-plan.md`](./reliability-gates-implementation-plan.md). - -1. Deduplicate and update `P0` issues #6873/#6874 and #6773 with the suspected fix status. -2. Add narrow red/green gates for known escapes first: newborn liveness race, PTY `0x0`, xterm addon throw containment, old-version upgrade fixture, and Linux Wayland render-live/input-live smoke. -3. Define test maturity levels, promotion records, numeric flake/demotion policy, and a reliability-gates manifest before making new reliability gates blocking. -4. Build the fake/fault-injection provider contract kit, then run the same contract against local and daemon PTY before expanding to SSH, WSL, remote runtime, and mobile. -5. Add upgrade/startup/release fixtures for pre-#5232 session data, stale daemon/helper paths, packaged app reveal/close, nested notarization, PATH probe timeout, and Windows terminal activation. -6. Add the terminal lifecycle state machine design doc and implement it incrementally behind the known escaped-regression gates. -7. Formalize the PTY size protocol and make `0x0` a quarantined/non-authoritative state for visible panes. -8. Add the agent identity contract for pane reuse, worktree switch, sidebar/mobile rows, provider session ids, stale notifications, and ownership-safe provider session replay across active/inactive tabs, visible/hidden panes, repeated activation, and live/quit/worktree-sleep records. -9. Add performance budgets and measurements to reliability gates that touch terminal throughput, hidden panes, store subscribers, persistence, startup, polling, subprocess churn, git/worktree scans, cleanup modals, SSH/WSL git providers, or Windows git crash risk. -10. Gate terminal/session PRs behind the reliability checklist above, including perf and git crash/perf checks when those surfaces are touched. -11. Keep the factory skill update as the enforcement layer, add the Playwright reliability-testing skill, and make `perf` plus `git-crash-perf` mandatory companion skills for relevant PRs; then revise companion skills once exact repo commands and CI job names exist. -12. Add pane lifecycle traces so future issues can be attributed from evidence instead of archaeology. diff --git a/docs/reference/ssh-gssapi-kerberos.md b/docs/reference/ssh-gssapi-kerberos.md deleted file mode 100644 index e7594151667..00000000000 --- a/docs/reference/ssh-gssapi-kerberos.md +++ /dev/null @@ -1,39 +0,0 @@ -# Kerberos / GSSAPI SSH Authentication - -Orca's ssh2-based SSH stack cannot speak `gssapi-with-mic` — the ssh2 library -has no GSSAPI userauth support, and adding it would mean forking ssh2's -protocol layer plus shipping the `kerberos` native module (MIT krb5 / Heimdal / -Windows SSPI) as a prebuilt Electron dependency on three platforms. Instead, -hosts that need Kerberos ride the existing **system OpenSSH transport** — the -same parallel transport already used for `ProxyCommand`/`ProxyJump` hosts — -which delegates GSSAPI, ticket lookup (`kinit` cache, Windows domain logon), -and credential delegation to the platform's own `ssh` binary on macOS, Linux, -and Windows (Win32-OpenSSH uses SSPI). - -## Transport selection - -Two tiers, deliberately asymmetric because RHEL-family distros ship -`GSSAPIAuthentication yes` in the global `/etc/ssh/ssh_config`, which makes -`ssh -G` report GSSAPI enabled for *every* host: - -1. **Proactive** — a target with `gssapiAuthentication: true` (parsed from an - explicit `GSSAPIAuthentication yes` in the host's `~/.ssh/config` block, or - set on the target directly) tries the system-ssh probe first. If that fails - (e.g. no ticket), the connect falls through to the normal ssh2 key/agent - path where passphrase/password prompts remain available — OpenSSH semantics - allow other auth methods alongside GSSAPI. -2. **Auth-failure fallback** — when ssh2 exhausts key/agent auth and the - `ssh -G`-resolved config enables GSSAPI (`isGssapiSystemSshFallbackCandidate` - in `ssh-connection-utils.ts`), the connection retries over system ssh - *before* prompting the user for credentials. Kerberos-only hosts on - distro-default configs connect this way; hosts where keys work never leave - the ssh2 path. - -Manual (non-ssh-config) targets flagged for GSSAPI get an explicit -`-o GSSAPIAuthentication=yes` in `system-ssh-args.ts`; config-backed targets -inherit the option from their own `Host` block since the system binary re-reads -ssh_config. - -Both tiers work headless (`orca serve`): the system-ssh probe needs no -credential callbacks, and GSSAPI itself is non-interactive once a ticket -exists. diff --git a/docs/reference/ssh-typing-latency-under-relay-load.md b/docs/reference/ssh-typing-latency-under-relay-load.md deleted file mode 100644 index f7ae8e0b3a7..00000000000 --- a/docs/reference/ssh-typing-latency-under-relay-load.md +++ /dev/null @@ -1,115 +0,0 @@ -# SSH Typing Latency Under Relay Load - -Date: 2026-07-06 - -## Symptom - -Typing in an SSH-host terminal can become extremely slow (hundreds of ms to -seconds per echoed keystroke) while other SSH work is active — file previews, -source-control refresh, search. A quiet SSH shell stays snappy, which made the -reports look unreproducible. - -This is distinct from the local warm-switch lag documented in -`terminal-switch-typing-lag-investigation.md` (daemon `listSessions()` -snapshot cost — already fixed; `TerminalHost.listSessions()` is now -metadata-only via `getAppliedSize()`). - -## Root Cause - -The relay and the Electron client share ONE ordered SSH channel for all -JSON-RPC traffic: PTY input/output, file streams, git responses, search, port -scans. Two mechanisms turned bulk traffic into typing latency: - -1. **Relay-side head-of-line blocking (primary).** The `fs.readFileStream` - pump (`src/relay/fs-handler-file-read.ts`) wrote every 256KB chunk (~340KB - framed) into the relay's stdout as fast as local disk reads completed, - ignoring the `write() === false` backpressure signal. A 10MB preview - enqueued ~13.6MB into the pipe at once; a `pty.data` echo emitted - mid-stream queued behind ALL of it. At 2MB/s WAN that is multiple seconds - of echo delay per open file. The reproduced measurement: 4,195,592 bytes - (the entire remaining 3MB test file, framed) queued ahead of one echo - frame. - -2. **Client-side O(n²) frame buffering (secondary).** `FrameDecoder.feed()` - in both `src/main/ssh/relay-protocol.ts` and `src/relay/protocol.ts` did - `Buffer.concat([buffered, chunk])` per data event, re-copying the whole - backlog for every ~32KB TCP chunk — ~2MB of memcpy per 340KB frame, - ~80MB per 10MB file — on the Electron main thread, delaying `pty:write` - IPC dispatch and echo delivery to the renderer. - -## Fix - -- **Bulk lane with sink backpressure** (`RelayDispatcher.notifyBulk`): - `fs.streamChunk` frames are serialized per client and each send waits for - the sink's `drain` when `write()` returns `false`. Interactive frames - (`pty.data`) still use plain `notify()` and are admitted immediately — - because the bulk lane keeps the outbound buffer at ~1 frame, an echo jumps - ahead of every not-yet-admitted chunk. Sinks (`relay.ts` stdout + Unix - socket clients) surface `write()`'s boolean and a one-shot - `waitWriteDrain`; every death path (EPIPE, stdin end, detach, dispose) - flushes parked waiters so a pump can never hang on a dead pipe. - -- **Credit-based flow control** (`fs.streamAck`): the client requests - streams with `flowControl: 'ack'` and acks each processed chunk; the relay - caps unacked chunks at `STREAM_ACK_WINDOW_CHUNKS = 4` (~1MB raw). This - bounds in-flight bulk bytes even past the relay's own pipe (sshd/TCP - buffers) and paces the relay's base64/JSON encode loop so incoming - keystroke frames get event-loop turns. A parked pump wakes on ack, cancel, - release, client detach, and a 1s staleness recheck. - -- **Cross-version compatibility**: old client + new relay → no - `flowControl` param → legacy unpaced pump (still drain-bounded, which is - transparent). New client + old relay → `fs.streamAck` is an unknown - notification and is silently ignored. `STREAM_CHUNK_SIZE` (256KB) is - intentionally unchanged — both sides bake it into chunk offset math, so - changing it would corrupt cross-version streams. - -- **FrameDecoder rewrite** (both mirrored protocol files): chunk-list - buffering; each frame is assembled exactly once (one copy) instead of - re-concatenating the backlog per feed. - -## What Stays Fast / Unchanged - -- Relay PTY output batching (interactive echo fast path in - `src/relay/pty-handler.ts`) is untouched. -- No `listSessions()` / provider inventory was added anywhere near typing, - focus, switch, resume, resize, or render paths. -- Remote file streaming correctness: per-chunk length checks, chunk-count and - byte-count invariants, cancel paths, and the 12MB round-trip integration - test all pass unchanged. - -## Regression Coverage - -- `src/relay/fs-stream-pty-echo-backpressure.integration.test.ts` — real - mux ↔ dispatcher ↔ FsHandler over a congestible in-memory pipe; asserts - deterministic BYTE bounds (not wall-clock): echo queues behind < 2 framed - chunks when congested (pre-fix: whole file), ack window caps in-flight - chunks, legacy no-ack clients still get complete streams. -- `src/relay/fs-handler-stream.test.ts` — pump parks at the ack window, - resumes per ack, and releases its file handle when cancelled while parked. -- `src/relay/dispatcher.test.ts` — notifyBulk semantics: drain gating, - per-client targeting, dispose releases parked senders, interactive - notify() not gated behind a stalled bulk lane. -- `src/main/ssh/relay-protocol.test.ts` — decoder byte-at-a-time boundary - straddling, oversized-frame resync across odd chunk sizes, and a - no-`Buffer.concat`-during-feed guard that locks out the O(n²) shape. -- `tests/e2e/ssh-docker-relay-perf.spec.ts` — new "busy relay" scenario: - types while two 8MB remote file-read loops and a git.status loop run; - budgets median < 500ms, worst < 2000ms. (On loopback Docker the pre-fix - HOL is only tens of ms — the in-process byte-bound test is the - authoritative red/green; the e2e guards the end-to-end wiring.) - -## Residual Gaps - -- **Large single-frame responses**: `git.*` responses (stdout capped at - `MAX_GIT_BUFFER` = 10MB → ~13MB framed) and other request/response results - are one atomic frame on the wire; a huge diff can still delay an echo by - its own transfer time. Fixing this requires response chunking (a protocol - change); bounded by the 16MB `MAX_MESSAGE_SIZE`. -- **Outbound direction**: a large `fs.writeFile` request frame (client → - relay) can queue ahead of keystroke `pty.data` frames on `channel.stdin`. - Same shape, opposite direction, rarer trigger (saving a large remote file - while typing). -- `pty.ackData` flow control for PTY output remains unenforced - (`src/relay/pty-handler.ts` — "not yet enforced"); PTY output floods are - already paced by the relay's 8ms batch/16KB slice scheduler. diff --git a/docs/reference/telemetry-availability.md b/docs/reference/telemetry-availability.md deleted file mode 100644 index e6d4924d699..00000000000 --- a/docs/reference/telemetry-availability.md +++ /dev/null @@ -1,393 +0,0 @@ -# Telemetry Availability - -Append-only reference for dashboard authors. Use this file to answer: "From what date is this event or property trustworthy enough to chart?" - -This file records operational availability. Event contracts still live in `src/shared/telemetry-events.ts`, and dashboard/query examples live with the dashboard work that introduced them. - -## How To Use - -- Prefer `dashboard_ready_at_utc` as the lower bound for saved PostHog retention, funnel, and cohort tiles. -- If `dashboard_ready_at_utc` is `TBD`, do not save production cohort or retention tiles. QA/exploratory charts may proceed only after checking first-seen evidence, and their descriptions must say the boundary is not final. -- Use the latest required first-valid timestamp when a dashboard depends on multiple signals. An earlier timestamp may prove one event exists while a join key or depth field is still absent, which silently biases cohorts. -- Do not save a cohort chart from one isolated first-seen row. -- Mention rollout boundaries in saved insight descriptions when querying across pre-rollout data. -- Do not add these timestamps to telemetry payloads. They are repo-side interpretation metadata. - -## Boundary Fields - -| Field | Meaning | -| ------------------------ | -------------------------------------------------------------------------------------------------------------------------- | -| `code_merged_at_utc` | When the implementation reached `main`. Useful for source history only. | -| `first_released_at_utc` | First release commit/build that contains the code. This is the earliest possible user exposure. | -| `first_seen_at_utc` | First observed PostHog row for the event/property. This proves at least one event flowed. | -| `dashboard_ready_at_utc` | Earliest lower bound for saved dashboards after all required signals have first-valid rows and pass any validation window. | - -First-valid means the released app version has been observed, the required event/property fields are populated, joins used by the dashboard are available, and any validation window listed in the rollout entry has passed. - -D1+/D3+/D7+ retention means the user fired `app_opened` at least once after 24/72/168 hours from cohort start, matching the existing dashboards' mature-denominator logic. Do not read D3/D7 as exactly-on-day returns, and do not include users whose cohort start is too recent to satisfy the relevant window. - -## Cross-Cutting Rules - -- `app_opened` is the return marker. It fires once per app session after telemetry consent/gating resolves. -- `nth_repo_added = 0` means the user had no repos at emit time. It is not a missing-value sentinel. -- Missing `nth_repo_added` means the classifier failed soft or the event predates the property. Do not bucket it with `0`. -- Main injects `nth_repo_added` only into schemas that declare the field, and injects onboarding `cohort` only into schemas that declare `cohort`. -- `onboarding_started { cohort: 'fresh_install' }` is the fresh-install anchor. `upgrade_backfill` rows should not be mixed into new-user onboarding funnels. -- Classify a user's fresh-install onboarding cohort from the first `onboarding_started { cohort: 'fresh_install' }` and carry that forward. Existing-user classification can flip after live completion is persisted, so later event-level `cohort` values are not a replacement for the start anchor. -- Numeric onboarding step values are rollout-specific. Prefer `value_kind` when the relevant event/property is available. -- Current `onboarding_step_skipped` means an optional preference/setup step was skipped toward required project setup. It does not mean the user abandoned repo setup. -- `source = 'unknown'` is not a real product surface. It means the caller omitted a source or the value failed schema validation. -- `workspace_created` means create-worktree IPC succeeded. It is not a general "usable workspace exists" or "workspace revealed" marker. -- `add_repo_setup_step_action` is a historical post-add choice-screen event. Current Add Project flows auto-open the default checkout or reveal the project row, so absence of this event after the default-checkout handoff rollout is expected and should not be read as setup abandonment. -- `add_repo_existing_workspaces_detected` is a detection signal for migration opportunity, not proof that a user chose an existing workspace. Current Add Project flows may emit it before automatically opening the default checkout. -- `add_repo_default_checkout_handoff.result = 'revealed_project'` means Orca could not confidently open the default checkout and instead revealed the newly added project row. It is the direct fallback metric for the 2026-06-03 Add Project handoff rollout. -- `agent_started` means PTY spawn succeeded with agent telemetry attached. It is not first-repo activation and does not prove the user sent a prompt. -- `agent_prompt_sent` means a live agent hook observed an explicit non-empty user prompt. It excludes hydrated/replayed status, agent auto-start, bare shells, draft prefill, and hookless sessions; missing rows mean no hook-confirmed interaction was observed, not proof the user never typed. -- Workspace-outcome joins are native Electron coverage unless the query explicitly proves remote/web instrumentation. Remote runtime and web paths can bypass native repo/worktree telemetry, so do not interpret missing workspace outcome rows as product drop-off for SSH, remote, or web users. - -## Rollouts - -### 2026-05-08 - Repo Cohort Property - -Scope: `nth_repo_added` on repo/activation/retention events. Current schemas declare it on `app_opened`, `repo_added`, `add_repo_setup_step_action`, `add_repo_existing_workspaces_detected`, `add_repo_default_checkout_handoff`, `workspace_created`, `workspace_create_failed`, `setup_script_prompt_shown`, `setup_script_prompt_action`, `agent_started`, `agent_prompt_sent`, and `agent_error`. The original rollout covered `app_opened`, `repo_added`, `add_repo_setup_step_action`, `workspace_created`, `workspace_create_failed`, `agent_started`, and `agent_error`; later events have their own first-seen timestamps below. - -| Field | Value | -| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| PR | `#1591` | -| Merge commit | `002e2acc38cad262d694049cc86c0962ffd381ce` | -| `code_merged_at_utc` | `2026-05-08T17:50:44Z` | -| First release | `v1.3.42-rc.1` | -| First release commit | `b6dab85aaa0e9b600ec93846824ba815542a1e1e` | -| `first_released_at_utc` | `2026-05-08T17:56:35Z` | -| Earliest `first_seen_at_utc` | `2026-05-08T18:40:00.354Z` on `app_opened` | -| `dashboard_ready_at_utc` | Event-dependent. Use `2026-05-08T18:40:00.354Z` for general `app_opened` repo-count cuts; use `2026-05-08T20:01:06.364Z` or later for first-repo activation cuts requiring `repo_added`. | - -PostHog evidence checked at `2026-05-23T23:34:32Z`: - -| Event | First seen with `nth_repo_added` | -| --------------------------------------- | ------------------------------------------ | -| `app_opened` | `2026-05-08T18:40:00.354Z` (`1.3.42-rc.1`) | -| `workspace_created` | `2026-05-08T19:15:06.913Z` | -| `agent_started` | `2026-05-08T19:15:07.130Z` | -| `agent_prompt_sent` | `TBD` | -| `repo_added` | `2026-05-08T20:01:06.364Z` (`1.3.42`) | -| `add_repo_setup_step_action` | `2026-05-08T20:01:20.897Z` | -| `workspace_create_failed` | `2026-05-08T23:04:32.315Z` | -| `agent_error` | `2026-05-13T14:13:16.096Z` | -| `add_repo_existing_workspaces_detected` | `2026-05-20T05:33:20.160Z` | -| `add_repo_default_checkout_handoff` | `TBD` | -| `setup_script_prompt_shown` | `2026-05-21T04:09:04.231Z` | -| `setup_script_prompt_action` | `2026-05-21T04:10:31.616Z` | - -Dashboard caveats: - -- Earlier rows do not have `nth_repo_added`; show them as pre-rollout or exclude them from repo-count cohorts. -- A dashboard that depends on a specific event must use that event's first-seen timestamp, not the earliest `app_opened` timestamp. -- `repo_added nth_repo_added = 1` is the first-repo activation marker. -- `add_repo_setup_step_action` is only representative of the old post-add choice-screen funnel. After the 2026-06-03 default-checkout handoff change below, Add Project no longer shows that choice screen during the normal Git add/clone/create/onboarding paths. - -### 2026-05-09 - Onboarding Cohort Injection - -Scope: `cohort` on onboarding events. Current schemas declare it on `onboarding_started`, `onboarding_step_viewed`, `onboarding_step_completed`, `onboarding_step_skipped`, `onboarding_tour_outcome`, `onboarding_step4_path_clicked`, `onboarding_step4_path_failed`, `onboarding_task_sources_snapshot`, `onboarding_windows_terminal_snapshot`, `onboarding_completed`, `onboarding_dismissed`, `onboarding_agent_picked`, onboarding import/setup events, `onboarding_feature_setup_toggled`, `onboarding_feature_setup_run`, `onboarding_feature_setup_terminal_opened`, and `onboarding_feature_setup_terminal_interacted`. See `src/shared/telemetry-events.ts` for the exact current roster. - -The original `#1608` rollout covered `onboarding_started`, `onboarding_step_viewed`, `onboarding_step_completed`, `onboarding_step_skipped`, `onboarding_step4_path_clicked`, `onboarding_step4_path_failed`, `onboarding_completed`, `onboarding_dismissed`, `onboarding_agent_picked`, and onboarding import events. Later onboarding events joined the roster by declaring `cohort` in their schemas. - -| Field | Value | -| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| PR | `#1608` | -| Merge commit | `318e2b4c2c501b50280dc9e5b11ed34bd899bdd9` | -| `code_merged_at_utc` | `2026-05-09T02:57:18Z` | -| First release | `v1.3.44` | -| First release commit | `e602decf1d35dc54c84eea516fc2b9afdc065cce` | -| `first_released_at_utc` | `2026-05-09T08:05:39Z` | -| Earliest `first_seen_at_utc` | `2026-05-09T09:54:03.038Z` on `onboarding_started { cohort: 'fresh_install' }` (`1.3.45`) | -| `dashboard_ready_at_utc` | `2026-05-09T09:54:03.038Z` for fresh-install onboarding-start cohorts; use later event-specific first-seen timestamps where a tile requires another event. | - -Later onboarding cohort events: - -| Event | PR | First release | `first_seen_at_utc` | -| ---------------------------------------------- | ---------------------------------------------------- | --------------------------------------- | ----------------------------------------- | -| `onboarding_feature_setup_run` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-15T05:55:37.248Z` (`1.4.2-rc.4`) | -| `onboarding_feature_setup_terminal_opened` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-15T05:55:37.261Z` (`1.4.2-rc.4`) | -| `onboarding_feature_setup_terminal_interacted` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-15T11:47:28.022Z` (`1.4.2-rc.6`) | -| `onboarding_feature_setup_toggled` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-17T03:03:54.535Z` (`1.4.3`) | -| `onboarding_task_sources_snapshot` | `#2275` (`ececd27fd8203a7acb3e430fa016fb4653aaa93c`) | `v1.4.7-rc.1` at `2026-05-19T00:47:11Z` | `2026-05-19T04:18:23.872Z` (`1.4.7`) | - -Dashboard caveats: - -- Use `onboarding_started { cohort: 'fresh_install' }` as the new-user denominator. -- Keep the single observed `upgrade_backfill` rows out of fresh-install funnels. -- `onboarding_step_completed` had semantic `value_kind` before the tour instrumentation, but `onboarding_step_viewed` and `onboarding_step_skipped` only became semantic-step-safe with the tour telemetry rollout below. -- Later onboarding events joined the `cohort` roster by declaring `cohort` in their schema. Use event-specific first-seen timestamps for tiles that depend on those later events. -- `onboarding_task_sources_snapshot` fires only when the integrations step exits through Continue or Skip to project setup. Viewing integrations and leaving does not emit the snapshot. -- Old numeric step analysis must be scoped to the flow version being analyzed. - -### 2026-05-14 - Feature Wall Base Telemetry - -Scope: first feature-wall tour event family before the inline onboarding tour. This added base open/close and tile activity telemetry. Historical `feature_wall_closed` rows from this rollout have `dwell_ms`; the source/depth fields needed for tour cohort retention only arrive in the later tour telemetry rollout. - -| Field | Value | -| ------------------------ | ---------------------------------------------------------------------------------------------------------------- | -| PR | `#1772` | -| Merge commit | `fdf7d9e97a46f27b3667d9bae6c8aca5d11345d8` | -| `code_merged_at_utc` | `2026-05-14T22:20:50Z` | -| First release | `v1.4.2-rc.1` | -| First release commit | `c66e9801fe5f159cac23564ec7c028957973692a` | -| `first_released_at_utc` | `2026-05-14T22:23:46Z` | -| `first_seen_at_utc` | `2026-05-15T06:15:30.263Z` on `feature_wall_opened { source: 'popup' }` (`1.4.2-rc.4`) | -| `dashboard_ready_at_utc` | Event-dependent. Useful for base feature-wall usage after first-seen rows, not for inline-tour cohort retention. | - -PostHog evidence checked at `2026-05-23T23:34:32Z`: - -| Event/property | `first_seen_at_utc` | -| ------------------------------------------------- | ----------------------------------------- | -| `feature_wall_opened { source: 'popup' }` | `2026-05-15T06:15:30.263Z` (`1.4.2-rc.4`) | -| `feature_wall_tile_focused` | `2026-05-15T06:15:31.073Z` (`1.4.2-rc.4`) | -| `feature_wall_closed` with legacy `dwell_ms` only | `2026-05-15T06:18:22.677Z` | -| `feature_wall_tile_clicked` | `2026-05-17T04:35:04.142Z` (`1.4.3`) | -| `feature_wall_opened { source: 'help_menu' }` | `2026-05-17T07:02:23.278Z` (`1.4.3`) | - -Dashboard caveats: - -- Do not infer inline onboarding tour cohorts from this rollout. -- Historical `feature_wall_closed` null `source`/depth fields are pre-rollout absence, not product behavior. - -### 2026-05-23 - Inline Tour Surface - -Scope: optional "Explore Orca" tour during onboarding and the Help menu entry point. This shipped the product surface, not the full retention cohort instrumentation. It also added `source = 'onboarding'` to the feature-wall open source enum and added feature-wall group/feature/docs click events with source. - -| Field | Value | -| -------------------------- | ---------------------------------------------------------------------------- | -| PR | `#2652` | -| Merge commit | `669ade23133b9905fff1b6ed22755ee2911ea517` | -| `code_merged_at_utc` | `2026-05-23T19:21:14Z` | -| First release | `v1.4.23-rc.0` | -| First release commit | `1f1171e0dee5d0fbf969de56845882bf8deb9037` | -| `first_released_at_utc` | `2026-05-23T20:14:38Z` | -| First app version observed | `1.4.23-rc.0` at `2026-05-23T20:54:12.343Z` | -| `dashboard_ready_at_utc` | `TBD`; do not use this surface-only rollout for tour cohort retention tiles. | - -Dashboard caveats: - -- `feature_wall_opened { source: 'onboarding' }` is a release-availability signal for the inline surface, not the canonical tour cohort assignment. -- Use this boundary only for exploratory checks that do not require the new outcome/depth fields. -- Tour cohorts are observational and self-selected. Do not use causal "lift" or "impact" language unless a separate experiment supports it. - -### 2026-05-23 - Onboarding Tour Retention Telemetry - -Scope: low-cardinality telemetry that makes inline-tour cohort retention dashboards possible. - -Current status: the inline first-run onboarding tour was later removed from active onboarding after PR #2734 moved these education/setup moments to contextual feature tours and the Getting started with Orca guide. The historical schemas still accept seven-step `tour`/`agent_setup` onboarding rows for compatibility, but current active onboarding should not emit new `onboarding_step_* { value_kind: 'tour' }` rows. - -Added/changed signals: - -- `onboarding_tour_outcome` with `outcome`, intro/tour duration fields, optional tour depth fields, `advanced_via`, and injected onboarding `cohort`. -- `onboarding_step_viewed.value_kind` and `onboarding_step_skipped.value_kind`, including `value_kind = 'tour'`. -- `feature_wall_closed` optional `source`, `exit_action`, `furthest_step`, `last_group_id`, and bounded visited/completed depth counts. -- `feature_wall_opened { source: 'onboarding' }` through the inline tour surface. - -| Field | Value | -| ------------------------ | ------------------------------------------ | -| PR | `#2713` | -| Branch commit | `9115143927dce38547545a07e58b85d17796fd7a` | -| Merge commit | `b9c55bb07127f799528761ec31d7e2b7f8598c07` | -| `code_merged_at_utc` | `2026-05-23T21:15:11Z` | -| First release | `v1.4.23-rc.1` | -| First release commit | `c74765922e770c6540496735a5a2be838457256a` | -| `first_released_at_utc` | `2026-05-23T21:16:38Z` | -| `first_seen_at_utc` | `TBD` | -| `dashboard_ready_at_utc` | `TBD` | - -Required readiness signals: - -| Signal | `first_seen_at_utc` | -| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -| `onboarding_tour_outcome` with valid `outcome` and joinable fresh-install start | `TBD` | -| `onboarding_step_viewed { value_kind: 'tour' }` | Historical only; no new active-onboarding rows after the PR #2734 education handoff. | -| `feature_wall_closed` with non-null `source`, `exit_action`, and expected depth fields | `TBD` | - -QA/opportunistic signals: - -| Signal | `first_seen_at_utc` | -| ------------------------------------------------ | ------------------------------------------------------------------------- | -| `onboarding_step_skipped { value_kind: 'tour' }` | Historical only; do not gate dashboard readiness on natural skip traffic. | -| `feature_wall_opened { source: 'onboarding' }` | `TBD`; surface availability only. | - -PostHog evidence checked at `2026-05-23T23:34:32Z`: - -- `1.4.23-rc.0` had app traffic from `2026-05-23T20:54:12.343Z` through `2026-05-23T22:59:47.599Z`. -- No `1.4.23-rc.1` or `1.4.23-rc.2` app traffic was observed in the checked query window. -- No rows were observed for `onboarding_tour_outcome`, `onboarding_step_viewed { value_kind: 'tour' }`, `onboarding_step_skipped { value_kind: 'tour' }`, `feature_wall_opened { source: 'onboarding' }`, or `feature_wall_closed` with the new source/depth fields. -- No rows were observed for `feature_wall_group_selected`, `feature_wall_feature_selected`, or `feature_wall_docs_clicked` in the checked query window. - -Dashboard readiness rule: - -Set this rollout's `dashboard_ready_at_utc` to the latest first-valid timestamp across the required readiness signals, then use that as the lower bound. If a query uses a local variable, name it `tour_telemetry_ready_at_utc` and assign it to the same value. After first-seen rows exist, keep saved cohort tiles in QA/exploratory mode until there is at least a 24-hour validation window where reached-tour users reconcile to exactly one primary inline cohort and closed tour sessions include depth fields. - -Invalid before this rollout's `dashboard_ready_at_utc`: - -- Tour cohort onboarding completion/progress. -- Corrected onboarding outcome split by tour cohort. -- Tour depth before abandonment. -- Skipped-tour later Help recovery. -- D1/D3/D7 retention by tour cohort, until cohorts are also old enough for each maturity window. -- Numeric-step max-progress charts unless segmented by rollout or app version. - -Primary cohort interpretation: - -- Historical denominator for inline completed/partial/skipped rates: `onboarding_step_viewed { value_kind: 'tour' }` after this rollout's `dashboard_ready_at_utc`. Do not use this as a current active-onboarding readiness signal after the PR #2734 education handoff. -- `onboarding_tour_outcome.outcome = 'completed_inline'` means the user completed the inline tour during fresh onboarding. -- `onboarding_tour_outcome.outcome = 'started_partial'` means the user started the inline tour but the fresh onboarding session resolved without inline completion. -- `onboarding_tour_outcome.outcome = 'skipped_intro'` means the user reached the tour intro and skipped without starting the inline tour. -- Users who started onboarding but never reached the tour intro are `pre_tour_abandoned`, not `unresolved_inline_outcome`. -- Users who reached the tour intro but have no outcome event are `unresolved_inline_outcome`; monitor this as a telemetry QA gap. -- Later Help menu tour usage is an overlay on the user's inline cohort. It does not replace the primary inline outcome. -- `completed_inline` wins over earlier partial state if the user returns and completes during the fresh onboarding session. -- `started_partial` resolves only after the user started the inline tour and the fresh onboarding session resolves without inline completion. -- `skipped_intro` intentionally has no `tour_dwell_ms` or depth fields. - -Feature-wall close interpretation: - -- `feature_wall_closed.dwell_ms` is session-close dwell time. -- `feature_wall_closed.source` is the entry point (`onboarding`, `help_menu`, `popup`, or `unknown`). -- `exit_action = 'onboarding_continue'` means the inline onboarding tour ended through Continue to project setup. -- `exit_action = 'done'` means a non-onboarding tour session ended through Done. -- `exit_action = 'dismissed'` is the default close/unmount path. -- Depth fields are per explicit tour session. Persisted completion state can affect UI progress but must not be interpreted as current-session depth. -- Missing/null `feature_wall_closed.source` or depth fields mean the row predates the expanded schema, came from an older app version, or failed field coverage. Do not bucket null with `source = 'unknown'`; exclude it from source/depth cohort tiles or show it as a telemetry coverage gap. - -### 2026-06-02 - Active Onboarding Step Removal - -Scope: active first-run onboarding no longer emits the `agent_setup` or `tour` semantic steps. The removed "Set up Orca for agents" and "Explore Orca" education/setup moments are covered by PR #2734 through contextual feature tours and the Getting started with Orca guide. - -This is a product-flow and telemetry-interpretation boundary, not a new event rollout. Historical onboarding schemas still accept seven-step `agent_setup` and `tour` rows so old data remains queryable, but dashboard authors should not expect new active-onboarding rows for those semantic steps after this rollout. - -| Field | Value | -| ------------------------ | ------------------------------------------------------------------------------- | -| PR | `#4445` | -| Merge commit | `TBD` | -| `code_merged_at_utc` | `TBD` | -| First release | `TBD` | -| First release commit | `TBD` | -| `first_released_at_utc` | `TBD` | -| `first_seen_at_utc` | N/A | -| `dashboard_ready_at_utc` | Event-dependent; use this as a cutoff only after the PR is merged and released. | - -Dashboard caveats: - -- Treat `onboarding_step_* { value_kind: 'agent_setup' }`, `onboarding_step_* { value_kind: 'tour' }`, and `onboarding_tour_outcome` as historical first-run onboarding signals after this rollout. -- Do not use absence of new `agent_setup` or `tour` onboarding rows as a drop-off signal; those steps no longer exist in active onboarding. -- Segment numeric onboarding step analysis across this boundary. The active final step changed from seven-step onboarding to the five-step active flow. -- Continue using `contextual_tour_shown` and `contextual_tour_outcome` from PR #2734 for current feature-education exposure and outcome analysis. - -### 2026-06-03 - Final Code Onboarding Step Removal - -Scope: active first-run onboarding no longer emits the final code/project picker step. The notifications step is now the final step, and completing it opens the Add Project modal. - -This is a product-flow and telemetry-interpretation boundary, not a new event rollout. Historical onboarding schemas still accept five-step rows so old data remains queryable, but dashboard authors should not expect new active-onboarding rows for the removed final code/project picker step after this rollout. - -| Field | Value | -| ------------------------ | ------------------------------------------------------------------------------- | -| PR | `#4524` | -| Merge commit | `TBD` | -| `code_merged_at_utc` | `TBD` | -| First release | `TBD` | -| First release commit | `TBD` | -| `first_released_at_utc` | `TBD` | -| `first_seen_at_utc` | N/A | -| `dashboard_ready_at_utc` | Event-dependent; use this as a cutoff only after the PR is merged and released. | - -Dashboard caveats: - -- Treat `onboarding_step_*` rows for the removed final code/project picker step as historical first-run onboarding signals after this rollout. -- Segment numeric onboarding step analysis across this boundary. At this boundary, the active final step changed from the five-step active flow to `ONBOARDING_FINAL_STEP = 4`; later onboarding step rollouts may supersede that final-step value. -- Do not use absence of new final code/project picker rows as a drop-off signal; that step no longer exists in active onboarding. - -### 2026-06-03 - Add Project Default Checkout Handoff - -Scope: Add Project handoff telemetry and interpretation change. The Git add/clone/create/onboarding flows no longer show the post-add setup-choice screen; they close the add modal and open the project/default checkout when available. If no default checkout is available, they reveal the project row instead. - -`repo_added` remains the add/import marker. `add_repo_existing_workspaces_detected` remains a low-cardinality detection event for pre-existing non-main workspaces on local folder, runtime server path, and SSH server path adds. `add_repo_default_checkout_handoff` is the direct rollout-health event for the automatic handoff decision. - -| Field | Value | -| ------------------------ | ------------------------------------------------------------------------------------- | -| PR | `#4530` | -| Merge commit | `TBD` | -| `code_merged_at_utc` | `TBD` | -| First release | `TBD` | -| First release commit | `TBD` | -| `first_released_at_utc` | `TBD` | -| `first_seen_at_utc` | `TBD` on `add_repo_default_checkout_handoff` | -| `dashboard_ready_at_utc` | Use the first release boundary once known for charts that compare setup-choice usage. | - -Dashboard caveats: - -- Treat `add_repo_setup_step_action` rows after this rollout as historical compatibility or stale modal cleanup only, not the normal Add Project funnel. -- Do not build current-flow conversion funnels that require `repo_added -> add_repo_setup_step_action`; the normal next step is an automatic workspace reveal/open, not a tracked user choice. -- Use `add_repo_existing_workspaces_detected` to estimate how often added projects had non-main existing workspaces, but do not infer the user selected "use existing worktrees" because that choice no longer exists in the normal flow. -- Use `add_repo_default_checkout_handoff` for the current handoff outcome. `result = 'opened_default_checkout'` is the expected path; `result = 'revealed_project'` is the graceful fallback. Break down fallback rows by `source` and `reason`. - -### 2026-06-10 - Repo Added Git-vs-Folder Signal - -Scope: `repo_added.is_git_repo` replaces the retired `onboarding_completed.is_git_repo` split for git-vs-folder analysis. Project selection moved out of onboarding in the 1.4.46 flow, so `onboarding_completed` now fires before any repo is chosen. After that boundary, the old `onboarding_completed.is_git_repo` value is not a valid git-vs-folder signal. - -`repo_added.is_git_repo` is sourced from git detection at the add point. It is optional so SSH/remote paths that genuinely cannot determine git-ness can omit the property instead of defaulting to `false`. - -| Field | Value | -| ------------------------ | ------------------------------------------------------------------------------------------- | -| PR | `#5121` | -| Merge commit | `TBD` | -| `code_merged_at_utc` | `TBD` | -| First release | `TBD` | -| First release commit | `TBD` | -| `first_released_at_utc` | `TBD` | -| `first_seen_at_utc` | `TBD` on `repo_added.is_git_repo` | -| `dashboard_ready_at_utc` | `TBD`; use only after first-seen rows exist and field coverage has been checked in PostHog. | - -PostHog evidence checked at `2026-06-10T19:00:00Z`: - -- Dashboard tile "Fresh-install onboarding completion over time" (`JlIt5J1N`, insight id `9076383`, project `406068`) showed the git-repo share collapse to about 4% while plain-folder completions spiked to about 88% on 2026-06-05. -- Raw `onboarding_completed.is_git_repo` counts by `app_version` showed a version cliff: versions through `1.4.45` were about 80% true, while `1.4.46`, `1.4.47`, and `1.4.48` had zero true rows in the sampled data. - -Dashboard caveats: - -- Treat `onboarding_completed.is_git_repo` as historical only after app version `1.4.45`. -- Do not stitch historical `onboarding_completed.is_git_repo` and new `repo_added.is_git_repo` series without an explicit version boundary and label change; they are emitted at different funnel moments. -- Repoint dashboard tile `JlIt5J1N` to use `repo_added.is_git_repo` once the new field is observed in release telemetry. -- Omitted `repo_added.is_git_repo` means unknown/degraded detection, not plain folder. Only explicit `false` means plain folder. - -### 2026-06-16 - Windows Terminal Preferences Onboarding Step - -Scope: Windows first-run onboarding adds a terminal preferences step before notifications. The step lets users choose the default Windows terminal shell and right-click paste/menu behavior before their first project handoff. - -`onboarding_step_*` rows can now emit `value_kind = 'windows_terminal'` at step `4`. Notifications move to step `5`, so `ONBOARDING_FINAL_STEP = 5` for current active onboarding. Non-Windows clients skip the Windows terminal step but still persist through the skipped step so resumed onboarding lands on notifications. `onboarding_windows_terminal_snapshot` records the low-cardinality selected shell bucket, right-click behavior, exit action, duration, and advance method when the visible Windows terminal step exits. - -| Field | Value | -| ------------------------ | -------------------------------------------------------------------------------------- | -| PR | `#5488` | -| Merge commit | `68abadba8198c627fb642c41e54937c04ccddfe8` | -| `code_merged_at_utc` | `2026-06-16T21:07:26Z` | -| First release | `TBD` | -| First release commit | `TBD` | -| `first_released_at_utc` | `TBD` | -| `first_seen_at_utc` | `TBD` on `onboarding_step_viewed { value_kind: 'windows_terminal' }` and `onboarding_windows_terminal_snapshot` | -| `dashboard_ready_at_utc` | `TBD`; use only after first-seen rows exist and Windows/non-Windows split plus snapshot coverage are verified. | - -Dashboard caveats: - -- Segment numeric onboarding step analysis across this boundary. Step `4` is Windows terminal preferences in the current flow, but was notifications in the previous active flow. -- Use `value_kind` rather than numeric `step` when comparing notifications or Windows terminal setup across releases. -- Non-Windows users can have persisted `lastCompletedStep` values that include the skipped Windows step; do not treat that as evidence they viewed the Windows terminal page. -- `onboarding_windows_terminal_snapshot.default_shell = 'other'` means Orca could not bucket the persisted setting. It is not a raw shell path and should be monitored as telemetry quality, not a product choice. - -## Updating This File - -When adding or changing telemetry that dashboard authors will depend on: - -1. Add or update one rollout entry in this file. -2. Record PR, merge commit, first release, release commit, `first_released_at_utc`, `first_seen_at_utc`, and `dashboard_ready_at_utc`. -3. Include the app version/build on first-seen rows when it is available. -4. Add `PostHog evidence checked at ...` with the UTC query time. -5. If dashboard readiness depends on multiple signals, add a required readiness signal table and set `dashboard_ready_at_utc` only after all of them are first-valid. -6. Add dashboard caveats for pre-rollout rows, missing/null fields, and maturity windows. -7. Keep product-specific facts here, not in agent instructions or telemetry payloads. diff --git a/docs/reference/terminal-cold-park-reveal-cost.md b/docs/reference/terminal-cold-park-reveal-cost.md deleted file mode 100644 index e4eecd7fb91..00000000000 --- a/docs/reference/terminal-cold-park-reveal-cost.md +++ /dev/null @@ -1,132 +0,0 @@ -# Cold-park reveal cost — measured - -Companion to [`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md). -Quantifies the reveal latency a user pays when returning to a terminal whose -hidden view was cold-parked, so the keep-warm policy is tuned from data rather -than guessed. Motivated by field reports that "terminal mounting is jumpy — -switching to a worktree after a while takes ~1s". - -## How to reproduce - -```bash -pnpm bench:cold-park-reveal -- --cycles=12 # empty shell -pnpm bench:cold-park-reveal -- --cycles=10 --scrollback-lines=5000 -pnpm bench:cold-park-reveal -- --cycles=10 --scrollback-lines=20000 -``` - -The bench (`tools/benchmarks/terminal-cold-park-reveal-bench.mjs`) drives the -real dev app over CDP. Each cycle times a **cold** reveal (tab confirmed parked -via `window.__terminalParkingDebug.parkedTabIds()` before revealing) and a -**warm** reveal (revealed inside the hot-retain window, confirmed not parked), -so the cold−warm delta is the isolated cost of parking. It shrinks the 30s -hysteresis with `ORCA_E2E_TERMINAL_PARKING_DELAY_MS` and roots userData under a -short `~/.ocpb` path — the default `/var/folders` tmp path overruns the macOS -104-char Unix-socket limit, the daemon fails to bind, and terminals fall back to -non-snapshot PTYs that never park. - -Phases per reveal (ms from the switch-back click): `activationMs` (store active -worktree flips), `ptyBindMs` (revealed pane has a bound pty — xterm remounted -and attached), `paintSettleMs` (two RAFs after ptyBind). - -## Results (macOS, dev app, medians / p95) - -| Scrollback | warm paintSettle | cold paintSettle | **cold-park penalty** | -| ---------- | ---------------- | ---------------- | --------------------- | -| empty shell | 104 ms | 264 / 298 ms | **+160 ms** | -| 5 000 lines | 131 ms | 318 / 423 ms | **+187 ms** | -| 20 000 lines | 102 ms | 266 / 286 ms | **+164 ms** | - -All cold samples confirmed parked (`parkedConfirmed = N/N`); all warm samples -confirmed not parked. - -## Finding - -**The cold-park reveal penalty is ~160–190 ms and effectively flat across buffer -size.** Growing scrollback 0 → 20 000 lines did not grow the penalty (the 20k -run is within noise of empty). So the cost is **fixed remount overhead** — React -subtree teardown/rebuild, fresh xterm construction, WebGL attach, fit, and the -reattach reset — **not** daemon snapshot replay, which scales fine. - -Implications for tuning: - -- The lever is **how often a remount happens**, not how much is replayed. - Widening the keep-warm working set (worktree hot-retain limit is 4, tabs 12) - and never parking the last-active tab per worktree remove remounts entirely - for the common switch-away-and-back, which is worth more than shaving the - ~170 ms remount itself. -- Pre-warming a predicted reveal (sidebar hover, jump-palette selection) hides - the fixed cost behind the navigation instead of eliminating it. -- This synthetic terminal is a plain shell. A live agent TUI in the alternate - screen pays additional reattach-reset + mode-rehydrate work on reveal (see the - Grok-scrollback reattach path), so real-world cold reveals of a busy agent - pane can exceed these numbers — the ~170 ms here is a floor for the mount - overhead, not a ceiling for the full experience. - -## Is parking worth having? (benefit side) - -`tools/benchmarks/terminal-cold-park-resource-bench.mjs` -(`pnpm bench:cold-park-resource`) A/Bs the `terminalHiddenViewParking` setting -in one session: mount N worktree terminals, background all but one, then read -resources with parking off vs on. Measured at 8 backgrounded worktrees -(5 000-line scrollback each): - -| metric | parking off | parking on | released | -| ------ | ----------- | ---------- | -------- | -| mounted pane managers | 9 | 1 | **8** | -| DOM nodes | 2 941 | 1 389 | **1 552** | -| attached WebGL contexts | 1 | 1 | **0** | - -So parking's unique win is **releasing every hidden terminal's mounted view** — -8 xterm instances + their DOM subtrees (~1 550 nodes) here. At xterm's default -5 000-row scrollback, each terminal buffer alone is ≥ ~4–5 MB of typed-array -cell data (12 bytes/cell × 80 cols × 5 000 rows), so the renderer-memory floor -is on the order of **several MB per hidden terminal** — tens of MB across a -many-worktree session, before DOM and addon overhead. - -Two honest caveats on the numbers: - -- The **WebGL-context budget is NOT parking's win.** Even with parking off, only - one context was attached across 9 mounted terminals — the separate - WebGL-suspend-on-hide path (`suspendRendering`) already releases hidden panes' - contexts. So the #6874 context-exhaustion protection stands with or without - parking; parking's benefit is renderer memory + DOM, not contexts. -- The measured JS-heap delta was small (~1.7 MB) because detached xterm - typed-array buffers GC lazily and the first measurement ran before they were - reclaimed. The bench was upgraded to force multi-pass GC and read whole-app - process memory (`window.api.memory.getSnapshot`), but a machine under disk + - memory pressure could not complete the upgraded run (esbuild dev-service - crashes). Re-run the upgraded bench on a healthy machine to get the settled - whole-app memory figure; the pane-manager / DOM release above is the robust, - reproducible part. - -### Verdict - -Parking earns its keep for the **many-worktree power user** (the reporter's -profile): it is the only mechanism that frees hidden terminals' view memory and -DOM, which is the dominant renderer cost at scale. It does **not** provide the -GPU-context protection (that is suspend-on-hide's job). The reveal price is a -flat ~170 ms remount. Net: keep it, and tune the keep-warm caps so the common -few-worktree rotation never pays the remount. - -### Tuning applied - -Because the reveal cost is a fixed remount, the levers are all about -**frequency**, not replay size. Three changes to -`terminal-hidden-view-parking.ts`: - -- **Worktree hot-retain limit 4 → 8.** Eight covers the ordinary working set; - at the ~4–5 MB renderer floor per hidden worktree that is ~16–20 MB worst - case to eliminate remounts for the common rotation. Beyond 8, parking still - reclaims for the heavy tail it was built for. -- **Hot-retain TTL 5 min → 15 min** (worktree and tab). The cap, not the clock, - is the primary evictor now; 5 min was aggressive enough that a meeting-length - absence parked the whole warm set. The tab count cap stays 12 (already - generous). -- **Last-active exemption.** The single most-recently-hidden worktree/tab is - never parked, regardless of TTL or cap, so returning to the view you just - left is always instant. Implemented in `selectIdsBeyondHotRetain`; it holds - one warm slot and counts against the cap. - -Not done (deliberately): pre-warm on predicted reveal — it hides the cost -behind navigation rather than removing it and adds hover/prediction complexity; -and the 30 s cold-park delay is unchanged (it is the quick-flip guard). diff --git a/docs/reference/terminal-hidden-view-parking.md b/docs/reference/terminal-hidden-view-parking.md deleted file mode 100644 index 55583ab5a79..00000000000 --- a/docs/reference/terminal-hidden-view-parking.md +++ /dev/null @@ -1,119 +0,0 @@ -# Terminal Hidden View Parking - -Status: Shipped — Phase 1 of the terminal model/view architecture, kill switch -`terminalHiddenViewParking` (default on). See -[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) for the -invariants this design extends and the full phase list. - -## Problem - -Hidden terminal panes keep a full renderer xterm instance alive (buffer, -scrollback, DOM, addons). At many-worktree scale this is the dominant renderer -memory cost, and it forces every hidden byte through renderer-side write/skip -decisions. The main-process model (daemon + runtime headless emulators) already -ingests every byte and can serve restorable snapshots, so the renderer view for -a long-hidden pane is redundant state. - -A previous attempt shipped and was reverted the same day. The post-mortem -finding: parking unmounted the pane component, which also tore down the -renderer's PTY byte parsers — and those parsers are the only source of bell -notifications, title-transition agent-complete notifications, and tab titles. -A parked worktree whose agent finished would never notify. This design keeps -those side effects alive while parked. - -## Design - -### Park policy (renderer) - -A pure policy module decides which hidden terminal tabs may park: - -- Cold-park hysteresis: a tab must be hidden for 30s before parking. -- Hot-retain working set: recently visible worktrees/tabs are retained - (15 minutes; up to 8 worktrees / 12 tabs) so quick tab switches never pay a - re-hydrate. The single most-recently-hidden (last-active) worktree/tab is - exempt from both the retain TTL and the count cap, so switching back to the - view you just left is always instant regardless of how long you were away. - See `terminal-cold-park-reveal-cost.md` for the data behind these caps. -- Eligibility excludes: visible panes, hidden-measuring startup probes, - activity-portal panes, tabs with pending startup commands or pending - activation spawns, floating-panel tabs, and any tab whose PTY is not - snapshot-backed (remote-runtime `remote:` PTYs and SSH PTYs are excluded). -- Kill switch: `settings.terminalHiddenViewParking === false` disables parking - entirely. - -### Park mechanics - -Parking a tab unmounts its `TerminalPane` React subtree (the overlay layer -renders null for parked tabs). This is the same teardown that tab-group moves -already exercise: transports detach but the PTY session, daemon model, and tab -state all survive. The xterm instance, its buffers, DOM, and WebGL/addon -resources are released. - -### Parked watcher (the piece the reverted attempt lacked) - -While a tab is parked, a pane-less watcher -(`parked-terminal-byte-watcher.ts`) keeps the pane's side effects alive. Its -consumption mode is decided once at watcher start: - -- **Main side-effect authority on (default):** the watcher is purely - fact-driven — it registers exactly one `pty:sideEffect` fact consumer and - parses no bytes. Titles, agent working/idle/exited transitions, BEL - attention, and PR links arrive as main-tracker facts and drive the same - policy callbacks a mounted pane uses. With the hidden-delivery gate also on, - the watcher marks the PTY hidden so main stops renderer byte delivery - entirely; the DECSET 2031 color-scheme subscribe arrives as main's - `2031-subscribe` fact and the watcher replies out-of-band via - `transport.sendInput`. -- **Kill switch off:** the watcher subscribes to raw bytes through the - dispatcher sidecar mechanism (the same mechanism background agent launches - use) and runs the transport-level byte parsers with no xterm — OSC 0/1/2 - titles (all-titles ordering, live-path normalization), the title-transition - agent tracker (completion notification, prompt-cache timer), the OSC-aware - stateful BEL detector, the GitHub PR link scan, and a dedicated DECSET 2031 - byte responder (`parked-terminal-mode2031-responder.ts`, whose - `subscribeToPtyData` registration doubles as the delivery-interest signal). - -The two modes drive one shared policy-callback block, so flipping the kill -switch never changes notification semantics. Main's synthetic -agent-title/permission frames feed the main tracker directly and arrive as -facts; the legacy synthetic `pty:data` copy exists only in kill-switch-off -mode. - -Out of scope while parked: OSC 52 clipboard writes. Terminal queries inside -hidden-dropped chunks are answered by main's model responder -([`terminal-query-authority.md`](./terminal-query-authority.md)); in -kill-switch-off byte mode only the 2031 reply is answered and Command Code -output is not scraped, matching the pre-gate status quo. - -### Reveal - -Revealing a parked tab remounts the pane subtree and rides the existing -reattach path: fresh xterm via `openTerminal` (unicode provider activation -before any write), daemon model snapshot > relay replay > cold restore -precedence, replay-guarded so snapshot-embedded queries never answer, then -`POST_REPLAY_REATTACH_RESET` hygiene, fit, and PTY resize. The watcher is -disposed before the pane handlers re-register. - -## Invariants - -1. PTY reads never stop; parking only changes renderer-side view lifetime. -2. Bell, agent-completion, title, and PR-link side effects keep working while - parked (watcher parity tests). -3. Reveal shows model-correct output (visual gates: hidden TUI restore, long - table, rendering golden) and accepts input immediately. -4. Sleep/wake, pane close, and PTY restart while parked must not leak watchers - or strand parked state. -5. Memory: parked tabs hold no xterm buffers; renderer memory scales with - visible panes. - -## Relation to later phases (all shipped) - -Side-effect authority in main (Phase 3) replaced the watcher's byte parsing -with the `pty:sideEffect` fact consumer; the hidden-delivery gate (Phase 4) -stops hidden byte delivery in main, moving the parked 2031 reply from the -byte sidecar to the `2031-subscribe` fact; the model query responder -(Phase 5) answers queries in hidden-dropped chunks. The watcher's byte-parser -mode survives only behind the kill switches. Parking still excludes -remote-runtime and SSH PTYs (no local snapshot to restore from); the watcher -would return as a byte parser only if remote-runtime tabs — whose bytes never -transit local main — ever became parkable. diff --git a/docs/reference/terminal-model-view-contract.md b/docs/reference/terminal-model-view-contract.md deleted file mode 100644 index 79fb55575f3..00000000000 --- a/docs/reference/terminal-model-view-contract.md +++ /dev/null @@ -1,218 +0,0 @@ -# Terminal Model/View Contract - -## Goal - -Terminal output should have one authoritative model path and many disposable -views. A renderer xterm is the fast interactive view, but it must not be the -only place hidden, remote, mobile, SSH, or CLI-visible terminal state exists. - -This contract defines the boundary the shipped terminal stack implements — and -that future terminal work must preserve — without changing the query-response -behavior that real shells and TUIs depend on. See [Architecture -Status](#architecture-status) for the shipped phases. - -## Terms - -- **PTY stream:** Ordered bytes read from a local PTY, daemon PTY, SSH relay PTY, - or remote runtime PTY. -- **Terminal model:** Main/runtime-owned state derived from PTY bytes. Today this - is mostly the headless emulator plus retained read transcript state. -- **Terminal view:** A renderer xterm, mobile subscriber, remote desktop - subscriber, or CLI read page consuming model state and live output. -- **Snapshot:** A bounded model serialization that can restore a view without - replaying an unbounded byte log. -- **Transcript:** The retained output contract for `orca terminal read`; it is - line/cursor oriented and distinct from a screen snapshot. - -## Non-Negotiable Invariants - -1. PTY reads do not stop to protect renderer performance. Backpressure may bound - delivery to views, but terminal state, notifications, titles, and agent - status keep advancing from the PTY stream. -2. Active visible terminal input/output stays on the lowest-latency path. Bulk - hidden or background output must not delay keystroke-sized foreground redraws. -3. Hidden views do not own unbounded output memory. Main's hidden-delivery - gate drops renderer-bound bytes for hidden-marked PTYs after model - ingestion and emits an out-of-band restore marker - (`pty:modelRestoreNeeded`) so the view restores from the model on reveal. - With the gate's kill switches off, hidden bytes ride a bounded renderer - queue whose overflow latches the same model restore. -4. Returning to a hidden or slept terminal must show model-correct output. A - stale or replaced view may be cleared and replayed from a snapshot, but it - must not show a warning fallback when model recovery is available. -5. Snapshots and live bytes have ordering metadata. A view restore must not - duplicate bytes already included in the snapshot or drop bytes that arrived - after it. Main buffer snapshots report the pending-delivery start sequence - (`pendingDeliveryStartSeq`) so the renderer reconciles live chunks racing a - restore without misreading foreign sequence domains as duplicates. -6. Terminal query authority is singular and structural: the party that - writes a chunk into a live terminal answers its queries. Visible renderer - and remote views keep xterm authority. Chunks dropped by the - hidden-delivery gate are answered exactly once by the main model - responder, from runtime-emulator state plus renderer-pushed view - attributes. Replayed, seeded, or snapshot bytes are answered by no one. - The daemon emulator never answers. (Amended by Phase 5 — see - [`terminal-query-authority.md`](./terminal-query-authority.md).) -7. The transcript contract stays separate from screen restore. `orca terminal - read` must preserve bounded previews, cursor pagination, partial-line rules, - truncation flags, and total counts even if view snapshots change shape. -8. Local, daemon, SSH, remote runtime, mobile, and CLI paths must either satisfy - the same model/view contract or explicitly report that model recovery is - unavailable. - -## Current Owners - -| Responsibility | Current owner | -| --- | --- | -| PTY byte source and local/SSH delivery | `src/main/ipc/pty.ts` | -| Hidden-delivery gate (hidden marks, delivery interest, drop accounting, restore markers) | `src/main/ipc/pty-hidden-delivery-gate.ts`, drop sites in `src/main/ipc/pty.ts` and `src/main/ssh/ssh-relay-session.ts` | -| Side-effect parsing and the `pty:sideEffect` facts channel | `src/shared/terminal-output-side-effects.ts` driven from `OrcaRuntimeService.onPtyData`; renderer policy in `src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts` | -| Model query responder and view-attribute bridge | `src/main/runtime/terminal-model-query-authority.ts`, `src/main/daemon/terminal-view-attribute-responder.ts`, `src/main/runtime/terminal-view-attribute-store.ts` | -| Hidden view parking policy and parked watcher | `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts`, `parked-terminal-byte-watcher.ts` | -| Daemon PTY state and headless snapshots | `src/main/daemon/headless-emulator.ts` | -| Runtime headless state, retained reads, mobile/session tabs | `src/main/runtime/orca-runtime.ts` | -| Remote terminal subscribe/multiplex/ACK semantics | `src/main/runtime/rpc/methods/terminal.ts` | -| Renderer xterm view and hidden restore behavior | `src/renderer/src/components/terminal-pane/pty-connection.ts` | -| Remote desktop runtime xterm transport | `src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts` | - -## Snapshot Contract - -A model snapshot must include: - -- terminal dimensions used to produce the snapshot; -- enough ANSI state to rehydrate xterm before snapshot content; -- bounded screen and scrollback content; -- title and cwd metadata when known; -- source metadata that distinguishes headless/model snapshots from renderer - fallback snapshots; -- monotonic ordering metadata for live-output reconciliation when available. - -A snapshot must not: - -- include unbounded transcript history; -- answer terminal queries while replaying into the model; -- overwrite newer live view output with older model output; -- hide that recovery was unavailable for a PTY surface. - -## View Contract - -A renderer or remote view may: - -- write active visible output immediately; -- budget visible inactive output; -- stop receiving hidden output entirely while main's hidden-delivery gate owns - the bytes (model restore on reveal); -- request fresh snapshots for restore, mobile subscription, or explicit remote - snapshot recovery. - -A view must: - -- keep live-output buffers bounded while a snapshot is in flight; -- apply generation or sequence checks before replaying a snapshot; -- refresh/repaint after replay when xterm/WebGL needs an explicit paint; -- keep side effects such as title, bell, cwd, and agent status flowing from the - PTY/model path (the `pty:sideEffect` facts channel) even while renderer byte - delivery is budgeted, gated, or parked. - -## Transcript Contract - -The retained read transcript is not a screen dump. It must preserve: - -- uncursored bounded latest preview behavior; -- cursor reads over completed retained lines; -- `oldestCursor`, `nextCursor`, `latestCursor`, and `returnedLineCount`; -- partial-line duplication rules; -- `truncated`, `limited`, and total count metadata; -- bounded memory for long partial lines and large output bursts. - -Snapshot optimizations must be tested against this transcript contract instead -of assuming xterm scrollback serialization can replace it. - -## Required Contract Tests - -Before moving more runtime behavior behind the model/view boundary, add or -extend tests that prove: - -- headless snapshots rehydrate rich alternate-screen TUI state; -- the daemon emulator never answers DA, DSR, OSC 11, or theme-sensitive - queries (the `session.test.ts` pins are permanent); -- the main runtime responder answers queries only from live chunks the - hidden-delivery gate dropped — never delivered, replayed, seeded, or - remote-subscribed chunks; -- hidden renderer overflow restores from model state without duplicate live - output; -- sleep/wake and worktree revisit restore from model-correct state; -- SSH-backed PTYs follow the same snapshot and ordering semantics as local PTYs; -- remote runtime multiplex output remains ACK bounded and can request recovery - snapshots; -- mobile subscribers receive bounded snapshots without unbounded pending live - output; -- retained terminal reads remain pageable and bounded after large output. - -Current coverage is spread across: - -- `src/main/daemon/headless-emulator.test.ts` -- `src/main/daemon/session.test.ts` -- `src/main/ipc/pty.test.ts` (hidden-gate drops, restore markers, - `pendingDeliveryStartSeq`) -- `src/main/ipc/pty-hidden-delivery-gate.test.ts` -- `src/main/runtime/mobile-subscribe-integration.test.ts` -- `src/main/runtime/rpc/terminal-subscribe-buffer.test.ts` -- `src/main/runtime/rpc/terminal-multiplex.test.ts` -- `src/main/runtime/orca-runtime.test.ts` -- `src/main/runtime/terminal-query-responder.test.ts` -- `src/shared/terminal-output-side-effects.test.ts` -- `src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts` -- `src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts` -- `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts` -- `src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts` -- `src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts` -- `tests/e2e/terminal-hidden-tui-visual-restore.spec.ts` -- `tests/e2e/terminal-hidden-view-parking.spec.ts` -- `tests/e2e/terminal-parked-memory.spec.ts` -- `tests/e2e/terminal-sleep-wake-restore.spec.ts` -- `tests/e2e/terminal-output-scheduler.spec.ts` -- `tests/e2e/artificial-opencode-terminal-load.spec.ts` - -## Architecture Status - -All six phases of the terminal model/view architecture are shipped; the kill -switches noted in parentheses default on: - -1. **Hidden view parking** — "Park hidden terminal views behind a byte - watcher": hidden terminal tabs unmount their xterm after a cold-park - hysteresis; a pane-less watcher keeps bell/title/agent/PR side effects - alive while parked (`terminalHiddenViewParking`). See - [`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md). -2. **Parked memory benchmarks** — "Benchmark parked hidden terminal memory": - renderer heap and live-terminal counts gate parking in the perf suite - (`tests/e2e/terminal-parked-memory.spec.ts`). -3. **Side-effect authority in main** — "Track terminal titles in main with - all-titles ordering", "Move terminal side-effect authority to a main facts - channel", "Complete terminal side-effect facts coverage", "Finish terminal - side-effect authority migration": every local/daemon/SSH PTY byte is - side-effect-parsed once in main and delivered as `pty:sideEffect` facts - (`terminalMainSideEffectAuthority`). See - [`terminal-side-effect-authority.md`](./terminal-side-effect-authority.md). -4. **Hidden delivery gate** — "Gate PTY delivery to hidden terminal views": - main drops renderer-bound bytes for hidden-marked PTYs after model - ingestion; delivery-interest registrations exempt sidecar byte consumers, - out-of-band restore markers latch model restore, and - `pendingDeliveryStartSeq` reconciles live output racing a restore - (`terminalHiddenDeliveryGate`). -5. **Model query authority** — "Answer hidden terminal queries from the - model", "Bridge renderer view attributes to the model responder", "Align - query authority contract and spawn-time ownership": hidden-dropped queries - are answered by the runtime emulator plus renderer-pushed view attributes, - and hidden-at-spawn PTYs are marked before byte one - (`terminalModelQueryAuthority`). See - [`terminal-query-authority.md`](./terminal-query-authority.md). -6. **Skip grammar deletion** — "Delete the hidden renderer skip grammar": the - renderer's per-chunk hidden-skip eligibility grammar and the 10s codex - startup query window are deleted; the kill-switch-off fallback is the - bounded background queue with overflow-latched model restore. - -Treat every hidden/slept/revisited TUI glitch as a contract failure, not as a -local repaint quirk. Renderer fallback paths retire only when their kill -switches do, and only after the equivalent model path has platform and TUI -golden coverage. diff --git a/docs/reference/terminal-query-authority.md b/docs/reference/terminal-query-authority.md deleted file mode 100644 index bb6b11fea8c..00000000000 --- a/docs/reference/terminal-query-authority.md +++ /dev/null @@ -1,347 +0,0 @@ -# Terminal Query Authority - -Status: Shipped — Phase 5 of the terminal model/view architecture, kill -switch `terminalModelQueryAuthority` (default on). Builds on -[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) (this -phase **amends invariant 6**), -[`terminal-side-effect-authority.md`](./terminal-side-effect-authority.md) -(Phase 3), and the Phase-4 hidden-delivery gate -(`src/main/ipc/pty-hidden-delivery-gate.ts`). - -## Problem - -Phase 4 drops renderer-bound bytes for hidden-gated PTYs after model ingestion -(`src/main/ipc/pty.ts:1426,1515`, `src/main/ssh/ssh-relay-session.ts:931`). -Queries embedded in dropped bytes get no reply: DA1 (ConPTY 1.22+ blocks -waiting for it — `terminal-conpty-device-attributes.ts:22`), CPR probes hang -TUIs, OSC 10/11 leaves `claude /theme` blind while hidden. The pre-Phase-4 -hidden skip latch had the same hole (only mode 2031 and the 10s codex startup -window answered), so this is not a regression — it is the long-standing gap -this phase closes. - -Contract invariant 6 ("the model must never answer queries") was written -against a real bug: the daemon emulator replying ahead of the renderer with -default-xterm values (the OSC-11 default-black-background race, -`headless-emulator.ts:86-97`, pinned by `session.test.ts:163-190`). The danger -was never "the model answers" — it was **two answerers for the same bytes**, -one of them with wrong values. Phase 5 keeps the singularity and fixes the -values. - -## Decision: the delivery decision is the reply decision - -Main answers a query **iff main dropped the chunk that carried it**. The same -per-chunk hidden-gate predicate (`shouldDropHiddenRendererPtyData`) that -decides renderer delivery decides reply ownership, evaluated once, -synchronously, at ingestion: - -- Visible/unmarked PTY → chunk delivered → renderer xterm auto-replies via - `Terminal.onData` → `transport.sendInput`, unchanged. -- Hidden-marked, no delivery interest → chunk dropped → main answers from the - runtime headless emulator, via the provider input path (`provider.write`, - same path as `pty:write`; daemon shell-ready write gating and the SSH relay - write apply unchanged). -- Replayed/seeded/snapshot bytes → answered by no one (replay guards on both - sides). - -This is structurally exactly-one-responder: a chunk is delivered or dropped, -never both, and each side only answers bytes it actually parsed live. The -mark/unmark ordering, unhide-before-restore, and restore-marker IPC all exist -from Phase 4 and are reused, not duplicated. - -Rejected alternatives: - -- **Fact-based renderer replies per query class** (the mode-2031 pattern - generalized): needs a main-side detection grammar per query, a fact round - trip per reply, and the renderer cannot answer CPR/DECRPM anyway — the - emulator is the only state for a hidden pane. The 2031 fact stays because it - is subscription registration, not a state query. -- **Emulator always answers**: re-creates the OSC-11 double-reply race for - visible panes. Never. - -## Mechanism: forwarded emulator onData, not a new grammar - -`HeadlessEmulator` has `onData` wiring behind a per-write capture flag. -For static and model-state queries, xterm core **is** the query grammar: the -runtime emulator runs the same xterm version with equivalent options as the -renderer pane, so main's reply set equals the visible renderer's by -construction — verified empirically against the bundled headless build: -DA1/DA2, DSR 5n, CPR, DECRPM (including unknown-mode `0`), DECRQSS (including -DECSCUSR from cursor options), XTVERSION, kitty `CSI ? u` all reply; XTWINOPS -(`windowOptions` stays default-off) and XTGETTCAP stay silent, matching -visible behavior today. The headless build has **no theme service**: OSC -4/10/11/12 queries and DSR ?996n return nothing even with the `theme` option -set, so the view-attribute class is answered by responder-registered parser -handlers instead (below) — never by core defaults. - -Forwarding predicate, captured per chunk in `OrcaRuntimeService.onPtyData` and -attached to the emulator `writeChain` link (the mark can flip between -ingestion and an async write; the decision must not be re-read at reply time): - -1. gate enabled (`terminalMainSideEffectAuthority` and - `terminalHiddenDeliveryGate` both on) AND new kill switch - `terminalModelQueryAuthority !== false`; -2. the chunk was hidden-dropped for this PTY (`shouldDropHiddenRendererPtyData` - — same module state, same tick as the drop sites); -3. the write is live PTY data — never `seedHeadlessTerminal`, - `maybeHydrateHeadlessFromRenderer`, option pushes, or any snapshot replay - (main-side replay guard, mirror of the renderer's `replay-guard.ts`); -4. no remote view subscriber is attached to the PTY (runtime terminal-RPC - subscriber records / `mobileSubscribers`): a mobile/web/remote-desktop - xterm receiving the multiplexed stream answers with view authority, exactly - like a visible local pane. Legacy JSON `terminal.subscribe` streams **do** - register as view subscribers and suppress, even when the consumer is a - read-only watcher — deliberately conservative, because the stream may feed - an older live xterm view and a withheld reply (the pre-Phase-5 status quo) - is strictly safer than a double reply. Consumers that never register a - stream (CLI `terminal.read`, automation observers) do not suppress — they - also do not answer; that bounded no-reply case matches today's behavior. - -Mobile streams preserve that singularity across snapshot startup and multiple -views. The mobile WebView suppresses `onData` during snapshot replay, then -enables replies at an explicit live-output boundary. Clearing the WebView drops -that replay queue and resumes live reply authority immediately. If a live query -arrived while the initial snapshot was being built and its output sequence is -covered by that snapshot, runtime RPC re-emits only the bounded query sequence -after the snapshot; ordinary covered output stays deduplicated. `terminal.send` -accepts the resulting `inputKind: query-reply` only from the earliest active -phone-fitted mobile subscriber while mobile owns the terminal driver. Earliest -is determined by preserved `subscribedAt`, so a soft-leave resubscribe does not -change reply ownership; passive desktop-mode watchers are excluded. Peer phones -are rejected, the next subscriber is promoted on unsubscribe, and desktop -parser and capability handlers stay silent until desktop retakes the driver. -Mixed versions: hosts advertise `terminal.query-reply-input.v1`; a mobile -client never forwards replies to a host that lacks it, because such hosts -strip `inputKind` and would treat the bytes as floor-taking shell input. -Residual: a desktop→mobile driver handoff has a bounded double-reply window -until the async driver-change event reaches the desktop renderer's cached -driver map (`isPtyLocked`). - -Everything the emulator emits outside a forwarding window is discarded, which -also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes -triggered by option mutations). - -## Reply classes - -| Class | Queries | Answer source | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state | -| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative | -| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** | -| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options | -| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden | -| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard | - -### View-attribute bridge - -Renderer→main push, `pty:terminalViewAttributes` — one global snapshot, -not per-PTY: the composed terminal `ITheme` (from -`applyTerminalAppearance`, `terminal-appearance.ts`), -`terminalCursorStyle`, `terminalCursorBlink`, and the resolved color-scheme -mode (`resolveTerminalColorSchemeMode` — the same source as the existing -hidden 2031 reply). Pushed on renderer startup and on every theme/settings -apply. - -Main consumes it two ways: - -- `cursorStyle`/`cursorBlink` are applied to every runtime emulator's options - inside the replay guard; xterm core then answers DECRQSS DECSCUSR and - DECRQM 12 with renderer-true values (verified working headless). -- Palette and color-scheme replies come from responder-registered parser - handlers on the emulator (`registerOscHandler` 4/10/11/12, - `registerCsiHandler` for DSR ?996n), because the headless core cannot - answer them. The OSC handlers see SET payloads too, so runtime OSC - 4/10/11/12 mutations (and 104/110/111/112 resets) from the byte stream are - tracked per PTY and layered over the pushed base palette — matching what - the renderer's theme service reports for a visible pane. - -Staleness rules: replies use the last push; a theme flip is stale for at most -one IPC hop (subscribed TUIs are corrected by the 2031/997 flip push). -**Before the first push main answers no view-attribute query** — a fabricated -default would resurrect the default-black OSC-11 bug; silence is the -documented hidden status quo. - -### Kitty keyboard flags - -`vtExtensions.kittyKeyboard: true` is enabled in `HeadlessEmulator`, matching -`buildDefaultTerminalOptions` (`pane-terminal-options.ts:50`). Risk is low: -for the write-only daemon use, keyboard state never alters serialization; the -change only makes the emulator parse `CSI =/>/< u` pushes instead of ignoring -them, and lets the responder answer `CSI ? u` with the flags the hidden app -actually pushed. Snapshot parity: add `kittyKeyboardFlags` to `TerminalModes` -for emulator re-seed parity only. `rehydrateSequences` must **not** push kitty -flags into a renderer xterm — `POST_REPLAY_REATTACH_RESET`'s deliberate kitty -reset (stale CSI-u Ctrl+C hazard, `terminal-replay-cursor-state.test.ts`) -stays authoritative. Slice 3 wires the re-seed consumer: the daemon -warm-reattach snapshot threads `modes.kittyKeyboardFlags` through the spawn -result into `seedHeadlessTerminal`, which applies them to the fresh runtime -emulator via its own `CSI = flags ; 1 u` parse (outside any forwarding -window), so hidden `CSI ? u` reports the flags the hidden app actually -pushed. Paths without a snapshot (cold restore spawns a fresh shell) answer -`?0u`; protocol-conformant programs re-push. - -### ConPTY DA1 variant - -The provider kind is known main-side: mirror `isLocalNativeWindowsPty` -(`windows-pty-compatibility.ts:59`) from the spawn record (local/daemon -provider, `win32`, not WSL). For such PTYs register a CSI `c` override on the -emulator parser (the main-side twin of -`installConptyDeviceAttributesHandler`) replying `CSI ?61;4c`, still gated by -the forwarding predicate. The override is installed at emulator creation and -retrofitted when the spawn mark lands (daemon stream data can create the -emulator before the awaited spawn response marks the PTY). ConPTY blocking on -a missing DA1 is a spawn-time hazard; the hidden-at-spawn loss window is -closed by the slice-3 `initiallyHidden` spawn flag (races section). - -## Suppression: when main never replies - -- Visible or unmarked PTY (chunk was delivered). -- Renderer delivery interest registered (chunk was delivered to a sidecar). -- Remote-runtime (`remote:`) PTYs — never markable - (`isHiddenDeliveryGateManagedPty`), bytes never transit local main. -- Remote view subscriber attached (mobile/web/remote desktop owns replies). -- Seed/hydration/snapshot writes into the emulator, and option pushes. -- Kill switches off — no marks exist, and `terminalModelQueryAuthority` is an - independent off switch for the responder alone. -- The **daemon** emulator: never, under any setting. The responder lives in - main's runtime only; `session.test.ts:163-190` stays pinned verbatim. - -## Transition races - -Worst cases, per direction: - -- **visible→hidden**: chunks delivered between the visibility flip and the - mark landing in main are hidden-skipped by the renderer write path without - query scanning. No reply, no duplicate — identical to the pre-Phase-4 hidden - skip behavior, bounded by one renderer→main IPC hop. After the mark lands, - main answers everything it drops. -- **hidden→visible**: unmark consumes the drop latch and emits the restore - marker; the snapshot replay is replay-guarded, so queries main already - answered are never re-answered from the snapshot; post-unmark live chunks - are answered by xterm once (restore-queued live chunks reply late, not - twice). -- **Split queries across the drop/deliver boundary**: neither parser saw the - whole sequence → no reply; the restore marker resets renderer cross-chunk - state and replay hygiene resets the parser. At-most-once holds. - -Safe-side rule per class: duplicates are structurally impossible (one decision -point per chunk); where the race costs anything it costs a missing reply. -That is acceptable for state queries (DSR/CPR/DECRPM — TUIs re-probe or -tolerate silence, as they did for every hidden pane before this phase). The -one blocking-on-no-reply sequence, ConPTY DA1, only fires at spawn. A visible -pane answers it from the renderer xterm. A PTY spawned hidden previously had -no answerer until the renderer's hidden mark landed in main (one IPC hop -after spawn). Slice 3 closes that window with the `initiallyHidden` -spawn-record flag: the renderer declares hidden-at-spawn on `pty:spawn` -(never for remote-runtime transports), and main marks the PTY hidden before -the first byte — pre-spawn for -daemon-host sessions whose id is minted up front, immediately after -`provider.spawn` resolves otherwise — so the gate and responder own queries -from byte one. The pane's first visibility sync then re-marks or unmarks -through the existing Phase-4 machinery (unmark emits the restore marker for -any spawn-window drops). - -## Invariants - -1. Exactly one party may answer any query, chosen by the chunk's delivery - decision: delivered → the consuming live view's xterm; dropped → main's - model responder; replayed/seeded → no one. The decision is captured once, - synchronously, at ingestion. -2. Main answers only from live PTY bytes parsed by the runtime emulator — - never from snapshot, seed, hydration, or option-push writes. -3. View-attribute answers are renderer-true or absent: no reply is ever - fabricated from emulator defaults (the OSC-11 lesson). -4. The daemon emulator stays write-only; daemon subprocess query writes stay - zero (`session.test.ts` pins are permanent). -5. Reply parity is structural for static and model-state classes: same xterm - core, equivalent options, no hand-rolled grammar — the only overrides are - the documented ConPTY DA1 variant and the view-attribute parser handlers - the headless core cannot serve. -6. Remote views keep view authority; main yields whenever a remote view - subscriber is attached. When multiple desktop/mobile views coexist, the - terminal driver and server-side mobile election keep one reply writer. - -**Contract amendment** — `terminal-model-view-contract.md` invariant 6 is -replaced by: - -> 6. Terminal query authority is singular and structural: the party that -> writes a chunk into a live terminal answers its queries. Visible renderer -> and remote views keep xterm authority. Chunks dropped by the -> hidden-delivery gate are answered exactly once by the main model -> responder, from runtime-emulator state plus renderer-pushed view -> attributes. Replayed, seeded, or snapshot bytes are answered by no one. -> The daemon emulator never answers. - -The contract's test bullet "headless tracking does not answer DA, DSR, OSC 11, -or theme-sensitive queries" splits into: daemon emulator never answers -(unchanged pins) / runtime responder answers only hidden-dropped chunks. The -side-effect authority matrix row "DECSET 2031 reply — query authority stays -with the view (contract invariant 6)" gains a pointer here; its reply path is -otherwise untouched in this phase. - -## Test strategy - -- Responder unit tests beside `orca-runtime.test.ts`: marked vs unmarked vs - interest-suppressed; each reply class; seed/hydrate silence; remote- - subscriber suppression; ConPTY DA1 variant; kill-switch off; mark flip - between ingestion and async emulator write (captured decision wins). -- Parity harness: shared query byte fixtures through a renderer-configured - xterm (onData capture) and through the responder; assert byte-identical - replies for static + model-state classes, and for view-attribute classes - after an attribute push. -- `session.test.ts:163-190`: assertions stay; the comment is updated to name - the main responder (not "the renderer") as the hidden answerer. -- E2E: hidden `claude /theme` reports the configured theme; hidden TUI - blocked on CPR/DA unblocks while gated; reveal shows no stray reply - fragments (`?1;2c`, `rgb:` …) on the prompt; Windows ConPTY golden and - `terminal-hidden-view-parking.spec.ts` stay green. - -## Cut-offs (shipped as three stacked slices) - -1. **Responder core.** Emulator onData wiring + per-write capture + main - replay guard; kitty flag enable (+ `TerminalModes.kittyKeyboardFlags`); - static + model-state classes; ConPTY DA1 override; remote-subscriber - suppression; `terminalModelQueryAuthority` switch; unit + parity tests. - Main-only — no renderer change. Ships the DA1/CPR/DECRPM unblock. -2. **View-attribute bridge.** `pty:terminalViewAttributes` push, cursor - option application under the guard, responder OSC/DSR parser handlers with - per-PTY palette-mutation tracking, silent-until-push rule, `/theme` e2e. -3. **Contract alignment.** Invariant-6 amendment in the contract doc, test - bullet split, `session.test.ts` comment, side-effect matrix pointer, and - the Phase 6 prerequisites below recorded as accepted. - -## Phase 6 (delete skip grammar + startup window): prerequisites from this design - -Phase 6 is shipped: the renderer hidden-skip eligibility grammar and the 10s -codex startup renderer-query window are deleted. Kill-switch-off hidden panes -fall back to the pre-grammar path — hidden bytes ride the bounded background -scheduler queue; overflow latches the model-snapshot restore — and never run -a per-chunk content scan. - -Accepted and shipped in slice 3 (except where noted): - -- **Mark-before-first-byte** (shipped): panes spawned without a visible view - are hidden-marked at spawn via the `initiallyHidden` flag on `pty:spawn` - (spawn-record flag, not a renderer round trip) so startup queries — - including ConPTY's blocking DA1 — are main-owned from byte zero. Phase 6 - removed the codex exclusion with the window: codex spawns are main-owned - from byte zero too, the responder answering their startup probes. -- **Attributes before spawn** (shipped): the renderer pushes composed view - attributes once at app start (right after settings load, before terminal - reconnect/spawn), so spawn-time view-attribute queries no longer fall into - the silent-until-push rule. Per-pane appearance applies keep re-publishing - through the same deduped publisher. -- **Daemon shell-ready write gating** (verified): responder replies through - `ptyController.write` → daemon `Session.write` are QUEUED pre-ready, never - dropped, and the queue flushes at the shell-ready marker or the 15s - `SHELL_READY_TIMEOUT_MS` bound (`session.ts`). The codex window was removed - with hosted ConPTY golden coverage, unit DA1 parity, and the kill switches - as the safety net; explicit spawn-time e2e on Windows daemon PTYs remains - worth adding. -- With the skip grammar deleted, every chunk is either written to a live - xterm or dropped — the delivered-but-skipped no-reply gap disappears and - the only remaining loss window is the mark IPC race. -- **2031 consolidation** (optional follow-up): move the subscription registry - into the responder (the headless core cannot serve 997 pushes any more than - it can ?996n) and push 997 flips from the attribute cache, retiring the - `2031-subscribe` fact reply, the parked responder, and the parked-tab - theme-flip gap. diff --git a/docs/reference/terminal-session-lifecycle.md b/docs/reference/terminal-session-lifecycle.md deleted file mode 100644 index 0b11fd15557..00000000000 --- a/docs/reference/terminal-session-lifecycle.md +++ /dev/null @@ -1,580 +0,0 @@ -# Terminal Session Ownership and Teardown - -**Status:** Incident fix and pane-authority transfer implemented -**Date:** 2026-07-13 -**Incident:** [`../../pty-exhaustion-agent-session-leak.md`](../../pty-exhaustion-agent-session-leak.md) -**Related contracts:** [`terminal-model-view-contract.md`](./terminal-model-view-contract.md), -[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md) - -## Summary - -Terminal process lifetime must be owned by explicit product intent, never by -whether a React view happens to be mounted. - -Orca currently preserves a PTY when a terminal view detaches, which is correct -for view parking, tab moves, renderer reload, and warm reattach. It also relies -on `TerminalPane` unmount to destroy a PTY after a tab is closed. Hidden-view -parking invalidated that implicit invariant: a parked tab has no mounted pane, -so closing it removes renderer state and observers without terminating the PTY. -Agent resume records survive the same close and later interpret the missing pane -as failed recovery, launching the deliberately closed session again. - -This design establishes one authoritative lifecycle contract: - -- **Close tab or pane:** permanently retire the surface, terminate every PTY it - owns, and revoke every resume record for its panes. -- **Detach or park:** remove only the view connection; preserve the PTY and its - durable identity. -- **Sleep:** intentionally checkpoint resumable agents, terminate the PTYs, and - preserve only the checkpoint needed for wake. -- **Quit or reload:** follow the configured persistence policy; warm detach is - allowed, but it is not a tab close. - -The immediate implementation makes the existing terminal-tab state close the -authoritative compatibility boundary for explicit retirement while adding an -explicit natural-exit reason for callers that are only reconciling an already -dead PTY. A follow-up separates the command and reducer APIs once all direct -store callers have migrated. - -## Implementation status - -The incident patch ships the retirement planner, provider-aware close routing, -unified-only retirement, parked watcher/candidate disposal, resume-authority -revocation, explicit natural-exit handling, late-binding rejection, and direct -background-launch ownership checks. It also adds exact pane retirement and a -persisted physical-to-owner pane-key alias for detach, plus a live Electron -parked-close gate. Focused tests cover local, SSH, split, parked, shared, -paired-host, ordinary runtime, pane-transfer, and late-spawn paths. - -`orca terminal close --tab` extends that same ownership boundary to automation. -With an attached renderer, main sends a request/reply close, the renderer runs -the canonical retirement path, persists a fresh host-partitioned session, and -forces the main store to disk before acknowledging success. Without a renderer, -the runtime applies the equivalent immutable projection to the persisted -aggregate tab, removes every split binding and resume record, safely selects a -surviving browser/editor/terminal surface, stops exact live or durable SSH PTYs, -and flushes before returning. The ordinary `terminal close` pane/session -contract is unchanged. - -This durability boundary does not use replay fences or tombstones. Renderer -session writes are ordered through the same IPC sender, and a pending debounced -write rebuilds from the latest store state, so the forced fresh snapshot is not -followed by a captured pre-close topology patch. Headless closes update the -single main-owned session projection directly. - -Origin-aware defensive expiry remains a follow-up. Immediate deletion on -explicit close fixes the incident without applying a wall-clock policy that -could invalidate intentional long-lived `worktree-sleep` checkpoints. - -## Goals - -1. Closing a terminal tab submits retirement for all exclusively owned local, - daemon, WSL, SSH, or runtime PTYs, including every split pane, whether its - views are mounted or parked. -2. Closing a terminal pane terminates only that pane's PTY; detaching a pane to - another tab preserves its PTY, hook identity, and resume authority under the - new owning pane. -3. Closing a tab permanently removes its agent resume authority. App restart, - worktree activation, mobile wake, and periodic capture must not resurrect it. -4. Parking, tab-group moves, renderer reload, and warm reattach keep their - existing process-preservation behavior. -5. Remote-runtime and web-session surfaces are terminated by their owning host, - not by sending a host-scoped ID to the local PTY provider. -6. Teardown is idempotent and safe under duplicate close requests, late PTY - exits, late agent-hook events, in-flight spawns, and provider disconnects. -7. A future bookkeeping regression cannot grow without bound: unowned defensive - resume records have an origin-aware expiry, and provider-owned detached - sessions gain a bounded orphan policy in the daemon-lease phase. - -## Non-goals - -- Changing terminal output, snapshot, replay, query-response, or hidden-delivery - behavior. -- Killing processes that deliberately daemonize away from a plain user - terminal's process group (nohup-style survivors remain user intent there). - For **agent sessions**, close/kill additionally terminates the snapshotted - descendant tree — including detached-pgid children the PTY's SIGHUP cannot - reach — via `pty-descendant-termination.ts` (bounded fresh snapshot with - same-turn coalescing, SIGTERM, grace window, then identity-safe SIGKILL). - Completed process tables are never reused as signal targets, and identity - checks use C-locale timestamps from the source scan. Later requests start a - fresh same-turn-coalesced successor inside their own deadline instead of - waiting behind older scans. A session is marked as terminating before capture - and keeps request ownership through natural exit, so reattach, duplicate kill, - and graceful-to-immediate upgrade cannot race the snapshot; descendant signals - still require the exact root session/handle to be live. Windows and SSH-hosted - PTYs keep the previous foreground-tree contract for now. -- Changing agent-provider resume commands or permission flags. -- Making a UI close wait for a remote process to exit before the tab disappears. -- Replacing worktree sleep with tab close. Sleep remains resumable by design. -- Claiming that a disconnected SSH relay process is already dead when main can - only tombstone its app-scoped ID. Durable relay-side kill-on-reconnect is a - follow-up. - -## Terminology - -- **Surface:** a terminal tab or a pane within a split terminal tab. -- **View:** a renderer xterm or pane-less parked watcher observing a surface. -- **Session:** the provider-owned PTY identified by a PTY ID. -- **Resume authority:** a `sleepingAgentSessionsByPaneKey` record that permits - Orca to launch an agent-provider resume command. -- **Retire:** permanently close a surface and revoke its process and resume - ownership. -- **Detach:** disconnect a view while preserving the session for reattachment. - -## Open-source prior art - -The lifecycle distinction is established in mature terminal implementations: - -- VS Code's terminal instance disposal calls process-manager disposal, and the - process manager marks the process `KilledByUser` before shutdown. Its separate - `detachFromProcess` path deliberately clears the client reference without - shutting down the process. It also shuts down a process whose asynchronous - creation completes after the manager was disposed. See - [`terminalInstance.ts`](https://github.com/microsoft/vscode/blob/3f5c62a95ddb886424da463b41ac3ac5e45aa04f/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts#L1287-L1325) - and - [`terminalProcessManager.ts`](https://github.com/microsoft/vscode/blob/3f5c62a95ddb886424da463b41ac3ac5e45aa04f/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts#L207-L239). -- tmux exposes the same contract as separate commands: `detach-client` removes - a client while leaving the session intact, whereas `kill-session` destroys - the session and its windows. See - [`tmux.1`](https://github.com/tmux/tmux/blob/42b3ea0d7411acb4cd0357a3c2829d986b455918/tmux.1#L1177-L1218). - -Orca needs the same semantic split, extended across local, WSL, SSH, persistent -daemon, and remote-runtime ownership. The important precedent is the explicit -intent boundary, not a particular framework or process API. - -## Non-negotiable invariants - -1. View lifetime and process lifetime are independent. Mount and unmount may - attach or detach observers; they do not decide whether a session should live. -2. Every live PTY has an owning surface or an explicit bounded detached state. -3. A user close is terminal: once accepted, no status event, session snapshot, - or runtime replay may recreate the closed surface or resume its agent. -4. A tab's retirement candidates are the union of its live index, tab row, - persisted split layout, last-known relay ID, deferred SSH ID, and pending - reconnect ID. A candidate is killable only when no other live surface claims - it. -5. Provider routing follows the execution owner: - - local/daemon/WSL and app-scoped SSH IDs use `pty.kill`; - - `remote:` runtime mirror IDs are never passed to the local PTY provider; - - paired web tabs are closed through the host session RPC. -6. Teardown requests are idempotent. Repeated kill, exit-after-kill, and - close-after-exit must converge on the same empty state. -7. Resume-record validity is origin-aware. Intentional `worktree-sleep` records - are not expired by agent-status freshness; unowned defensive `live` and - `quit` records use a documented decision-time horizon. -8. Natural process exit and explicit user retirement are distinct intents. - Reconciliation after an exit must not silently adopt destructive user-close - semantics. - -## Lifecycle matrix - -| Intent | View | PTY | Resume record | Persistent tab state | -| --------------------- | ------------------------------------------ | ---------------------------------- | --------------------------------------------------- | -------------------- | -| Switch tabs/worktrees | hidden or parked | keep | keep/update | keep | -| Move pane/tab group | detach, then remount | keep | keep | move | -| Renderer reload | detach | keep when warm persistence applies | keep | keep | -| App quit | detach or terminate per persistence policy | policy-dependent | capture live agents | keep | -| Sleep worktree | unmount | terminate | capture intentional sleep checkpoint | keep identifiers | -| Close split pane | destroy | terminate that pane | delete that pane record | remove pane | -| Close terminal tab | destroy or already absent | terminate all tab PTYs | delete all tab records | remove tab | -| Remove worktree | destroy | terminate all worktree PTYs | delete all worktree records | remove worktree | -| PTY exits naturally | disconnect | already dead | clear or retain completed evidence per agent policy | reconcile surface | - -## Current failure - -The current close path deletes `ptyIdsByTabId[tabId]` and the tab layout, then -relies on a mounted `TerminalPane` cleanup to call `transport.destroy()`. A -parked tab has already run the detach branch and has no mounted cleanup left. -`disposeParkedTabWatchers` only unregisters observers. The provider session is -therefore alive after Orca discards its last renderer-side owner. - -The same state close drops live agent status but not -`sleepingAgentSessionsByPaneKey`. Worktree activation intentionally fresh-resumes -records that no preserved pane can own. A deliberately closed pane therefore -looks like a failed restore. The existing age check compares `capturedAt` with -`updatedAt`; that detects a status that was stale at capture time but never -expires a record as wall time advances. - -## Proposed architecture - -### 1. Authoritative tab retirement plan - -Before deleting any tab-scoped state, build a `TerminalTabRetirementPlan` from -one store snapshot: - -```ts -type TerminalTabRetirementPlan = { - tabId: string - worktreeId: string | null - ptyIds: string[] - localOrSshPtyIds: string[] - runtimeTerminals: Array<{ environmentId: string; handle: string }> - sharedPtyIds: string[] - paneKeys: string[] -} -``` - -`ptyIds` is the deduplicated union of: - -- `ptyIdsByTabId[tabId]`; -- the tab row's legacy `ptyId`; -- every value in `terminalLayoutsByTabId[tabId].ptyIdsByLeafId`; -- `lastKnownRelayPtyIdByTabId[tabId]`; -- `deferredSshSessionIdsByTabId[tabId]`; -- `pendingReconnectPtyIdByTabId[tabId]`. - -This snapshot must be built before any of those maps are pruned. PTY IDs are -classified structurally with `parseRemoteRuntimePtyId`, not by assuming a host -or platform from the current active workspace. Before shutdown, the planner -subtracts IDs referenced by another tab row, live index, split layout, or relay -and reconnect map. A partially completed pane move must not let closing the -source tab kill the target tab's session. - -The immediate compatibility boundary is `closeTab`, because several production -paths still call the store action directly. Its options carry an explicit -`reason: 'user' | 'pty-exit' | 'cleanup'`; `user` and `cleanup` retire, while -`pty-exit` reconciles an already-dead session without killing siblings or -revoking crash-recovery policy by accident. Making only the rendered tab-bar -action destructive would leave background launch cleanup, floating terminal, -onboarding, runtime notifications, and future direct callers vulnerable. - -### 2. Teardown ordering - -A local tab retirement executes in this order: - -1. Snapshot the retirement plan and its execution-owner classifications. -2. Mark the tab recently closed so late agent-hook events cannot reintroduce - status or resume authority. -3. Dispose the whole tab's parked registry entry and captured candidates, and - silence teardown side-effect handlers while preserving deterministic exit - observation. -4. Submit `pty.kill` for exclusive local or SSH IDs and `terminal.close` for - exclusive ordinary runtime handles. A paired host-session close remains - owned by `session.tabs.close` and is not duplicated locally. -5. Atomically remove the tab, layout/binding maps, agent status, and resume - records from renderer state. -6. Let subsequent React unmount cleanup run idempotently. It is no longer the - process-lifetime authority. - -Steps 3 and 4 are issued before the state loses its IDs. The UI state mutation -remains synchronous and does not await provider exit. Completion is observed -with `Promise.allSettled` so rejections cannot become unhandled promises. The -current Phase 1 fallback for a rejected retirement is provider `listSessions` -plus Resource Manager orphan cleanup; durable retry inventory belongs to the -main-owned retirement phase. - -Remote-runtime mirrors are different: their `remote:` IDs are excluded from -local `pty.kill`. Ordinary runtime terminals retire through `terminal.close`. -Paired host-session tabs retire through `session.tabs.close`, which owns the -entire host tab graph. The local mirror may be pruned optimistically only after -the host close intent is recorded, as it is today. - -### 3. Resume-authority revocation - -Tab retirement removes every sleeping record whose: - -- map key begins with `${tabId}:`; or -- record has `tabId === tabId`. - -The explicit `record.tabId` check covers migrated or legacy keys whose key no -longer encodes the current tab identity. Sibling tabs and other worktrees retain -their records by reference when unchanged. - -The existing recently-closed tab registry remains the short-lived race guard -for hook events already in flight. It must prevent those events from creating a -new `origin: 'live'` resume record after retirement. Tests must cover both event -orders: close then late status, and status queued in the same turn as close. - -Periodic capture iterates only current live status entries. Once close removes -the live entry and resume record, the periodic pass has nothing to persist. - -### 4. Origin-aware defensive record expiry - -An unowned defensive `live` or `quit` record may expire when: - -```ts -Date.now() - record.capturedAt > DEFENSIVE_AGENT_RESUME_MAX_AGE_MS -``` - -Intentional `worktree-sleep` records are exempt from the agent-status freshness -window. Originless legacy behavior remains unchanged until its migration policy -is explicitly chosen. The same validator must run before desktop activation and -mobile/background wake so one entry point cannot bypass it. - -Expiry is defense in depth, not the primary close mechanism. A correctly closed -record is deleted immediately; it is not retained for 30 minutes. - -### 5. Pane close remains pane-scoped - -`PaneManager.closePane` already distinguishes `reason: 'close'` from -`reason: 'detach'`. Preserve that distinction with explicit pane authority: - -- `close` must clear the exact pane's resume authority, add a pane-scoped late - event tombstone, and destroy the pane transport; -- `detach` must atomically transfer or alias resume ownership because the agent - process retains its immutable source `ORCA_PANE_KEY`. - -The last-pane path routes to terminal-tab retirement so it receives the same -parked, split, SSH, and resume cleanup behavior as a tab-bar close. - -### 6. Provider-side safety net - -Client correctness is necessary but not sufficient for a daemon deliberately -designed to outlive renderer and app processes. Add a follow-up provider-owned -orphan policy: - -- track attachments/ownership leases rather than treating `detach` as a no-op; -- distinguish warm-reattach grace from ownerless retirement; -- reap sessions that have no owner after a bounded grace period; -- never idle-reap a session merely because it produces no output; -- scope leases to native, WSL, SSH provider, relay connection, or runtime host; -- expose orphan count and oldest orphan age in Resource Manager diagnostics. - -This hardening is not required to land the immediate close fix, because a lease -protocol changes daemon compatibility and needs its own migration. It is the -defense against a future client bookkeeping regression. - -## Provider behavior - -| Provider/session kind | Close operation | Notes | -| ----------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------- | -| Local native | `pty.kill(id)` | Main routes to the owning local/daemon provider. | -| Persistent daemon | `pty.kill(id)` | Explicit close overrides warm-reattach persistence. | -| Windows ConPTY | `pty.kill(id)` | Provider shutdown owns Windows process-tree semantics. | -| WSL | `pty.kill(id)` | Route by PTY ownership; do not infer from path separators. | -| SSH | `pty.kill(appScopedId)` | Main resolves the connection-specific SSH provider. | -| Disconnected SSH | `pty.kill(appScopedId)` | Tombstones locally and prevents fallback; relay-side death requires reconnect-aware follow-up. | -| Ordinary remote runtime | host `terminal.close` | Classify environment and handle from the `remote:` ID. | -| Paired web host tab | host `session.tabs.close` | Host owns the complete tab and pane graph. | -| Web client local mirror | optimistic prune after close intent | Host snapshot confirms final removal. | - -## Failure and race handling - -### Duplicate close - -The first close snapshots and retires the tab. Later closes find no target and -no-op. Provider shutdown is idempotent for an already exited session. - -### PTY exit races close - -Observers are unregistered before shutdown. If an exit event was already -queued, the recently-closed tab guard and missing tab make it a no-op. It must -not recreate a tab, completion row, resume record, or notification. - -### Spawn completes after close - -The transport already kills a PTY when its spawn resolves after `destroyed` is -set, but the two direct background launchers do not use that transport. Each -direct launcher must revalidate tab ownership immediately after `pty.spawn` or -`terminal.create` resolves and before writing any binding, layout, eager buffer, -subscription, or mount request. A retired local or SSH result is killed; a -retired runtime result is closed through `terminal.close`. - -### Provider shutdown fails - -The tab remains closed; reappearing would violate user intent. Report the -provider class through structured diagnostics and leave the session visible in -provider inventory so existing Resource Manager orphan cleanup can retry. A -durable pending-retirement inventory is a later main-process API and is not -claimed by Phase 1. Do not restore resume authority after a failed terminal -kill. - -### Remote host is unavailable - -Keep the local close intent until either a host snapshot confirms removal or the -existing intent TTL expires. If it expires, the host remains authoritative and -may republish the tab; log the failed close. Never pretend a local mirror prune -terminated the host PTY. - -### App exits during close - -Submit shutdown IPC before discarding the only local PTY identifiers. The main -process/provider owns completion after accepting the request. A future batched -shutdown IPC may return per-ID acceptance, but UI close does not wait for it. - -## Implementation plan - -### Phase 1: correctness patch (implemented) - -1. Add a small store-independent terminal-tab retirement planner that collects - all six ID sources, classifies execution owners, and excludes IDs shared by - another live surface. -2. Add an explicit close reason, update natural-exit callers, and make explicit - `closeTab` retirement dispose the whole parked-tab registry, submit local, - SSH, or runtime shutdown, atomically clear all reconnect maps, and remove - tab-owned resume records. -3. Add post-await owner checks to both direct background launchers so a late - local, SSH, or runtime spawn is terminated before any renderer binding. -4. Defer decision-time expiry until unowned defensive `live` or `quit` records - can be distinguished without changing intentional `worktree-sleep` or legacy - recovery behavior. -5. Add focused planner, store, launcher-race, and resume-policy tests. Keep one - local Electron parked-close proof as an RC release gate. - -### Phase 2: remaining API cleanup - -1. Rename the state-only reducer primitive so `closeTab` cannot ambiguously mean - either UI cleanup or lifecycle teardown. -2. Route all user and runtime close call sites through one domain command. -3. Keep explicit `detach` and `sleep` APIs; do not encode them as close options. -4. Add a development assertion when tab state is removed with provider PTY IDs - but no retirement plan. - -Exact pane retirement, persisted detach authority transfer, and unified-only -terminal routing landed with Phase 1 because exact-head review found they were -required to avoid introducing adjacent close regressions. - -### Phase 3: daemon ownership leases - -1. Define versioned attach/detach/retire lease messages. -2. Preserve warm reattach across renderer/app restarts within a bounded grace. -3. Reap ownerless sessions and expose pressure telemetry. -4. Add compatibility tests across the supported daemon protocol versions and - native/WSL/SSH host isolation. - -## Test plan - -### Unit/store tests - -- Closing an active single-pane tab submits exactly its PTY for shutdown and - removes all tab state. -- Closing a parked tab with no mounted transport still submits its PTY. -- Closing a split tab submits every unique PTY found across tab row, index, and - layout plus relay/deferred/reconnect maps, including IDs present in only one - source. -- Duplicate IDs are submitted once by the authoritative retirement plan. -- An ID referenced by another live tab is not killed when the source tab closes. -- `remote:` IDs are excluded from local PTY shutdown. -- App-scoped SSH IDs are included and are not mistaken for remote runtime IDs. -- All sleeping records for the tab are removed by key prefix or `record.tabId`; - sibling records retain identity. -- Periodic capture after close does not recreate the record. -- Late agent status after close cannot recreate live status or resume authority. -- Closing twice and close-after-exit are no-ops after the first retirement. -- A spawn resolving after close kills the newly returned PTY. -- A defensive live/quit record at the expiry boundary is retained; one - millisecond past it is cleared and never launched, while an older intentional - worktree-sleep record still resumes. - -### Renderer integration tests - -- Mounted tab close still destroys its xterm and terminates the PTY. -- Parked watcher disposal occurs before or with PTY shutdown and emits no final - completion/bell notification. -- Closing one split pane preserves the sibling; closing the last pane retires - the tab. -- Pane-to-tab detach preserves the PTY and resume record. -- Pinned-tab cancellation performs no shutdown; confirmed close does. -- Bulk close (`close others`, `close right`, kill-all) uses the same semantics. - -### Provider/main tests - -- Local, daemon, WSL, ConPTY, and SSH ownership route explicit close to the - correct provider. -- Disconnected SSH close tombstones the app-scoped ID and never falls through to - a local provider. -- Repeated `pty.kill` for the same ID is benign. -- Remote-runtime IDs cannot reach the local kill handler from renderer close. - -### Electron end-to-end regression - -1. Create a worktree terminal and start a deterministic long-lived child. -2. Switch worktrees and wait until the source worktree is cold-parked. -3. Close the parked tab. -4. Assert its exact PTY disappears from `pty:listSessions` and the child exits. -5. Restart/reload Orca and activate the worktree. -6. Assert no terminal or agent resume command is recreated for the closed pane. -7. Repeat with two split panes and with an SSH fixture where CI supports it. - -### Soak and pressure gate - -Run repeated create, park, close, reload, and reopen cycles. App-owned PTY count -must return to baseline after each cycle and remain bounded over the run. Record -the peak and final provider session count in CI artifacts. - -## Observability - -Emit a synchronous scheduled summary and an asynchronous completion summary: - -- tab/worktree identifiers in hashed or existing diagnostic form; -- number of PTY IDs discovered by each source; -- provider classification counts; -- submitted count, then fulfilled/rejected counts after `Promise.allSettled`; -- number of resume records removed; -- whether the tab was parked or mounted when retired. - -After the daemon-lease phase, Resource Manager should distinguish: - -- attached sessions; -- warm-detached sessions eligible for reattach; -- ownerless/orphan sessions; -- pending/failed explicit retirement. - -Alert locally before app-owned PTYs approach platform pressure. The warning is a -defense, not a substitute for lifecycle correctness. - -## Rollout - -1. Land the correctness patch without a feature flag. User close semantics are - restorative behavior, not an experiment. -2. Keep hidden-view parking enabled; disabling it masks the ownership bug and - forfeits its memory benefit. -3. Run focused unit/integration tests plus the live parked-close Electron gate. -4. Cut an RC and soak create/close/restart cycles while monitoring provider and - OS PTY counts. -5. Ship daemon leases separately behind protocol compatibility and telemetry. - -## Alternatives rejected - -### Disable hidden-view parking - -This restores the old accidental unmount behavior at significant memory cost -and leaves every other state-only close path fragile. It does not repair resume -records or daemon ownership. - -### Kill only from `TerminalPane` cleanup - -There is intentionally no pane during parking and some headless/runtime flows. -View cleanup cannot be process authority. - -### Clear only resume records - -This prevents resurrection but still leaks the live PTY until app/daemon exit. - -### Kill only the tab row's `ptyId` - -Split panes and partially reconciled layouts can own multiple IDs. The full -union is required. - -### Rely only on a daemon idle timeout - -Interactive agents can be legitimately idle for hours. Reaping by output -idleness loses user work; ownership, not activity, is the safe signal. - -### Await every provider exit before hiding the tab - -Remote and disconnected providers can take seconds or fail. UI retirement must -be immediate once the user confirms; provider completion is asynchronous and -observable. - -## Phase 1 acceptance criteria - -- A confirmed terminal-tab close submits retirement for every exclusively owned - session Orca can currently reach and reports rejected requests without an - unhandled promise. -- A closed agent session is never resumed by restart, activation, or mobile wake. -- Parking, move/detach, sleep/wake, and warm app reattach retain their documented - behavior. -- Split, local, SSH, and remote-runtime ownership are covered by deterministic - tests; Windows/WSL process-tree behavior remains a live-platform follow-up. -- PTY counts remain bounded across the soak scenario. -- No process-lifetime decision depends on React component mount state. - -## Full-contract follow-ups - -- Durable main-owned retirement intent and bounded retry inventory. -- Disconnected SSH relay kill-on-reconnect. -- Serializable daemon attachment leases with protocol-version fallback. -- Positive ownership validation for late hook events beyond the bounded recent- - close registry. diff --git a/docs/reference/terminal-side-effect-authority.md b/docs/reference/terminal-side-effect-authority.md deleted file mode 100644 index 852a607d5bf..00000000000 --- a/docs/reference/terminal-side-effect-authority.md +++ /dev/null @@ -1,252 +0,0 @@ -# Terminal Side-Effect Authority - -Status: Shipped — Phase 3 of the terminal model/view architecture, kill switch -`terminalMainSideEffectAuthority` (default on). Builds on -[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) and -[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md) (Phase 1). - -## Problem - -Main parses every local/daemon/SSH PTY byte before renderer delivery -(`OrcaRuntimeService.onPtyData` in `src/main/runtime/orca-runtime.ts`: -side-effect tracker, OSC 9999 agent status, headless emulator, tails, URL -watchers; SSH feeds the same path from `wireUpPtyEvents` in -`src/main/ssh/ssh-relay-session.ts`). Before this phase, the side effects -users see — bell unread/notifications, title transitions, agent-complete -notifications, command lifecycle, PR links — were derived a second time by -renderer byte parsers. That duplication forced Phase 1's watcher to parse -bytes, forced main to fabricate synthetic OSC title frames over `pty:data` -just so renderer parsers could see them, and blocked Phase 4 from ever -stopping hidden byte delivery. Phase 3 made main the side-effect parser for -every PTY whose bytes transit local main; the renderer byte parsers -(`createPtyOutputProcessor` in `pty-transport.ts`, the parked watcher's byte -mode) survive only for remote-runtime PTYs and the kill-switch-off fallback. - -## Authority Matrix - -"Main" means parsed once in `onPtyData` and delivered as derived facts. -Remote-runtime PTYs (`remote:`) never transit local main; the renderer -(`remote-runtime-pty-transport.ts:74`) stays their parser permanently. - -| Side effect | local-daemon | SSH | remote-runtime | -| --- | --- | --- | --- | -| OSC 9999 agent status | main (parsed in `onPtyData`, emitted as `agentStatus:set`) | main | renderer (`shouldOwnAgentStatusInRenderer`, `pty-connection.ts`) | -| OSC 0/1/2 titles + working/idle/exited tracker + 3s stale-title timer | main | main | renderer | -| BEL attention (OSC-aware stateful detector) | main | main | renderer | -| OSC 133;D command-finished exit code | main | main | renderer | -| GitHub PR-link scan | main | main | renderer | -| Command Code output scrape | main (per-PTY detector beside the tracker → `command-code-working`/`command-code-done` facts; the renderer pane keeps the done settle timer — it must consult the live status row) | main | renderer | -| DECSET 2031 color-scheme reply | renderer view/watcher — the 2031 fact reply path is untouched by Phase 5; general query authority is now per-chunk structural ownership, see [`terminal-query-authority.md`](./terminal-query-authority.md) (contract invariant 6 as amended) | same | renderer | -| DECSET 2004 paste readiness (`agent-paste-draft.ts`) | renderer — input pacing, not a model side effect | renderer | renderer | - -## Main-Side Tracker - -- The side-effect core shared with the renderer processor lives in - `src/shared/terminal-output-side-effects.ts`: all-titles ordering via - `extractAllOscTitles` (coalesced working→idle transitions are why last-title - is insufficient — issue #1083), `normalizeTerminalTitle`, the literal - `cursor agent` title drop (`CURSOR_NATIVE_TITLE_LOWER`, - `src/shared/agent-detection.ts`), the `createAgentStatusTracker` - transitions, the stale-working-title 3s timer - (`STALE_WORKING_TITLE_TIMEOUT_MS`), and the stateful BEL detector - (`src/shared/terminal-bell-detector.ts`). -- One tracker per PTY on `OrcaRuntimeService`, lazily created like - `agentStatusOscProcessorsByPtyId`; disposed in `onPtyExit` (cancels the - stale-title timer). -- It replaced the chunk-level last-title extraction in `onPtyData`: titles - feed in byte order, so `lastOscTitle`/`lastAgentStatus`, tui-idle waiters, - and pending-message delivery see intermediate transitions instead of only - the chunk's last title. PTY/leaf records keep the **raw** last title - (worktree `ps` and mobile tab titles expect raw); emitted facts carry - `(normalizedTitle, rawTitle)` like `onTitleChange`. -- No deferred drain in main — the renderer's setTimeout(0) batching - (`sideEffectDrainTimer`, `pty-transport.ts`) protects xterm paint, which - does not exist in main. Main applies synchronously and batches the IPC per - flush. -- The stats `AgentDetector` (`src/main/stats/agent-detector.ts`) keeps its own - last-title scan, untouched: synthetic titles must never reach it. - -## Event Transport: `pty:sideEffect` - -One batched main→renderer channel (`window.api.pty.onSideEffect`, -`src/preload/index.ts`). It is **not** routed through the pty dispatcher: -the renderer fact-consumer registry -(`terminal-side-effect-facts-handler.ts`) subscribes directly via -`window.api.pty.onSideEffect` — one channel subscription per renderer, with -exactly one registered fact consumer per PTY. Events are **facts, not -decisions**: `title`, `bell`, `agent-working`, `agent-idle` (with title), -`agent-exited`, `command-finished` (exit code), `pr-link`. Each carries -`ptyId`, main-known attribution (worktreeId/tabId/paneKey from runtime leaf -records, same resolution as `emitTerminalAgentStatusEvents`), and the PTY -`outputSequence`. - -Ordering rules: - -1. Per-PTY in-order; facts from one chunk are emitted in byte order (status - payloads, then titles in sequence, then bell — the renderer drain's order). -2. Deliberately **not** synchronized with `pty:data`: side effects must keep - advancing while renderer delivery is ACK-gated (contract invariant 1). A - completion title may reach the store before the visible xterm paints the - final output; that is acceptable — attention/title state is out-of-band UI - state, and today's renderer drain already decouples by many batches under - timer throttling. -3. No attention replay: facts emitted while no renderer is subscribed are - dropped. On transport attach/park-handoff the renderer pulls a title-only - snapshot (`pty:sideEffectSnapshot`) marked `replay: true` — this reproduces - the eager-buffer behavior where replay restores titles but is barred from - bells/completions (`suppressAttentionEvents`, `pty-transport.ts`). The - store handler ignores a replay title older (by `outputSequence`) than the - last live title fact it applied. - -## Renderer Store Handler (policy stays in the renderer) - -Notification semantics, all preserved across the authority flip: - -- BEL marks worktree+tab unread unconditionally — including the focused pane - (`onBell`, `pty-connection.ts`); pane unread only behind - `experimentalTerminalAttention`; keydown clears unread - (`onTerminalKeyDown`, `pty-connection.ts`). -- BEL's OS notification is delayed 250 ms and yields to a pending - agent-task-complete (`scheduleTerminalBellNotification`, - `pty-connection.ts`). -- working→idle starts the Claude cache timer (null settings = not hydrated, - treat enabled) and schedules completion with 250 ms grace + 1500 ms max - wait + detail-wait store subscription - (`AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS` / - `AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS`, - `agent-task-complete-policy.ts`). -- Completion unread is suppressed only for the exact visible foreground pane - (`isVisibleForegroundPaneKey`, `use-notification-dispatch.ts`); BEL unread - has no such check. -- Dispatch-time liveness/staleness guards - (`dispatchTerminalNotification`, `use-notification-dispatch.ts`) and main's - 5 s per-worktree cooldown (`NOTIFICATION_COOLDOWN_MS`, - `src/main/ipc/notifications.ts`) remain the final gates. - -These need live renderer store state (PTY/layout maps, pane visibility, -settings, `agentStatusByPaneKey`, repo labels), so they stay in the renderer: -the pane-independent per-paneKey handler module -(`terminal-side-effect-facts-handler.ts`) consumes `pty:sideEffect` and -subsumes both `pty-connection.ts`'s callbacks and the parked watcher's -callback block (`sideEffectCallbacks`, `parked-terminal-byte-watcher.ts`) — -one policy path whether the tab is mounted, hidden, or parked. Main holds -**no** notification timers; only the stale-title timer (parser state) lives -in main. - -## Synthetic Frame Reroute - -`driveSyntheticTitleFromHook` and the spinner tick (`sendSyntheticTitle`, -`src/main/index.ts`) feed `runtime.ingestSyntheticTitleFrame(ptyId, data)`, -so synthetic agent-title/BEL frames enter the per-PTY tracker directly — -**not** `onPtyData`, so emulator state, tails, transcripts, and stats never -see them. The decorative-frame visibility gating -(`shouldSendSyntheticTitleFrame`) stands. The legacy synthetic `pty:data` -copy survives only in kill-switch-off mode, where renderer parsers still -need the bytes. The visible xterm renders nothing from titles, but -`pane.terminal.onTitleChange` feeds `registerPtyTitleSource` -(`pty-connection.ts`) → renderer serialize-snapshot `lastTitle` (mobile -parity); main prefers its own tracked title over renderer snapshot -`lastTitle` in both serialize paths. Under main authority synthetic frames -no longer produce phantom ACKs for bytes main never metered (`ackPtyData`, -`pty-dispatcher.ts`). - -## Migration Switch and Double-Fire Prevention - -Authority is structural per PTY kind — the predicate is "bytes transit local -main", exactly the `shouldOwnAgentStatusInRenderer` split -(`pty-connection.ts`). One renderer-consulted kill switch -(`settings.terminalMainSideEffectAuthority`, default on, mirroring -`terminalHiddenViewParking`): when on, IPC transports and the parked watcher -do not register byte parsers for local/SSH and the store handler consumes -`pty:sideEffect`; when off, renderer parsers register and `pty:sideEffect` -events are ignored. Main always parses and emits (its internal consumers need -the tracker regardless); main consults the same setting only to keep the -legacy synthetic-frame `pty:data` path alive while the switch is off. Exactly -one consumer per fact at any time — decided at transport/watcher creation, so -no per-chunk race. - -## Sidecar Consumers and Phase 4 - -Keep renderer byte access (input pacing / raw-output consumers, not side -effects): `agent-paste-draft.ts` (DECSET 2004 readiness), -`launch-agent-background-session.ts` (startup-injection pacing, onData -passthrough), `automation-session-observer.ts` (onData passthrough), and -`parked-terminal-mode2031-responder.ts` (DECSET 2031 theme replies for -parked tabs while the delivery gate is off). Their duplicated local OSC 9999 -store writes are gated off under main authority (the `onAgentStatus` -automation callbacks still fire; only the racing `setAgentStatus` store -writes drop). The Phase-4 hidden-delivery gate exempts PTYs with an active -`subscribeToPtyData` sidecar: registration is auto-surfaced to main as a -ref-counted delivery-interest signal (`pty-delivery-interest.ts`). With main -authoritative, the parked watcher is purely fact-driven: byte parsing exists -only in kill-switch-off mode, and the 2031 reply comes from the -`2031-subscribe` fact when the gate is on (the byte responder sidecar only -when it is off). The watcher file is deleted outright only when the kill -switch retires — it returns as a byte parser only if remote-runtime tabs -ever become parkable. - -## Invariants - -1. Every byte is side-effect-parsed exactly once, by exactly one authority, - chosen structurally per PTY kind. -2. Attention facts never replay: snapshot/eager/attach replays restore title - state only. -3. Notification policy (grace timers, yielding, suppression, dispatch guards) - lives with the renderer store; main emits facts with ordering metadata. -4. Side-effect facts keep flowing while renderer byte delivery is - backpressured, parked, or stopped by the hidden-delivery gate. -5. Synthetic agent frames feed the model tracker, never the emulator, tails, - transcripts, or stats. - -## Test Strategy - -- Parity harness (`terminal-title-tracker-parity.test.ts`): shared byte - fixtures (agent title cycles incl. coalesced chunks, BEL inside/spanning - OSC, CAN/SUB cancellation, cursor-agent literal, stale-title timeout under - fake timers, OSC 133;D, split PR URLs) run through the renderer - `createPtyOutputProcessor` and the main tracker; assert identical ordered - fact sequences. -- Unit: main tracker tests beside `orca-runtime.test.ts` (lastOscTitle - parity, tui-idle waiter transitions, synthetic ingestion); store-handler - tests reusing `parked-terminal-byte-watcher.test.ts` scenarios. -- Pinned tests that flip or retire: `pty-connection.test.ts` callback wiring, - `parked-terminal-byte-watcher.test.ts` (retires with the watcher); - `pty-transport*.test.ts` stay (processor remains for remote + kill switch). -- E2E gates that must stay green throughout: `terminal-attention.spec.ts`, - `droid-notification.spec.ts`, `terminal-hidden-view-parking.spec.ts`, - `terminal-parked-memory.spec.ts`; add main-authority bell/completion cases - (parked tab, focused-pane suppression, kill switch off). SSH parity is - exercised manually per the SSH test procedure before each slice ships. - -## Cut-Offs (shipped as four stacked slices) - -1. **Shared tracker in main.** Extract the processor core to shared, run the - per-PTY tracker in `onPtyData` replacing `extractLastOscTitle`, parity - tests. Main-internal consumers only; no IPC or renderer change. -2. **Authority flip.** `pty:sideEffect` channel, renderer store handler, - titles/bell/tracker authority to main for local+SSH behind the kill - switch; parked watcher stops byte parsing for those kinds. -3. **Inversion unwind.** Synthetic frames into the tracker, off `pty:data`; - OSC 133;D and PR-link facts; mobile `lastTitle` source preference. -4. **Long tail.** Command Code scrape to main, sidecar OSC 9999 dedup, parked - watcher shrunk to fact-driven mode (deletion waits on kill-switch - retirement), Phase 4 delivery-interest registration documented in the gate - design. - -## Open Items - -- **Daemon checkpoint `lastTitle` is write-only.** The daemon sleep/periodic - checkpoint (`daemon-pty-adapter.checkpointSessions` → daemon - `Session.getSnapshot`) persists the daemon emulator's `lastTitle`, which is - derived from real PTY bytes only — synthetic hook title frames never reach - the daemon process, so that field cannot carry hook-driven titles. Today no - restore path reads it back (`ColdRestoreInfo` drops it; reattach snapshots - surface only the ANSI payload), so there is nothing to fix. Main-side - consumers of the renderer serializer's `lastTitle` (mobile snapshot reads - and the headless hydration seed) prefer main's tracked title. If a future - consumer starts reading checkpoint `lastTitle`, it must route through the - same tracked-title preference. -- **Kill-switch retirement.** Once `terminalMainSideEffectAuthority` is - removed, the parked watcher's byte-parser mode, the renderer transport - parsers for local/SSH, and the legacy synthetic-frame `pty:data` copy all - become dead code and the watcher byte path can be deleted outright. diff --git a/docs/reference/terminal-switch-typing-lag-investigation.md b/docs/reference/terminal-switch-typing-lag-investigation.md deleted file mode 100644 index d8f6da5a608..00000000000 --- a/docs/reference/terminal-switch-typing-lag-investigation.md +++ /dev/null @@ -1,362 +0,0 @@ -# Terminal Switch Typing Lag Investigation - -Date: 2026-07-01 - -## Scope - -This note tracks the investigation into terminal input lag that appears after switching workspaces or terminals in a heavy packaged Orca profile. - -The user-visible symptom is that typing after switching back to a workspace can feel delayed for about one second. Text may appear all at once after the delay. The issue reproduced in the user's main packaged Orca app, but not reliably in lighter dev profiles. - -## Current Reproduction Setup - -- Main packaged Orca app is used for the meaningful repro. -- The main profile had roughly 150 terminals when the lag reproduced. -- The current worktree under test is `/Users/jinwoohong/orca/workspaces/orca/osprey`. -- The main app did not expose CDP, so browser-level renderer profiling was limited. -- The harness creates a throwaway bash terminal, switches away and back, sends a marker command, waits for a receipt file, and closes the throwaway terminal. -- Harness path: `.tmp/terminal-main-app-typing-lag/probe.mjs` -- The strongest repro switches through an old, output-rich workspace before typing in the probe terminal. -- Current deterministic alternate terminal: - `term_4d7a0b50-e9ae-420e-ac5d-7ec12cbfa408` in the `triage-issues` workspace. - -Useful harness modes: - -```sh -ORCA_PROBE_RUNS=8 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_TYPE_MODE=terminal-send ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs -ORCA_PROBE_RUNS=8 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_TYPE_MODE=daemon-direct-request ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs -ORCA_PROBE_RUNS=8 ORCA_PROBE_SKIP_SWITCH=1 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_TYPE_MODE=daemon-direct-request ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs -ORCA_PROBE_RUNS=4 ORCA_PROBE_FOCUS_EACH_RUN=0 ORCA_PROBE_SKIP_FOCUS=1 ORCA_PROBE_SAMPLE_RENDERER=0 ORCA_PROBE_ALT_TERMINAL=term_4d7a0b50-e9ae-420e-ac5d-7ec12cbfa408 ORCA_PROBE_TYPE_MODE=daemon-direct-request ORCA_PROBE_RECEIPT_MODE=file node .tmp/terminal-main-app-typing-lag/probe.mjs -``` - -## Key Measurements - -### 2026-07-01 follow-up: cold visit vs warm revisit - -After the metadata-only `listSessions()` fix, the user reported a sharper -pattern: - -- First visit to a terminal, where the terminal briefly shows blank while the - renderer/webgl surface loads, does not show the typing lag. -- Revisiting that same already-mounted terminal brings the lag back. - -Code-level reproduction: - -- `connectPanePty` previously armed a one-shot input liveness check from - `noteVisibilityResume()`. -- The first `xterm.onData` input after a warm visibility resume called - `window.api.pty.listSessions()` before forwarding the byte with - `transport.sendInput(data)`. -- A focused unit regression captured the bad order as - `["listSessions", "sendInput"]`. - -Fix direction: - -- Terminal input no longer starts a renderer→main→daemon session enumeration. -- Hidden→visible lifecycle reconciliation and daemon missing-session exit events - remain responsible for stale pane cleanup. -- The focused regression now asserts that repeated warm resumes plus typing - produce zero `listSessions()` calls from the input handler. - -### 2026-07-01 follow-up: preserved old daemon after input fix - -After the input-handler fix, the main packaged profile still reproduced the -warm-switch lag because the live v18 daemon was preserved from an older build: - -- Fresh main-profile repro: - `.tmp/terminal-main-app-typing-lag/result-2026-07-01T09-59-08-111Z.json` -- Direct daemon writes after switching took 661, 982, 998, 980, and 1000 ms. -- Receipt latency after the daemon write returned stayed low at 77-85 ms. -- No-switch control: - `.tmp/terminal-main-app-typing-lag/result-2026-07-01T09-59-28-346Z.json` - ended with fast direct daemon writes once the probe terminal settled. - -The remaining source hot path was the visibility-resume dead-session sweep: - -1. Switching a warm terminal hidden→visible ran the lifecycle visibility effect. -2. The effect scheduled `reconcileDeadSessions`. -3. `reconcileDeadSessions` invoked `window.api.pty.listSessions()`. -4. A preserved old daemon still implemented `listSessions` by snapshotting every - live session, so the user's next daemon `write` queued behind that work. - -Fix: - -- Replace the automatic visibility-resume `listSessions()` sweep with a targeted - single-session liveness check. -- Main exposes `pty:hasPty(id)`, which reads provider-owned in-memory PTY state - and returns `null` when the provider cannot answer authoritatively. -- Renderer visible-resume asks only about each mounted pane's current PTY id. - The pane tears down only on an authoritative `false`; `true`, `null`, rejected - checks, remote-runtime ids, SSH ids, and stale/newborn races all fail open. -- Keep visibility resume process tracking and PTY-size reassertion intact. -- This preserves the recovery added by `a9ef6f916` for panes that missed - `pty:exit` while hidden, without putting a daemon-wide session enumeration on - the warm-switch/input path. - -Verification: - -- Focused vitest suite: `508` tests passed across terminal lifecycle, - pty-connection, dead-session reconcile, and PTY IPC. -- Headful fullscreen E2E harness: - `tests/e2e/terminal-warm-switch-no-list-sessions.tmp.spec.ts` - wraps main-process `pty:listSessions` with an 800 ms stall and then switches - warm workspaces and types into the terminal. -- Latest E2E artifact: - `.tmp/terminal-warm-switch-no-list-sessions/result-1782929853828.json` - showed `fullscreen: true`, `listSessionCallCount: 0`, and - `postTypeEchoLatencyMs: 10`. - -### CLI `terminal-send`, switch away/back - -Result file: `.tmp/terminal-main-app-typing-lag/result-2026-07-01T06-59-45-098Z.json` - -- Average echo latency was about 1841 ms. -- Receipt latency was roughly 836-1445 ms. - -### CLI `terminal-send`, no switch - -- Average echo latency was about 312 ms. - -### CLI `terminal-send`, switch away/back, 1000 ms settle before send - -- Average echo latency was about 574 ms. -- Receipt latency was mostly 1-227 ms, with one run around 684 ms. -- This suggests the problematic window is bounded and concentrated immediately after switch/resume. - -### Direct daemon request, switch away/back - -Result file: `.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-04-43-497Z.json` - -- The harness opened its own daemon control socket and sent the real daemon `write` request directly. -- Daemon write response time was roughly 947-1142 ms. -- Receipt latency after the daemon write response was about 76 ms. -- Average echo latency was about 1385 ms. - -### Direct daemon request, no switch - -Result file: `.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-05-12-496Z.json` - -- Daemon write response time was 0-45 ms. -- Receipt latency was roughly 76-85 ms. -- Average echo latency was about 258 ms. - -### Direct daemon request, output-rich workspace switch - -Result file: `.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-22-06-789Z.json` - -- The alternate terminal was `term_4d7a0b50-e9ae-420e-ac5d-7ec12cbfa408` in the `triage-issues` workspace. -- All four runs delayed. -- Direct daemon `write` response times were 1566, 1527, 1477, and 1333 ms. -- Average echo latency was about 1790 ms. -- A daemon CPU sample was captured at `.tmp/terminal-main-app-typing-lag/daemon-71111-sample-20260701032206.txt`. - -Latest reproduced run: -`.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-27-25-513Z.json` - -- Two runs after switching through `triage-issues`. -- Direct daemon `write` response times were 1556 and 1582 ms. -- Receipt latency after the daemon write response was about 76-77 ms. -- This confirms the daemon request itself waits; once it returns, the PTY and shell process the input quickly. - -### Direct daemon ping loop during switch - -A direct daemon client sent `ping` requests every roughly 50 ms while the harness issued `orca terminal switch` away and back. - -- Pings before and after the switch were effectively immediate. -- One ping sent around 469 ms after switch start waited 1212 ms. -- No terminal input was involved in this probe. - -This is the strongest evidence so far that workspace/terminal switching creates a daemon event-loop stall. The typing lag is a user-visible symptom of the same stall, not the root trigger. - -### Direct daemon request, no switch - -Latest no-switch result: -`.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-27-58-622Z.json` - -- First run had a direct daemon `write` response of 571 ms, likely a new-probe/settling artifact. -- Next two runs were 0-1 ms. -- No-switch is generally fast once the probe terminal is settled. - -### Pause after switching away - -- Adding `ORCA_PROBE_AFTER_SWITCH_AWAY_MS=1500` before switching back made direct daemon writes fast again. -- This indicates expensive work starts when Orca switches into or resumes the output-rich workspace, then spills into the immediate switch-back/write window. - -Latest pause-control result: -`.tmp/terminal-main-app-typing-lag/result-2026-07-01T07-29-58-568Z.json` - -- Direct daemon `write` response times were 0, 0, and 0 ms. -- Echo latency was about 343-363 ms. -- This bounds the problematic window to roughly the first 1-1.5 seconds after switching into/through the expensive workspace. - -### Synthetic heavy terminals - -- Synthetic split panes with large scrollback were not enough to reproduce reliably. -- A plain heavy scrollback split reproduced once in four runs, then larger synthetic scrollback stayed fast. -- A synthetic TUI repaint split stayed fast. -- The issue is therefore not simply "large output" or "any hidden repaint"; old retained/reattach/snapshot state is still suspect. - -### Output timestamp check - -- `lastOutputAt` did not advance when switching through the `triage-issues` terminal and away. -- That weakens the theory that a resume-triggered SIGWINCH caused the child TUI to emit a fresh repaint burst. -- The delay can happen without fresh PTY output from the child process. - -### Snapshot and resize controls - -Direct `getSnapshot` probes on real sessions were much cheaper than the observed lag: - -- Output-rich `triage` terminal: about 10 ms, roughly 122 KB response. -- Active `osprey` Codex terminal: about 57 ms, roughly 804 KB response. - -Synthetic resize pulses on throwaway heavy-scrollback terminals were also cheap: - -- Background 80x24 resize pulse: about 2 ms. -- Focused 232x86 resize pulse: about 2 ms. - -These controls weaken the idea that one ordinary snapshot or one ordinary resize explains a 1.2-1.6 second stall. The remaining suspicious shape is switch-time fanout: many warm reattachments, snapshots, visibility/resume requests, pending-output drains, or checkpoint-like work running serially in the daemon. - -### Daemon `listSessions` proof - -The daemon-only measurement identified the blocking request: - -```json -[ - { "type": "ping", "elapsedMs": 0 }, - { "type": "listSessions", "elapsedMs": 552, "count": 137 }, - { "type": "ping", "elapsedMs": 0 }, - { "type": "listSessions", "elapsedMs": 547, "count": 137 }, - { "type": "ping", "elapsedMs": 0 } -] -``` - -A second daemon-only queue test sent two `listSessions` requests and then a `ping` from another client: - -```json -{ - "totalMs": 1069, - "listSessions1Ms": 1068, - "listSessions2Ms": 1068, - "pingBehindListSessionsMs": 1034, - "sessions": 137 -} -``` - -This reproduces the same stall shape without terminal input or UI switching: a control request sent behind resume-time `listSessions` waits about one second. - -Source cause: - -- `TerminalHost.listSessions()` loops every live daemon session. -- For each session, it calls `session.getSnapshot()` only to read `cols` and `rows`. -- `getSnapshot()` serializes the headless xterm buffer, so a liveness/session-list request scales with terminal scrollback/state across the whole profile. -- Renderer visibility resume calls `window.api.pty.listSessions()` for dead-session reconciliation. With about 137 live daemon sessions, one resume-time list was about 550 ms; two back-to-back resumes were about 1.0-1.1 seconds. - -## What This Rules Out - -- It is probably not keyboard focus. Direct daemon writes reproduce the delay. -- It is probably not only renderer paint. Direct daemon writes wait before the shell receives bytes. -- It is probably not bash or node-pty readiness. No-switch direct writes are fast, and receipt latency after a delayed daemon response is low. -- It is probably not queueing only on Orca's normal daemon client socket. The direct-daemon harness uses a separate socket and still sees the delay. -- It is not caused by typing itself. A ping loop with no write showed the daemon stall during switching. -- It is unlikely to be a single normal `getSnapshot` or `resize` call, because direct controls for those operations are much cheaper than the observed stall. -- The resume-time dead-pane recovery is still necessary; the hot path should - use single-PTY liveness, not a global session list. - -## Current Conclusion - -Switching workspaces or terminals can create a short daemon/main busy window -when warm resume triggers global session enumeration. During that window, even a -direct terminal `write` request waits before the daemon services it. Once the -daemon services the write, the shell receives and processes the bytes quickly. - -The confirmed root for the remaining warm-switch lag is the visibility-resume -dead-session reconciliation path calling global `listSessions()` in profiles -with many preserved daemon sessions. The original dead-session recovery is valid; -the expensive primitive was the problem. - -## Leading Hypotheses - -1. Confirmed: resume-time dead-session reconciliation called daemon - `listSessions`, and older daemons synchronously snapshot every live session - to return cols/rows. -2. Confirmed: avoiding global `listSessions()` on warm resume removes the - request that queued ahead of first post-switch input. -3. Possible secondary contributor: switching to certain old or output-rich - workspaces may also reattach existing daemon-backed PTYs. The daemon - `createOrAttach` path synchronously calls `existing.getSnapshot()` before - responding. -4. Possible secondary contributor: hidden-output recovery, pending-output - draining, or another snapshot-like serialization path runs synchronously on - resume and delays request handling. -5. Less likely: workspace or terminal resume triggers visible-terminal - resize/SIGWINCH or TUI repaint output. The unchanged `lastOutputAt` - observation currently makes this less likely than `listSessions`. - -## Relevant Code Areas - -- CLI terminal send: `src/cli/handlers/terminal.ts` -- Runtime terminal send/focus/write: `src/main/runtime/orca-runtime.ts` -- Runtime PTY data handling: `onPtyData`, `trackHeadlessTerminalData`, hidden-output serialization in `src/main/runtime/orca-runtime.ts` -- Daemon request routing: `src/main/daemon/daemon-server.ts` -- Daemon session write/resize/output handling: `src/main/daemon/session.ts` -- Daemon-side headless terminal state: `src/main/daemon/headless-emulator.ts` -- Daemon adapter: `src/main/daemon/daemon-pty-adapter.ts` -- Main IPC PTY controller: `src/main/ipc/pty.ts` -- Renderer terminal resume: `src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts` -- Renderer PTY connection and resume size reassertion: `src/renderer/src/components/terminal-pane/pty-connection.ts` -- Renderer output scheduler: `src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts` -- Existing-session reattach snapshot: `TerminalHost.createOrAttach` in `src/main/daemon/terminal-host.ts` -- Session snapshot serialization: `TerminalSession.getSnapshot()` in `src/main/daemon/session.ts` -- Snapshot cost benchmark: `src/main/daemon/headless-emulator-snapshot-cost.bench.test.ts` - -## Current Root-Cause Theory - -The root cause is using global session enumeration as a per-pane liveness check: - -1. Workspace switching makes a terminal pane visible again. -2. The renderer schedules dead-session reconciliation on hidden-to-visible resume. -3. The old reconcile path called `window.api.pty.listSessions()`, which reaches - the daemon `listSessions` RPC. -4. Older preserved daemons implement `TerminalHost.listSessions()` by calling - `session.getSnapshot()` for every live session. -5. With a heavy profile, those snapshot serializations block the daemon event - loop for about 550 ms per list. -6. A switch away/back can put a `write` behind two resume-time lists, yielding - about 1.0-1.6 seconds of delayed input. - -This theory fits the current evidence: - -- Direct daemon writes block, so the delay is below keyboard focus and normal CLI plumbing. -- Receipt handling after the daemon write response is fast, so the shell and PTY are not the bottleneck. -- No-switch writes are fast. -- Waiting after switching away lets the expensive resume work finish, so switching back and typing is fast. -- `lastOutputAt` does not advance, so the child process probably is not generating the expensive work. -- Synthetic fresh heavy output did not reproduce reliably, which points toward retained old session/state, request fanout, or workspace-specific resume behavior rather than simple line count. - -The correct fix is two-layered: - -1. Keep daemon `listSessions` metadata-only for builds where callers genuinely - need the global session list. -2. Do not use `listSessions` for warm-resume dead-pane recovery. Use - `pty:hasPty(id)` to ask about the pane's own PTY id, backed by provider - in-memory state. Close only on authoritative `false`; fail open on `true`, - `null`, unsupported providers, remote-runtime/SSH ids, and stale/newborn - races. - -## Investigation Constraints - -- Do not kill or restart the user's main packaged Orca app or daemon without explicit approval. -- Use throwaway terminals for probes and close them afterward. -- Keep temp harnesses and screenshots out of commits unless explicitly requested. -- Reproduce in the real main-app flow when possible; lighter dev profiles may not show the problem. - -## Verification Plan - -1. Unit-test that `pty:hasPty(id)` does not call provider `listProcesses()`. -2. Unit-test that targeted liveness still closes a missing local PTY and fails - open for live/unknown/stale cases. -3. Unit-test that terminal input and warm resume do not call `listSessions()`. -4. Re-run the headful fullscreen warm-switch E2E with `pty:listSessions` - artificially delayed and assert the count stays zero. -5. Re-run the main-app direct-daemon switch/no-switch harness when validating - against the user's heavy profile. diff --git a/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts b/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts index 8ce1191722d..77db320650c 100644 --- a/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts +++ b/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts @@ -19,8 +19,8 @@ import { writeChunksToTerminal } from '../../shared/terminal-restore-parity-fixture' -// Differential garble gate for the hidden-terminal model/view contract -// (docs/reference/terminal-model-view-contract.md): with the hidden-delivery +// Differential garble gate for the hidden-terminal model/view contract: +// with the hidden-delivery // gate on, a hidden pane receives NOTHING — main's HeadlessEmulator is the // source of truth and reveal repaints the renderer xterm from // preamble + rehydrateSequences + snapshotAnsi (applyMainBufferSnapshot). diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 767c9904a6c..e1950d9caf5 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -131,7 +131,7 @@ export class HeadlessEmulator { // the visible casualty). Only main's runtime per-PTY emulators pass a // sink, and even then replies flow only for chunks the hidden-delivery // gate DROPPED, where the renderer never sees the bytes and main is the - // single answerer. See docs/reference/terminal-query-authority.md. + // single answerer under main's terminal query authority. this.onQueryReply = opts.onQueryReply ?? null if (this.onQueryReply) { this.terminal.onData((reply) => this.emitQueryReply(reply)) diff --git a/src/main/daemon/terminal-view-attribute-responder.ts b/src/main/daemon/terminal-view-attribute-responder.ts index 68ff83c349f..ddd04bf11dc 100644 --- a/src/main/daemon/terminal-view-attribute-responder.ts +++ b/src/main/daemon/terminal-view-attribute-responder.ts @@ -1,6 +1,6 @@ /** - * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute - * bridge): OSC 4/10/11/12 and DSR ?996n responder handlers for the runtime + * Phase 5 slice 2 (View-attribute bridge): OSC 4/10/11/12 and DSR ?996n + * responder handlers for the runtime * headless emulator. The headless xterm core has no theme service, so these * handlers compute replies from the renderer's pushed attribute snapshot, * with per-PTY OSC SET mutations layered on top — mirroring exactly what the diff --git a/src/main/ipc/pty-hidden-delivery-gate.ts b/src/main/ipc/pty-hidden-delivery-gate.ts index 77230f7300c..b1f089e385e 100644 --- a/src/main/ipc/pty-hidden-delivery-gate.ts +++ b/src/main/ipc/pty-hidden-delivery-gate.ts @@ -8,7 +8,6 @@ * the existing seq-guarded machinery. Any renderer party that still needs raw * bytes (dispatcher sidecars) registers delivery * interest, which suppresses the gate for that PTY. - * See docs/reference/terminal-side-effect-authority.md (Open Items). */ import type { GlobalSettings } from '../../shared/types' diff --git a/src/main/ipc/skills.test.ts b/src/main/ipc/skills.test.ts index 91c449f751f..4a412f2494d 100644 --- a/src/main/ipc/skills.test.ts +++ b/src/main/ipc/skills.test.ts @@ -3,15 +3,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { handleMock, discoverSkillsMock, + discoverSkillsInWslMock, inventorySkillFreshnessMock, getDefaultWslDistroMock, - getWslHomeMock + getWslHomeMock, + parseWslPathMock } = vi.hoisted(() => ({ handleMock: vi.fn(), discoverSkillsMock: vi.fn(), + discoverSkillsInWslMock: vi.fn(), inventorySkillFreshnessMock: vi.fn(), getDefaultWslDistroMock: vi.fn(), - getWslHomeMock: vi.fn() + getWslHomeMock: vi.fn(), + parseWslPathMock: vi.fn() })) vi.mock('electron', () => ({ @@ -27,13 +31,27 @@ vi.mock('../skills/discovery', () => ({ discoverSkills: discoverSkillsMock })) +vi.mock('../skills/skill-discovery-wsl', () => ({ + discoverSkillsInWsl: discoverSkillsInWslMock +})) + vi.mock('../skills/skill-freshness-inventory', () => ({ inventorySkillFreshness: inventorySkillFreshnessMock })) vi.mock('../wsl', () => ({ getDefaultWslDistro: getDefaultWslDistroMock, - getWslHome: getWslHomeMock + getWslHome: getWslHomeMock, + parseWslPath: parseWslPathMock, + toLinuxPath: (pathValue: string) => { + if (pathValue === '\\\\wsl.localhost\\Ubuntu\\home\\alice') { + return '/home/alice' + } + if (pathValue === 'C:\\repo\\worktree') { + return '/mnt/c/repo/worktree' + } + return pathValue + } })) import { registerSkillsHandlers } from './skills' @@ -48,9 +66,13 @@ describe('registerSkillsHandlers', () => { beforeEach(() => { handleMock.mockReset() discoverSkillsMock.mockReset() + discoverSkillsInWslMock.mockReset() getDefaultWslDistroMock.mockReset() getWslHomeMock.mockReset() + parseWslPathMock.mockReset() + parseWslPathMock.mockReturnValue(null) discoverSkillsMock.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 }) + discoverSkillsInWslMock.mockResolvedValue({ skills: [], sources: [], scannedAt: 1 }) inventorySkillFreshnessMock.mockResolvedValue({ schemaVersion: 1, installations: [], @@ -139,10 +161,35 @@ describe('registerSkillsHandlers', () => { expect(getDefaultWslDistroMock).not.toHaveBeenCalled() expect(getWslHomeMock).toHaveBeenCalledWith('Ubuntu') - expect(discoverSkillsMock).toHaveBeenCalledWith({ - repos: [], - homeDir: '\\\\wsl.localhost\\Ubuntu\\home\\alice', - cwd: '\\\\wsl.localhost\\Ubuntu\\home\\alice' + expect(discoverSkillsInWslMock).toHaveBeenCalledWith({ + distro: 'Ubuntu', + homeDir: '/home/alice', + cwd: '/home/alice' + }) + }) + + it('scans the requested project directory in the selected WSL runtime', async () => { + const handler = getDiscoverHandler() + + await handler(null, { + cwd: 'C:\\repo\\worktree', + projectRuntime: { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'repo-1', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'repo-1:wsl:Ubuntu' + } + } + }) + + expect(discoverSkillsInWslMock).toHaveBeenCalledWith({ + distro: 'Ubuntu', + homeDir: '/home/alice', + cwd: '/mnt/c/repo/worktree' }) }) diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index 711162927fe..c349af8b24e 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -1,60 +1,23 @@ import { app, ipcMain } from 'electron' import type { Store } from '../persistence' -import { discoverSkills } from '../skills/discovery' -import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills' +import { + SkillDiscoveryTargetSchema, + type SkillDiscoveryResult, + type SkillDiscoveryTarget +} from '../../shared/skills' import type { SkillFreshnessInventory } from '../../shared/skill-freshness' -import { getDefaultWslDistro, getWslHome } from '../wsl' import { inventorySkillFreshness } from '../skills/skill-freshness-inventory' - -type SkillDiscoveryRuntimeTarget = - | { runtime: 'host' } - | { runtime: 'wsl'; wslDistro: string | null | undefined } - -function getSkillDiscoveryRuntimeTarget( - target: SkillDiscoveryTarget | undefined -): SkillDiscoveryRuntimeTarget { - const projectRuntime = target?.projectRuntime - if (!projectRuntime) { - return target?.runtime === 'wsl' - ? { runtime: 'wsl', wslDistro: target.wslDistro } - : { runtime: 'host' } - } - - if (projectRuntime.status === 'repair-required') { - throw new Error( - `Project runtime requires repair before skill discovery: ${projectRuntime.repair.reason}` - ) - } - - if (projectRuntime.runtime.kind === 'wsl') { - return { runtime: 'wsl', wslDistro: projectRuntime.runtime.distro } - } - - return { runtime: 'host' } -} +import { + discoverSkillsOnTarget, + resolveSkillDiscoveryTarget +} from '../skills/skill-discovery-target' export function registerSkillsHandlers(store: Store): void { ipcMain.handle( 'skills:discover', async (_event, target?: SkillDiscoveryTarget): Promise => { - const runtimeTarget = getSkillDiscoveryRuntimeTarget(target) - if (runtimeTarget.runtime === 'wsl') { - if (process.platform !== 'win32') { - throw new Error('WSL skill discovery is only available on Windows.') - } - const distro = runtimeTarget.wslDistro?.trim() || getDefaultWslDistro() - if (!distro) { - throw new Error('No WSL distribution is available for skill discovery.') - } - const homeDir = getWslHome(distro) - if (!homeDir) { - throw new Error(`Could not resolve the WSL home directory for ${distro}.`) - } - return discoverSkills({ repos: [], homeDir, cwd: homeDir }) - } - - const cwd = target?.cwd?.trim() || undefined - return cwd ? discoverSkills({ repos: [], cwd }) : discoverSkills({ repos: store.getRepos() }) + const parsedTarget = target ? SkillDiscoveryTargetSchema.parse(target) : undefined + return discoverSkillsOnTarget(resolveSkillDiscoveryTarget(parsedTarget), store.getRepos()) } ) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 17fcec9ba87..9231beb040a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -8233,6 +8233,16 @@ export class OrcaRuntimeService { return pty ? { worktreeId: pty.worktreeId, connectionId: pty.connectionId } : null } + // Why: remote clients cannot resolve this runtime's WSL project preference, + // so host-affecting RPCs (skill discovery) resolve it from the owning store. + resolveProjectRuntimeForWorktree( + worktreeId: string | null | undefined + ): ProjectExecutionRuntimeResolution | undefined { + return this.store && worktreeId + ? resolveLocalProjectRuntimeForWorktreeId(this.requireStore(), worktreeId) + : undefined + } + getTerminalOrchestrationCliCommand(handle: string): 'orca' | 'orca-ide' { let pty: RuntimePtyWorktreeRecord | null = null try { diff --git a/src/main/runtime/rpc/methods/skills.test.ts b/src/main/runtime/rpc/methods/skills.test.ts new file mode 100644 index 00000000000..a4335ec2e62 --- /dev/null +++ b/src/main/runtime/rpc/methods/skills.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RpcContext } from '../core' + +vi.mock('../../../skills/skill-discovery-target', () => ({ + resolveSkillDiscoveryTarget: vi.fn((target) => ({ kind: 'native-host', cwd: target?.cwd })), + discoverSkillsOnTarget: vi.fn(async () => ({ skills: [], sources: [], scannedAt: 1 })) +})) + +import { SKILL_METHODS } from './skills' +import { resolveSkillDiscoveryTarget } from '../../../skills/skill-discovery-target' + +const WSL_RUNTIME = { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + projectId: 'project-1', + distro: 'Ubuntu', + reason: 'project-override', + cacheKey: 'wsl:Ubuntu' + } +} as const + +function makeContext(overrides: { + resolveProjectRuntimeForWorktree?: (worktreeId: string | null | undefined) => unknown +}): RpcContext { + return { + runtime: { + listRepos: () => [], + resolveProjectRuntimeForWorktree: + overrides.resolveProjectRuntimeForWorktree ?? (() => undefined) + } + } as unknown as RpcContext +} + +function discoverMethod() { + const method = SKILL_METHODS.find((entry) => entry.name === 'skills.discover') + if (!method) { + throw new Error('skills.discover method not registered') + } + return method +} + +describe('skills.discover RPC', () => { + it('resolves the project runtime from the owning runtime store when the caller omits it', async () => { + const resolveProjectRuntimeForWorktree = vi.fn(() => WSL_RUNTIME) + await discoverMethod().handler( + { cwd: 'C:\\repo', worktreeId: 'worktree-1' }, + makeContext({ resolveProjectRuntimeForWorktree }) + ) + expect(resolveProjectRuntimeForWorktree).toHaveBeenCalledWith('worktree-1') + expect(vi.mocked(resolveSkillDiscoveryTarget)).toHaveBeenLastCalledWith( + expect.objectContaining({ projectRuntime: WSL_RUNTIME }) + ) + }) + + it('prefers a caller-supplied project runtime over store resolution', async () => { + const resolveProjectRuntimeForWorktree = vi.fn() + await discoverMethod().handler( + { cwd: '/repo', worktreeId: 'worktree-1', projectRuntime: WSL_RUNTIME }, + makeContext({ resolveProjectRuntimeForWorktree }) + ) + expect(resolveProjectRuntimeForWorktree).not.toHaveBeenCalled() + expect(vi.mocked(resolveSkillDiscoveryTarget)).toHaveBeenLastCalledWith( + expect.objectContaining({ projectRuntime: WSL_RUNTIME }) + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/skills.ts b/src/main/runtime/rpc/methods/skills.ts index 8719cc528ab..346198aeffc 100644 --- a/src/main/runtime/rpc/methods/skills.ts +++ b/src/main/runtime/rpc/methods/skills.ts @@ -1,20 +1,25 @@ -import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' -import { discoverSkills } from '../../../skills/discovery' - -const SkillDiscoveryParams = z.object({ - cwd: z.string().optional().nullable() -}) +import { SkillDiscoveryTargetSchema } from '../../../../shared/skills' +import { + discoverSkillsOnTarget, + resolveSkillDiscoveryTarget +} from '../../../skills/skill-discovery-target' export const SKILL_METHODS: RpcMethod[] = [ defineMethod({ name: 'skills.discover', - params: SkillDiscoveryParams, + params: SkillDiscoveryTargetSchema.default({}), handler: async (params, { runtime }) => { - const cwd = params.cwd?.trim() || undefined - return cwd - ? discoverSkills({ repos: [], cwd }) - : discoverSkills({ repos: runtime.listRepos() }) + // Why: the executing runtime owns WSL project preferences. Remote callers + // send worktree identity only; trusting their projectRuntime absence + // would scan this host's native filesystem for a WSL-configured project. + const target = params.projectRuntime + ? params + : { + ...params, + projectRuntime: runtime.resolveProjectRuntimeForWorktree(params.worktreeId) + } + return discoverSkillsOnTarget(resolveSkillDiscoveryTarget(target), runtime.listRepos()) } }) ] diff --git a/src/main/runtime/terminal-model-query-authority.ts b/src/main/runtime/terminal-model-query-authority.ts index e1e0f9c28ef..df8f05798a4 100644 --- a/src/main/runtime/terminal-model-query-authority.ts +++ b/src/main/runtime/terminal-model-query-authority.ts @@ -1,6 +1,6 @@ /** * Phase 5 of the terminal model/view architecture: main-side terminal query - * authority (docs/reference/terminal-query-authority.md). + * authority. * * The delivery decision is the reply decision: main answers a query iff the * hidden-delivery gate dropped the chunk that carried it. This module owns diff --git a/src/main/runtime/terminal-query-responder.test.ts b/src/main/runtime/terminal-query-responder.test.ts index b6988e06731..896fa59b060 100644 --- a/src/main/runtime/terminal-query-responder.test.ts +++ b/src/main/runtime/terminal-query-responder.test.ts @@ -1,5 +1,5 @@ /** - * Phase 5 model query responder (docs/reference/terminal-query-authority.md): + * Phase 5 model query responder: * reply parity through the runtime emulator, the per-chunk ownership matrix, * the main-side replay guard, and the ingestion-time capture race. */ diff --git a/src/main/runtime/terminal-view-attribute-store.ts b/src/main/runtime/terminal-view-attribute-store.ts index ee55581bfe9..2743dd70e3b 100644 --- a/src/main/runtime/terminal-view-attribute-store.ts +++ b/src/main/runtime/terminal-view-attribute-store.ts @@ -1,6 +1,6 @@ /** - * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute - * bridge): main-side cache of the renderer's `pty:terminalViewAttributes` + * Phase 5 slice 2 (View-attribute bridge): main-side cache of the renderer's + * `pty:terminalViewAttributes` * push. One app-global snapshot, not per-PTY — per-pane font zoom never * affects these attributes and the color/cursor settings are global. * diff --git a/src/main/skills/claude-plugin-skill-sources-wsl.test.ts b/src/main/skills/claude-plugin-skill-sources-wsl.test.ts new file mode 100644 index 00000000000..4d4f1add82c --- /dev/null +++ b/src/main/skills/claude-plugin-skill-sources-wsl.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { + buildWslClaudePluginMetadataCommand, + parseWslClaudePluginMetadataOutput +} from './claude-plugin-skill-sources-wsl' + +function record(...fields: string[]): string { + return `${fields.join('\0')}\0` +} + +describe('WSL Claude plugin metadata', () => { + it('reads metadata paths inside the distro without exposing them to Windows shell parsing', () => { + const command = buildWslClaudePluginMetadataCommand(["/home/alice's/.claude/settings.json"]) + const encoded = /printf %s '([^']+)'/.exec(command)?.[1] + expect(encoded).toBeTruthy() + const script = Buffer.from(encoded!, 'base64').toString('utf8') + + expect(script).toContain(`'/home/alice'\\''s/.claude/settings.json'`) + expect(script).toContain('base64 < "$metadata_path"') + expect(command).not.toContain('/home/alice') + }) + + it('decodes present files and retains missing files as null', () => { + const settings = JSON.stringify({ enabledPlugins: { 'plugin@market': true } }) + const output = [ + record('F', '0', '1', Buffer.from(settings).toString('base64')), + record('F', '1', '0', '') + ].join('') + + expect(parseWslClaudePluginMetadataOutput(output, 2)).toEqual([settings, null]) + }) + + it('rejects malformed metadata responses', () => { + expect(() => parseWslClaudePluginMetadataOutput(record('F', '4', '1', ''), 1)).toThrow( + 'invalid response' + ) + }) +}) diff --git a/src/main/skills/claude-plugin-skill-sources-wsl.ts b/src/main/skills/claude-plugin-skill-sources-wsl.ts new file mode 100644 index 00000000000..d3adddfde55 --- /dev/null +++ b/src/main/skills/claude-plugin-skill-sources-wsl.ts @@ -0,0 +1,100 @@ +import { execFile } from 'node:child_process' +import { posix as pathPosix } from 'node:path' +import { buildEncodedWslBashCommand, quoteBashString } from '../wsl-bash-command' +import { + getClaudePluginMetadataPaths, + resolveClaudePluginSkillSources, + type ClaudePluginMetadata +} from './claude-plugin-skill-sources' +import type { SkillScanRoot } from './skill-discovery-sources' + +const MAX_PLUGIN_METADATA_BYTES = 4 * 1024 * 1024 +const WSL_METADATA_TIMEOUT_MS = 5_000 +const WSL_METADATA_MAX_BUFFER_BYTES = 32 * 1024 * 1024 + +export function buildWslClaudePluginMetadataCommand(paths: readonly string[]): string { + const lines = [ + 'set -u', + 'set -o pipefail', + 'read_metadata() {', + ' metadata_index=$1', + ' metadata_path=$2', + ` metadata_size=$(stat -c '%s' -- "$metadata_path" 2>/dev/null || true)`, + ` if [ -z "$metadata_size" ] || [ "$metadata_size" -gt ${MAX_PLUGIN_METADATA_BYTES} ]; then`, + ` printf '%s\\0%s\\0%s\\0\\0' F "$metadata_index" 0`, + ' return', + ' fi', + ` encoded_metadata=$(base64 < "$metadata_path" | tr -d '\\n') || { printf '%s\\0%s\\0%s\\0\\0' F "$metadata_index" 0; return; }`, + ` printf '%s\\0%s\\0%s\\0%s\\0' F "$metadata_index" 1 "$encoded_metadata"`, + '}' + ] + paths.forEach((pathValue, index) => { + lines.push(`read_metadata ${index} ${quoteBashString(pathValue)}`) + }) + return buildEncodedWslBashCommand(lines.join('\n')) +} + +export function parseWslClaudePluginMetadataOutput( + output: string, + fileCount: number +): (string | null)[] { + const contents = Array(fileCount).fill(null) + const fields = output.split('\0') + let index = 0 + while (index < fields.length && fields[index]) { + const kind = fields[index++] + const fileIndex = Number.parseInt(fields[index++] ?? '', 10) + const exists = fields[index++] === '1' + const encoded = fields[index++] + if (kind !== 'F' || !Number.isInteger(fileIndex) || fileIndex < 0 || fileIndex >= fileCount) { + throw new Error('WSL Claude plugin metadata returned an invalid response.') + } + if (exists && encoded !== undefined) { + contents[fileIndex] = Buffer.from(encoded, 'base64').toString('utf8') + } + } + return contents +} + +function executeWslMetadataRead(distro: string, command: string): Promise { + return new Promise((resolve, reject) => { + execFile( + 'wsl.exe', + ['-d', distro, '--', 'bash', '-c', command], + { + encoding: 'utf8', + maxBuffer: WSL_METADATA_MAX_BUFFER_BYTES, + timeout: WSL_METADATA_TIMEOUT_MS, + windowsHide: true + }, + (error, stdout) => { + if (error) { + reject(error) + return + } + resolve(stdout) + } + ) + }) +} + +export async function discoverClaudePluginSkillSourcesInWsl(args: { + distro: string + homeDir: string + cwd: string +}): Promise { + const paths = getClaudePluginMetadataPaths(args.homeDir, args.cwd, pathPosix) + const orderedPaths = [paths.installedPlugins, ...paths.settings] + // Why: plugin enablement and install paths belong to the distro just like + // SKILL.md identity; reading them through UNC could apply Windows semantics. + const output = await executeWslMetadataRead( + args.distro, + buildWslClaudePluginMetadataCommand(orderedPaths) + ) + const [installedPlugins, ...settings] = parseWslClaudePluginMetadataOutput( + output, + orderedPaths.length + ) + const metadata: ClaudePluginMetadata = { installedPlugins, settings } + return resolveClaudePluginSkillSources({ metadata, cwd: args.cwd, pathApi: pathPosix }) +} diff --git a/src/main/skills/claude-plugin-skill-sources.test.ts b/src/main/skills/claude-plugin-skill-sources.test.ts new file mode 100644 index 00000000000..c12f2e3415c --- /dev/null +++ b/src/main/skills/claude-plugin-skill-sources.test.ts @@ -0,0 +1,122 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { resolveClaudePluginSkillSources } from './claude-plugin-skill-sources' + +function installedPlugins(installs: Record[]>): string { + return JSON.stringify({ version: 2, plugins: installs }) +} + +function settings(enabledPlugins: Record): string { + return JSON.stringify({ enabledPlugins }) +} + +describe('Claude plugin skill sources', () => { + const pluginId = 'compound-engineering@compound-engineering-plugin' + const projectPath = join('/workspace', 'orca') + const cwd = join(projectPath, 'worktree') + + it('selects the applicable project install over a stale user version', () => { + const roots = resolveClaudePluginSkillSources({ + cwd, + metadata: { + installedPlugins: installedPlugins({ + [pluginId]: [ + { + scope: 'user', + installPath: '/home/alice/.claude/plugins/cache/compound/3.13.1', + lastUpdated: '2026-06-23T00:00:00.000Z' + }, + { + scope: 'project', + projectPath, + installPath: '/home/alice/.claude/plugins/cache/compound/3.14.3', + lastUpdated: '2026-06-25T00:00:00.000Z' + } + ] + }), + settings: [settings({ [pluginId]: true })] + } + }) + + expect(roots).toEqual([ + expect.objectContaining({ + label: 'Claude plugin compound-engineering', + path: join('/home/alice/.claude/plugins/cache/compound/3.14.3', 'skills'), + sourceKind: 'plugin', + providers: ['claude'], + owner: 'claude' + }) + ]) + }) + + it('falls back to the user install outside the project scope', () => { + const roots = resolveClaudePluginSkillSources({ + cwd: '/workspace/another-project', + metadata: { + installedPlugins: installedPlugins({ + [pluginId]: [ + { + scope: 'user', + installPath: '/home/alice/.claude/plugins/cache/compound/user' + }, + { + scope: 'project', + projectPath, + installPath: '/home/alice/.claude/plugins/cache/compound/project' + } + ] + }), + settings: [settings({ [pluginId]: true })] + } + }) + + expect(roots[0]?.path).toBe(join('/home/alice/.claude/plugins/cache/compound/user', 'skills')) + }) + + it('honors project and local settings overrides for disabled plugins', () => { + const roots = resolveClaudePluginSkillSources({ + cwd, + metadata: { + installedPlugins: installedPlugins({ + [pluginId]: [ + { + scope: 'project', + projectPath, + installPath: '/home/alice/.claude/plugins/cache/compound/3.14.3' + } + ] + }), + settings: [settings({ [pluginId]: true }), settings({ [pluginId]: false })] + } + }) + + expect(roots).toEqual([]) + }) + + it('chooses the newest applicable install within the same scope', () => { + const roots = resolveClaudePluginSkillSources({ + cwd, + metadata: { + installedPlugins: installedPlugins({ + [pluginId]: [ + { + scope: 'project', + projectPath, + installPath: '/home/alice/.claude/plugins/cache/compound/old', + lastUpdated: '2026-01-01T00:00:00.000Z' + }, + { + scope: 'project', + projectPath, + installPath: '/home/alice/.claude/plugins/cache/compound/new', + lastUpdated: '2026-07-01T00:00:00.000Z' + } + ] + }), + settings: [settings({ [pluginId]: true })] + } + }) + + expect(roots[0]?.path).toBe(join('/home/alice/.claude/plugins/cache/compound/new', 'skills')) + }) +}) diff --git a/src/main/skills/claude-plugin-skill-sources.ts b/src/main/skills/claude-plugin-skill-sources.ts new file mode 100644 index 00000000000..1726cfdc9db --- /dev/null +++ b/src/main/skills/claude-plugin-skill-sources.ts @@ -0,0 +1,219 @@ +import { open, stat } from 'node:fs/promises' +import { basename, isAbsolute, join, relative, sep, type posix } from 'node:path' +import { stablePathId, type SkillScanRoot } from './skill-discovery-sources' +import { stripUnsafeDisplayCharacters } from '../../shared/skill-display-text' + +const MAX_PLUGIN_METADATA_BYTES = 4 * 1024 * 1024 + +export type SkillDiscoveryPathApi = Pick< + typeof posix, + 'basename' | 'isAbsolute' | 'join' | 'relative' | 'sep' +> + +export type ClaudePluginMetadata = { + installedPlugins: string | null + settings: (string | null)[] +} + +type ClaudePluginInstall = { + scope: 'user' | 'project' | 'local' + installPath: string + projectPath?: string + installedAt?: string + lastUpdated?: string +} + +const defaultPathApi: SkillDiscoveryPathApi = { basename, isAbsolute, join, relative, sep } + +export function getClaudePluginMetadataPaths( + homeDir: string, + cwd: string, + pathApi: SkillDiscoveryPathApi = defaultPathApi +): { installedPlugins: string; settings: string[] } { + return { + installedPlugins: pathApi.join(homeDir, '.claude', 'plugins', 'installed_plugins.json'), + // Claude merges user, project, then project-local settings in this order. + settings: [ + pathApi.join(homeDir, '.claude', 'settings.json'), + pathApi.join(cwd, '.claude', 'settings.json'), + pathApi.join(cwd, '.claude', 'settings.local.json') + ] + } +} + +function parseJsonObject(content: string | null): Record | null { + if (!content) { + return null + } + try { + const parsed: unknown = JSON.parse(content) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null + } catch { + return null + } +} + +function readEnabledPlugins(settingsContents: readonly (string | null)[]): Map { + const enabled = new Map() + for (const content of settingsContents) { + const settings = parseJsonObject(content) + const configured = settings?.enabledPlugins + if (!configured || typeof configured !== 'object' || Array.isArray(configured)) { + continue + } + for (const [pluginId, value] of Object.entries(configured)) { + if (typeof value === 'boolean') { + enabled.set(pluginId, value) + } + } + } + return enabled +} + +function parseInstall(value: unknown): ClaudePluginInstall | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const record = value as Record + if ( + (record.scope !== 'user' && record.scope !== 'project' && record.scope !== 'local') || + typeof record.installPath !== 'string' + ) { + return null + } + return { + scope: record.scope, + installPath: record.installPath, + ...(typeof record.projectPath === 'string' ? { projectPath: record.projectPath } : {}), + ...(typeof record.installedAt === 'string' ? { installedAt: record.installedAt } : {}), + ...(typeof record.lastUpdated === 'string' ? { lastUpdated: record.lastUpdated } : {}) + } +} + +function isProjectInstallApplicable( + install: ClaudePluginInstall, + cwd: string, + pathApi: SkillDiscoveryPathApi +): boolean { + if (install.scope === 'user') { + return true + } + if (!install.projectPath || !pathApi.isAbsolute(install.projectPath)) { + return false + } + const relativePath = pathApi.relative(install.projectPath, cwd) + return ( + relativePath === '' || + (relativePath !== '..' && + !relativePath.startsWith(`..${pathApi.sep}`) && + !pathApi.isAbsolute(relativePath)) + ) +} + +function installPriority(install: ClaudePluginInstall): number { + return install.scope === 'local' ? 2 : install.scope === 'project' ? 1 : 0 +} + +function installTimestamp(install: ClaudePluginInstall): number { + const timestamp = Date.parse(install.lastUpdated ?? install.installedAt ?? '') + return Number.isFinite(timestamp) ? timestamp : 0 +} + +function selectActiveInstall( + values: unknown[], + cwd: string, + pathApi: SkillDiscoveryPathApi +): ClaudePluginInstall | null { + return ( + values + .map(parseInstall) + .filter((install): install is ClaudePluginInstall => install !== null) + .filter( + (install) => + pathApi.isAbsolute(install.installPath) && + isProjectInstallApplicable(install, cwd, pathApi) + ) + .sort( + (a, b) => + installPriority(b) - installPriority(a) || + (b.projectPath?.length ?? 0) - (a.projectPath?.length ?? 0) || + installTimestamp(b) - installTimestamp(a) + )[0] ?? null + ) +} + +function safePluginLabel(pluginId: string, pathApi: SkillDiscoveryPathApi): string { + const packageName = pluginId.split('@')[0] || pathApi.basename(pluginId) + const safeLabel = stripUnsafeDisplayCharacters(packageName).slice(0, 80) + return safeLabel || 'plugin' +} + +export function resolveClaudePluginSkillSources(args: { + metadata: ClaudePluginMetadata + cwd: string + pathApi?: SkillDiscoveryPathApi +}): SkillScanRoot[] { + const pathApi = args.pathApi ?? defaultPathApi + const installed = parseJsonObject(args.metadata.installedPlugins)?.plugins + if (!installed || typeof installed !== 'object' || Array.isArray(installed)) { + return [] + } + const enabled = readEnabledPlugins(args.metadata.settings) + const roots = new Map() + for (const [pluginId, rawInstalls] of Object.entries(installed)) { + if (enabled.get(pluginId) !== true || !Array.isArray(rawInstalls)) { + continue + } + const install = selectActiveInstall(rawInstalls, args.cwd, pathApi) + if (!install) { + continue + } + const skillsPath = pathApi.join(install.installPath, 'skills') + if (!roots.has(skillsPath)) { + roots.set(skillsPath, { + id: `claude-plugin-${stablePathId(skillsPath)}`, + label: `Claude plugin ${safePluginLabel(pluginId, pathApi)}`, + path: skillsPath, + sourceKind: 'plugin', + providers: ['claude'], + owner: 'claude' + }) + } + } + return [...roots.values()] +} + +async function readMetadataFile(pathValue: string): Promise { + try { + const fileStat = await stat(pathValue) + if (!fileStat.isFile() || fileStat.size > MAX_PLUGIN_METADATA_BYTES) { + return null + } + const file = await open(pathValue, 'r') + try { + const buffer = Buffer.alloc(fileStat.size) + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0) + return buffer.toString('utf8', 0, bytesRead) + } finally { + await file.close() + } + } catch { + return null + } +} + +export async function discoverClaudePluginSkillSources(args: { + homeDir: string + cwd: string +}): Promise { + const paths = getClaudePluginMetadataPaths(args.homeDir, args.cwd) + const [installedPlugins, ...settings] = await Promise.all( + [paths.installedPlugins, ...paths.settings].map(readMetadataFile) + ) + return resolveClaudePluginSkillSources({ + metadata: { installedPlugins, settings }, + cwd: args.cwd + }) +} diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index d65f30dc391..a5d113aebe9 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -45,6 +45,90 @@ describe('skill discovery', () => { expect(result.skills.find((skill) => skill.name === 'Docs')?.providers).toEqual(['claude']) }) + it('discovers the enabled Claude plugin version applicable to the project cwd', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const home = join(root, 'home') + const project = join(root, 'project') + const cwd = join(project, 'worktree') + const userInstall = join(home, '.claude', 'plugins', 'cache', 'compound', '3.13.1') + const projectInstall = join(home, '.claude', 'plugins', 'cache', 'compound', '3.14.3') + const pluginId = 'compound-engineering@compound-engineering-plugin' + await mkdir(join(userInstall, 'skills', 'old-plan'), { recursive: true }) + await mkdir(join(projectInstall, 'skills', 'ce-plan'), { recursive: true }) + await mkdir(join(home, '.claude', 'plugins'), { recursive: true }) + await mkdir(cwd, { recursive: true }) + await writeFile(join(userInstall, 'skills', 'old-plan', 'SKILL.md'), '# old-plan') + await writeFile( + join(projectInstall, 'skills', 'ce-plan', 'SKILL.md'), + '---\nname: ce-plan\ndescription: Create structured plans.\n---\n' + ) + await writeFile( + join(home, '.claude', 'plugins', 'installed_plugins.json'), + JSON.stringify({ + plugins: { + [pluginId]: [ + { scope: 'user', installPath: userInstall }, + { scope: 'project', projectPath: project, installPath: projectInstall } + ] + } + }) + ) + await writeFile( + join(home, '.claude', 'settings.json'), + JSON.stringify({ enabledPlugins: { [pluginId]: true } }) + ) + + const result = await discoverSkills({ homeDir: home, cwd, repos: [] }) + + expect(result.skills.map((skill) => skill.name)).toContain('ce-plan') + expect(result.skills.map((skill) => skill.name)).not.toContain('old-plan') + const pluginSource = result.sources.find( + (source) => source.path === join(projectInstall, 'skills') + ) + expect(pluginSource).toMatchObject({ sourceKind: 'plugin', owner: 'claude', exists: true }) + }) + + it('skips Claude plugin discovery when no explicit cwd targets the scan (Settings shape)', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const home = join(root, 'home') + const install = join(home, '.claude', 'plugins', 'cache', 'compound', '3.14.3') + const pluginId = 'compound-engineering@compound-engineering-plugin' + await mkdir(join(install, 'skills', 'ce-plan'), { recursive: true }) + await writeFile(join(install, 'skills', 'ce-plan', 'SKILL.md'), '# ce-plan') + await writeFile( + join(home, '.claude', 'plugins', 'installed_plugins.json'), + JSON.stringify({ plugins: { [pluginId]: [{ scope: 'user', installPath: install }] } }) + ) + await writeFile( + join(home, '.claude', 'settings.json'), + JSON.stringify({ enabledPlugins: { [pluginId]: true } }) + ) + + const result = await discoverSkills({ homeDir: home, repos: [], includeCwd: false }) + + expect(result.skills.map((skill) => skill.name)).not.toContain('ce-plan') + expect(result.sources.some((source) => source.id.startsWith('claude-plugin'))).toBe(false) + }) + + it('records every contributing root when symlinked roots dedup to one skill', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const home = join(root, 'home') + const codexSkills = join(home, '.codex', 'skills') + await mkdir(join(codexSkills, 'review'), { recursive: true }) + await writeFile(join(codexSkills, 'review', 'SKILL.md'), '# review') + await mkdir(join(home, '.agents'), { recursive: true }) + // Shared root is a symlink onto the Codex root: one canonical file, two roots. + await symlink(codexSkills, join(home, '.agents', 'skills'), 'dir') + + const result = await discoverSkills({ homeDir: home, repos: [], includeCwd: false }) + + const reviews = result.skills.filter((skill) => skill.name === 'review') + expect(reviews).toHaveLength(1) + expect(reviews[0].rootPaths?.slice().sort()).toEqual( + [codexSkills, join(home, '.agents', 'skills')].sort() + ) + }) + it('does not add SSH-backed repository paths to local scan roots', () => { const roots = buildSkillDiscoverySources({ homeDir: '/home/test', diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts index ed74ef3ffa6..00edfc8e705 100644 --- a/src/main/skills/discovery.ts +++ b/src/main/skills/discovery.ts @@ -1,19 +1,23 @@ import type { Dirent } from 'node:fs' import { open, readdir, realpath, stat } from 'node:fs/promises' +import { homedir } from 'node:os' import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path' import { summarizeSkillMarkdown } from '../../shared/skill-metadata' import type { Repo } from '../../shared/types' import type { DiscoveredSkill, SkillDiscoveryResult, - SkillDiscoverySource, - SkillSourceKind + SkillDiscoverySource } from '../../shared/skills' import { buildSkillDiscoverySources, + compareSkills, + sourceKindForSkill, + sourceLabelForSkill, stablePathId, type SkillScanRoot } from './skill-discovery-sources' +import { discoverClaudePluginSkillSources } from './claude-plugin-skill-sources' export { buildSkillDiscoverySources } from './skill-discovery-sources' @@ -30,14 +34,6 @@ async function pathExists(pathValue: string): Promise { } } -function compareSkills(a: DiscoveredSkill, b: DiscoveredSkill): number { - return ( - a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || - a.sourceLabel.localeCompare(b.sourceLabel, undefined, { sensitivity: 'base' }) || - a.skillFilePath.localeCompare(b.skillFilePath) - ) -} - function isWithinDepth(rootPath: string, childPath: string, maxDepth: number): boolean { const rel = relative(rootPath, childPath) if (!rel) { @@ -50,23 +46,6 @@ function isWithinDepth(rootPath: string, childPath: string, maxDepth: number): b return rel.split(sep).length <= maxDepth } -function sourceKindForSkill(root: SkillScanRoot, skillFilePath: string): SkillSourceKind { - if ( - root.sourceKind === 'home' && - relative(root.path, skillFilePath).split(sep)[0] === '.system' - ) { - return 'bundled' - } - return root.sourceKind -} - -function sourceLabelForSkill(root: SkillScanRoot, sourceKind: SkillSourceKind): string { - if (sourceKind === 'bundled') { - return `${root.label} bundled` - } - return root.label -} - async function findSkillFiles(rootPath: string, maxDepth: number): Promise { const out: string[] = [] const visitedDirectoryPaths = new Set() @@ -182,7 +161,7 @@ async function readSkillSummary(skillFilePath: string): Promise<{ name: string | null description: string | null updatedAt: number | null -}> { +} | null> { try { const fileStat = await stat(skillFilePath) const file = await open(skillFilePath, 'r') @@ -199,23 +178,33 @@ async function readSkillSummary(skillFilePath: string): Promise<{ updatedAt: fileStat.mtimeMs } } catch { - return { name: null, description: null, updatedAt: null } + return null } } -async function scanRoot(root: SkillScanRoot): Promise { +type ScannedSkill = DiscoveredSkill & { canonicalSkillFilePath: string } + +async function scanRoot(root: SkillScanRoot): Promise { const maxDepth = root.sourceKind === 'plugin' ? 9 : 4 const skillFiles = await findSkillFiles(root.path, maxDepth) const skills = await Promise.all( - skillFiles.map(async (skillFilePath) => { + skillFiles.map(async (skillFilePath): Promise => { + // Why: path identity belongs to the scanning host; canonicalizing before + // returning prevents symlinked roots from becoming duplicate picker rows. + const canonicalSkillFilePath = await realpath(skillFilePath).catch(() => skillFilePath) const directoryPath = dirname(skillFilePath) const summary = await readSkillSummary(skillFilePath) - const sourceKind = sourceKindForSkill(root, skillFilePath) + if (!summary) { + return null + } + const sourceKind = sourceKindForSkill(root, skillFilePath, { relative, sep }) return { - id: stablePathId(skillFilePath), + id: stablePathId(canonicalSkillFilePath), name: summary.name ?? basename(directoryPath), description: summary.description, - providers: root.providers, + // Copy: `root.providers` is shared across every skill/source from this + // root, so the dedup merge below must not mutate the aliased array. + providers: [...root.providers], sourceKind, sourceLabel: sourceLabelForSkill(root, sourceKind), rootPath: root.path, @@ -223,11 +212,12 @@ async function scanRoot(root: SkillScanRoot): Promise { skillFilePath, installed: true, fileCount: await countFiles(directoryPath), - updatedAt: summary.updatedAt - } satisfies DiscoveredSkill + updatedAt: summary.updatedAt, + canonicalSkillFilePath + } satisfies ScannedSkill }) ) - return skills + return skills.filter((skill): skill is ScannedSkill => skill !== null) } export async function discoverSkills(args: { @@ -236,12 +226,25 @@ export async function discoverSkills(args: { cwd?: string includeCwd?: boolean }): Promise { - const roots = buildSkillDiscoverySources(args) + const homeDir = args.homeDir ?? homedir() + const roots = [ + ...buildSkillDiscoverySources({ ...args, homeDir }), + // Why: plugin discovery is native-chat data keyed to an explicit workspace. + // Untargeted scans (Settings) keep their pre-picker inventory and cost. + ...(args.cwd && args.includeCwd !== false + ? await discoverClaudePluginSkillSources({ homeDir, cwd: args.cwd }) + : []) + ] const sources: SkillDiscoverySource[] = [] const skillGroups = await Promise.all( roots.map(async (root) => { const exists = await pathExists(root.path) - sources.push({ ...root, exists, skippedReason: exists ? undefined : 'missing' }) + sources.push({ + ...root, + providers: [...root.providers], + exists, + skippedReason: exists ? undefined : 'missing' + }) if (!exists) { return [] } @@ -250,11 +253,30 @@ export async function discoverSkills(args: { ) const seen = new Map() for (const skill of skillGroups.flat()) { - // Why: WSL discovery sets cwd to the WSL home, so home skills can also be - // reached through the synthetic repo root. Keep the global home identity. - if (!seen.has(skill.skillFilePath)) { - seen.set(skill.skillFilePath, skill) + // Why: overlapping repo/cwd roots and symlinked provider homes can reach + // the same file. Keep the first source's higher-level scope identity, but + // record every contributing root so per-agent visibility survives dedup. + const existing = seen.get(skill.canonicalSkillFilePath) + if (existing) { + if (existing.rootPaths && !existing.rootPaths.includes(skill.rootPath)) { + existing.rootPaths.push(skill.rootPath) + } + // Why: providers is per-agent visibility just like rootPaths; keeping only + // the first root's tags makes a shared/symlinked skill under-report which + // agents can see it on the Settings provider badges/filter. Reassign a + // fresh array — `providers` aliases the scan root's array, so pushing in + // place would mutate the root and every sibling skill/source sharing it. + const mergedProviders = [...existing.providers] + for (const provider of skill.providers) { + if (!mergedProviders.includes(provider)) { + mergedProviders.push(provider) + } + } + existing.providers = mergedProviders + continue } + const { canonicalSkillFilePath, ...publicSkill } = skill + seen.set(canonicalSkillFilePath, { ...publicSkill, rootPaths: [skill.rootPath] }) } return { skills: Array.from(seen.values()).sort(compareSkills), diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index ba3726f43e0..f17d15e1ef2 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -1,24 +1,63 @@ import { createHash } from 'node:crypto' import { homedir } from 'node:os' -import { basename, join } from 'node:path' -import type { SkillDiscoverySource, SkillProvider, SkillSourceKind } from '../../shared/skills' +import { basename, join, type posix } from 'node:path' +import type { + DiscoveredSkill, + SkillDiscoverySource, + SkillProvider, + SkillSourceKind +} from '../../shared/skills' +import type { AgentType } from '../../shared/agent-status-types' import type { Repo } from '../../shared/types' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' export type SkillScanRoot = Omit +type SkillDiscoveryPathApi = Pick export function stablePathId(pathValue: string): string { return createHash('sha1').update(pathValue).digest('hex').slice(0, 16) } +// Skill classification and ordering are identical for native and WSL discovery; +// only the path arithmetic differs (node:path vs pathPosix), so both callers +// share these and pass the matching path adapter. +type SkillRelativePathApi = { relative: (from: string, to: string) => string; sep: string } + +export function sourceKindForSkill( + root: SkillScanRoot, + skillFilePath: string, + pathApi: SkillRelativePathApi +): SkillSourceKind { + if ( + root.sourceKind === 'home' && + pathApi.relative(root.path, skillFilePath).split(pathApi.sep)[0] === '.system' + ) { + return 'bundled' + } + return root.sourceKind +} + +export function sourceLabelForSkill(root: SkillScanRoot, sourceKind: SkillSourceKind): string { + return sourceKind === 'bundled' ? `${root.label} bundled` : root.label +} + +export function compareSkills(a: DiscoveredSkill, b: DiscoveredSkill): number { + return ( + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || + a.sourceLabel.localeCompare(b.sourceLabel, undefined, { sensitivity: 'base' }) || + a.skillFilePath.localeCompare(b.skillFilePath) + ) +} + function source( id: string, label: string, path: string, sourceKind: SkillSourceKind, - providers: SkillProvider[] + providers: SkillProvider[], + owner: AgentType | null ): SkillScanRoot { - return { id, label, path, sourceKind, providers } + return { id, label, path, sourceKind, providers, owner } } export function buildSkillDiscoverySources( @@ -27,39 +66,95 @@ export function buildSkillDiscoverySources( cwd?: string repos?: Repo[] includeCwd?: boolean + pathApi?: SkillDiscoveryPathApi } = {} ): SkillScanRoot[] { + const pathApi = args.pathApi ?? { basename, join } const home = args.homeDir ?? homedir() const cwd = args.cwd ?? process.cwd() const roots: SkillScanRoot[] = [ - source('home-codex', 'Codex home', join(home, '.codex', 'skills'), 'home', ['codex']), - source('home-agents', 'Agent skills home', join(home, '.agents', 'skills'), 'home', [ - 'agent-skills' - ]), - source('home-claude', 'Claude home', join(home, '.claude', 'skills'), 'home', ['claude']), + source( + 'home-codex', + 'Codex home', + pathApi.join(home, '.codex', 'skills'), + 'home', + ['codex'], + 'codex' + ), + source( + 'home-agents', + 'Agent skills home', + pathApi.join(home, '.agents', 'skills'), + 'home', + ['agent-skills'], + null + ), + source( + 'home-claude', + 'Claude home', + pathApi.join(home, '.claude', 'skills'), + 'home', + ['claude'], + 'claude' + ), source( 'codex-plugin-cache', 'Codex plugin cache', - join(home, '.codex', 'plugins', 'cache'), + pathApi.join(home, '.codex', 'plugins', 'cache'), 'plugin', - ['codex', 'agent-skills'] + ['codex', 'agent-skills'], + 'codex' ), // Why: `npx skills add --global` writes into each agent's own home skills // directory, so coverage misses them unless we scan every provider root. - source('home-grok', 'Grok home', join(home, '.grok', 'skills'), 'home', ['agent-skills']), - source('home-opencode', 'OpenCode home', join(home, '.config', 'opencode', 'skills'), 'home', [ - 'agent-skills' - ]), - source('home-pi', 'Pi home', join(home, '.pi', 'agent', 'skills'), 'home', ['agent-skills']), - source('home-gemini', 'Gemini home', join(home, '.gemini', 'skills'), 'home', ['agent-skills']), + source( + 'home-grok', + 'Grok home', + pathApi.join(home, '.grok', 'skills'), + 'home', + ['agent-skills'], + 'grok' + ), + source( + 'home-opencode', + 'OpenCode home', + pathApi.join(home, '.config', 'opencode', 'skills'), + 'home', + ['agent-skills'], + 'opencode' + ), + source( + 'home-pi', + 'Pi home', + pathApi.join(home, '.pi', 'agent', 'skills'), + 'home', + ['agent-skills'], + 'pi' + ), + source( + 'home-gemini', + 'Gemini home', + pathApi.join(home, '.gemini', 'skills'), + 'home', + ['agent-skills'], + 'gemini' + ), source( 'home-antigravity', 'Antigravity home', - join(home, '.gemini', 'antigravity', 'skills'), + pathApi.join(home, '.gemini', 'antigravity', 'skills'), 'home', - ['agent-skills'] + ['agent-skills'], + 'antigravity' ), - source('home-cursor', 'Cursor home', join(home, '.cursor', 'skills'), 'home', ['agent-skills']) + source( + 'home-cursor', + 'Cursor home', + pathApi.join(home, '.cursor', 'skills'), + 'home', + ['agent-skills'], + 'cursor' + ) ] const projectPaths = new Set() @@ -76,21 +171,23 @@ export function buildSkillDiscoverySources( } for (const repoPath of projectPaths) { - const label = `Repo ${basename(repoPath)}` + const label = `Repo ${pathApi.basename(repoPath)}` roots.push( source( `repo-agents-${stablePathId(repoPath)}`, `${label} .agents`, - join(repoPath, '.agents', 'skills'), + pathApi.join(repoPath, '.agents', 'skills'), 'repo', - ['agent-skills'] + ['agent-skills'], + null ), source( `repo-claude-${stablePathId(repoPath)}`, `${label} .claude`, - join(repoPath, '.claude', 'skills'), + pathApi.join(repoPath, '.claude', 'skills'), 'repo', - ['claude'] + ['claude'], + 'claude' ) ) } diff --git a/src/main/skills/skill-discovery-target.ts b/src/main/skills/skill-discovery-target.ts new file mode 100644 index 00000000000..2be10232c8c --- /dev/null +++ b/src/main/skills/skill-discovery-target.ts @@ -0,0 +1,70 @@ +import type { Repo } from '../../shared/types' +import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills' +import { getDefaultWslDistro, getWslHome, parseWslPath, toLinuxPath } from '../wsl' +import { discoverSkills } from './discovery' +import { discoverSkillsInWsl } from './skill-discovery-wsl' + +export type ResolvedSkillDiscoveryTarget = + | { kind: 'native-host'; cwd: string | undefined } + | { kind: 'wsl'; distro: string; homeDir: string; cwd: string } + +export function resolveSkillDiscoveryTarget( + target: SkillDiscoveryTarget | undefined +): ResolvedSkillDiscoveryTarget { + const projectRuntime = target?.projectRuntime + if (projectRuntime?.status === 'repair-required') { + throw new Error( + `Project runtime requires repair before skill discovery: ${projectRuntime.repair.reason}` + ) + } + + const wslRequested = + (projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl') || + (!projectRuntime && target?.runtime === 'wsl') + const wslDistro = + projectRuntime?.status === 'resolved' && projectRuntime.runtime.kind === 'wsl' + ? projectRuntime.runtime.distro + : !projectRuntime && target?.runtime === 'wsl' + ? target.wslDistro?.trim() || getDefaultWslDistro() + : null + if (wslRequested && !wslDistro) { + throw new Error('No WSL distribution is available for skill discovery.') + } + if (!wslDistro) { + return { kind: 'native-host', cwd: target?.cwd?.trim() || undefined } + } + if (process.platform !== 'win32') { + throw new Error('WSL skill discovery is only available on Windows.') + } + const homeDir = getWslHome(wslDistro) + if (!homeDir) { + throw new Error(`Could not resolve the WSL home directory for ${wslDistro}.`) + } + + const requestedCwd = target?.cwd?.trim() + const parsedCwd = requestedCwd ? parseWslPath(requestedCwd) : null + if (parsedCwd && parsedCwd.distro.toLowerCase() !== wslDistro.toLowerCase()) { + throw new Error( + `The workspace belongs to WSL distribution ${parsedCwd.distro}, not ${wslDistro}.` + ) + } + const linuxHomeDir = toLinuxPath(homeDir) + const cwd = parsedCwd?.linuxPath ?? (requestedCwd ? toLinuxPath(requestedCwd) : linuxHomeDir) + return { kind: 'wsl', distro: wslDistro, homeDir: linuxHomeDir, cwd } +} + +export async function discoverSkillsOnTarget( + target: ResolvedSkillDiscoveryTarget, + repos: readonly Repo[] +): Promise { + if (target.kind === 'wsl') { + return discoverSkillsInWsl({ + distro: target.distro, + homeDir: target.homeDir, + cwd: target.cwd + }) + } + return target.cwd + ? discoverSkills({ repos: [], cwd: target.cwd }) + : discoverSkills({ repos: [...repos] }) +} diff --git a/src/main/skills/skill-discovery-wsl-plugins.test.ts b/src/main/skills/skill-discovery-wsl-plugins.test.ts new file mode 100644 index 00000000000..fcb48befa33 --- /dev/null +++ b/src/main/skills/skill-discovery-wsl-plugins.test.ts @@ -0,0 +1,83 @@ +import { posix as pathPosix } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const execFileMock = vi.hoisted(() => vi.fn()) + +vi.mock('node:child_process', () => ({ execFile: execFileMock })) + +import { buildSkillDiscoverySources } from './skill-discovery-sources' +import { discoverSkillsInWsl } from './skill-discovery-wsl' + +function record(...fields: string[]): string { + return `${fields.join('\0')}\0` +} + +function completeExecFileCall(callIndex: number, stdout: string): void { + const callback = execFileMock.mock.calls[callIndex]?.[3] as + | ((error: Error | null, stdout: string) => void) + | undefined + callback?.(null, stdout) +} + +describe('WSL Claude plugin skill discovery', () => { + beforeEach(() => execFileMock.mockReset()) + + it('reads enabled plugin metadata and scans the selected install inside the distro', async () => { + const homeDir = '/home/alice' + const cwd = '/work/orca' + const pluginId = 'compound-engineering@compound-engineering-plugin' + const installPath = '/home/alice/.claude/plugins/cache/compound/3.14.3' + const installed = JSON.stringify({ + plugins: { + [pluginId]: [{ scope: 'project', projectPath: cwd, installPath }] + } + }) + const settings = JSON.stringify({ enabledPlugins: { [pluginId]: true } }) + const metadataOutput = [ + record('F', '0', '1', Buffer.from(installed).toString('base64')), + record('F', '1', '1', Buffer.from(settings).toString('base64')), + record('F', '2', '0', ''), + record('F', '3', '0', '') + ].join('') + const baseRootCount = buildSkillDiscoverySources({ + homeDir, + cwd, + repos: [], + pathApi: pathPosix + }).length + const skillPath = `${installPath}/skills/ce-plan/SKILL.md` + const markdown = Buffer.from('---\nname: ce-plan\ndescription: Plan work.\n---\n').toString( + 'base64' + ) + const scanOutput = [ + record('R', String(baseRootCount), '1'), + record('S', String(baseRootCount), skillPath, skillPath, '1700000000', '2', markdown) + ].join('') + execFileMock.mockImplementationOnce((..._args: unknown[]) => { + queueMicrotask(() => completeExecFileCall(0, metadataOutput)) + }) + execFileMock.mockImplementationOnce((..._args: unknown[]) => { + queueMicrotask(() => completeExecFileCall(1, scanOutput)) + }) + + const result = await discoverSkillsInWsl({ distro: 'Ubuntu', homeDir, cwd }) + + expect(execFileMock).toHaveBeenCalledTimes(2) + const scanArgs = execFileMock.mock.calls[1]?.[1] as string[] + const encoded = /printf %s '([^']+)'/.exec(scanArgs[5] ?? '')?.[1] + const scanScript = Buffer.from(encoded ?? '', 'base64').toString('utf8') + expect(scanScript).toContain(`${installPath}/skills`) + expect(result.skills).toEqual([ + expect.objectContaining({ + name: 'ce-plan', + sourceKind: 'plugin', + rootPath: `${installPath}/skills` + }) + ]) + expect(result.sources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: `${installPath}/skills`, owner: 'claude', exists: true }) + ]) + ) + }) +}) diff --git a/src/main/skills/skill-discovery-wsl.test.ts b/src/main/skills/skill-discovery-wsl.test.ts new file mode 100644 index 00000000000..621b9f4c0a3 --- /dev/null +++ b/src/main/skills/skill-discovery-wsl.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import type { SkillScanRoot } from './skill-discovery-sources' +import { buildWslSkillDiscoveryCommand, parseWslSkillDiscoveryOutput } from './skill-discovery-wsl' + +const homeRoot: SkillScanRoot = { + id: 'home-codex', + label: 'Codex home', + path: '/home/alice/.codex/skills', + sourceKind: 'home', + providers: ['codex'], + owner: 'codex' +} +const repoRoot: SkillScanRoot = { + id: 'repo-agents', + label: 'Repo project .agents', + path: '/work/project/.agents/skills', + sourceKind: 'repo', + providers: ['agent-skills'], + owner: null +} + +function record(...fields: string[]): string { + return `${fields.join('\0')}\0` +} + +describe('WSL skill discovery', () => { + it('parses distro-native metadata and deduplicates canonical skill paths', () => { + const markdown = Buffer.from( + '---\nname: Review\ndescription: Review this change\n---\n', + 'utf8' + ).toString('base64') + const output = [ + record('R', '0', '1'), + record('R', '1', '0'), + record( + 'S', + '0', + '/home/alice/.codex/skills/.system/review/SKILL.md', + '/opt/orca/review/SKILL.md', + '1700000000', + '7', + markdown + ), + record( + 'S', + '1', + '/work/project/.agents/skills/review/SKILL.md', + '/opt/orca/review/SKILL.md', + '1700000001', + '9', + markdown + ) + ].join('') + + const result = parseWslSkillDiscoveryOutput(output, [homeRoot, repoRoot], 42) + + expect(result.scannedAt).toBe(42) + expect(result.skills).toEqual([ + expect.objectContaining({ + name: 'Review', + description: 'Review this change', + sourceKind: 'bundled', + rootPath: homeRoot.path, + skillFilePath: '/home/alice/.codex/skills/.system/review/SKILL.md', + fileCount: 7, + updatedAt: 1_700_000_000_000 + }) + ]) + expect(result.sources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'home-codex', exists: true }), + expect.objectContaining({ id: 'repo-agents', exists: false, skippedReason: 'missing' }) + ]) + ) + }) + + it('builds a distro-side scan for enumeration, reads, and canonical identity', () => { + const command = buildWslSkillDiscoveryCommand([ + { ...repoRoot, path: "/work/alice's project/.agents/skills" } + ]) + const encoded = /printf %s '([^']+)'/.exec(command)?.[1] + expect(encoded).toBeTruthy() + const script = Buffer.from(encoded!, 'base64').toString('utf8') + + expect(script).toContain('find -L "$root_path"') + expect(script).toContain('realpath -- "$skill_file"') + expect(script).toContain('head -c 262144 -- "$skill_file"') + expect(script).toContain(`'/work/alice'\\''s project/.agents/skills'`) + }) + + it('rejects malformed host responses instead of reporting an empty scan', () => { + expect(() => parseWslSkillDiscoveryOutput(record('S', '9'), [homeRoot])).toThrow( + 'unknown source' + ) + }) +}) diff --git a/src/main/skills/skill-discovery-wsl.ts b/src/main/skills/skill-discovery-wsl.ts new file mode 100644 index 00000000000..c4f553225ae --- /dev/null +++ b/src/main/skills/skill-discovery-wsl.ts @@ -0,0 +1,212 @@ +import { execFile } from 'node:child_process' +import { posix as pathPosix } from 'node:path' +import { summarizeSkillMarkdown } from '../../shared/skill-metadata' +import type { + DiscoveredSkill, + SkillDiscoveryResult, + SkillDiscoverySource +} from '../../shared/skills' +import { buildEncodedWslBashCommand, quoteBashString } from '../wsl-bash-command' +import { + buildSkillDiscoverySources, + compareSkills, + sourceKindForSkill, + sourceLabelForSkill, + stablePathId, + type SkillScanRoot +} from './skill-discovery-sources' +import { discoverClaudePluginSkillSourcesInWsl } from './claude-plugin-skill-sources-wsl' + +const MAX_MARKDOWN_BYTES = 256 * 1024 +const MAX_PACKAGE_FILES = 200 +const WSL_SCAN_TIMEOUT_MS = 10_000 +const WSL_SCAN_MAX_BUFFER_BYTES = 128 * 1024 * 1024 + +export function buildWslSkillDiscoveryCommand(roots: readonly SkillScanRoot[]): string { + const lines = [ + 'set -u', + 'set -o pipefail', + 'scan_root() {', + ' root_index=$1', + ' root_path=$2', + ' max_depth=$3', + ' if [ ! -d "$root_path" ]; then', + ` printf '%s\\0%s\\0%s\\0' R "$root_index" 0`, + ' return', + ' fi', + ` printf '%s\\0%s\\0%s\\0' R "$root_index" 1`, + ` while IFS= read -r -d '' skill_file; do`, + ` canonical_path=$(realpath -- "$skill_file" 2>/dev/null || printf '%s' "$skill_file")`, + ` directory_path=\${skill_file%/*}`, + ` updated_at=$(stat -c '%Y' -- "$skill_file" 2>/dev/null || true)`, + ` encoded_markdown=$(head -c ${MAX_MARKDOWN_BYTES} -- "$skill_file" 2>/dev/null | base64 | tr -d '\\n') || continue`, + ' file_count=0', + ` while IFS= read -r -d '' package_file; do`, + ' file_count=$((file_count + 1))', + ` [ "$file_count" -ge ${MAX_PACKAGE_FILES} ] && break`, + ` done < <(find -L "$directory_path" -type f -print0 2>/dev/null)`, + ` printf '%s\\0%s\\0%s\\0%s\\0%s\\0%s\\0' S "$root_index" "$skill_file" "$canonical_path" "$updated_at" "$file_count"`, + ` printf '%s' "$encoded_markdown"`, + ` printf '\\0'`, + ` done < <(find -L "$root_path" -mindepth 1 -maxdepth "$max_depth" -type f -name 'SKILL.md' -print0 2>/dev/null)`, + '}' + ] + roots.forEach((root, index) => { + const maxDepth = root.sourceKind === 'plugin' ? 10 : 5 + lines.push(`scan_root ${index} ${quoteBashString(root.path)} ${maxDepth}`) + }) + return buildEncodedWslBashCommand(lines.join('\n')) +} + +function executeWslSkillDiscovery(distro: string, command: string): Promise { + return new Promise((resolve, reject) => { + execFile( + 'wsl.exe', + ['-d', distro, '--', 'bash', '-c', command], + { + encoding: 'utf8', + maxBuffer: WSL_SCAN_MAX_BUFFER_BYTES, + timeout: WSL_SCAN_TIMEOUT_MS, + windowsHide: true + }, + (error, stdout) => { + if (error) { + reject(error) + return + } + resolve(stdout) + } + ) + }) +} + +function readProtocolField(fields: string[], index: number): string { + const value = fields[index] + if (value === undefined) { + throw new Error('WSL skill discovery returned an incomplete response.') + } + return value +} + +export function parseWslSkillDiscoveryOutput( + output: string, + roots: readonly SkillScanRoot[], + scannedAt = Date.now() +): SkillDiscoveryResult { + const fields = output.split('\0') + const rootExists = new Map() + const skillsByCanonicalPath = new Map() + let index = 0 + while (index < fields.length && fields[index]) { + const recordKind = fields[index++] + const rootIndex = Number.parseInt(readProtocolField(fields, index++), 10) + const root = roots[rootIndex] + if (!root) { + throw new Error('WSL skill discovery returned an unknown source.') + } + if (recordKind === 'R') { + rootExists.set(rootIndex, readProtocolField(fields, index++) === '1') + continue + } + if (recordKind !== 'S') { + throw new Error('WSL skill discovery returned an invalid response.') + } + + const skillFilePath = readProtocolField(fields, index++) + const canonicalSkillFilePath = readProtocolField(fields, index++) + const updatedAtSeconds = Number.parseInt(readProtocolField(fields, index++), 10) + const fileCount = Number.parseInt(readProtocolField(fields, index++), 10) + const markdown = Buffer.from(readProtocolField(fields, index++), 'base64').toString('utf8') + const existing = skillsByCanonicalPath.get(canonicalSkillFilePath) + if (existing) { + // Why: dedup keeps one row, but every contributing root must survive so + // per-agent visibility does not depend on root scan order. providers is + // per-agent visibility too, so union it rather than keeping only the first. + if (existing.rootPaths && !existing.rootPaths.includes(root.path)) { + existing.rootPaths.push(root.path) + } + // Reassign a fresh array — `providers` aliases the scan root's array, so + // pushing in place would mutate the root and sibling skills/sources. + const mergedProviders = [...existing.providers] + for (const provider of root.providers) { + if (!mergedProviders.includes(provider)) { + mergedProviders.push(provider) + } + } + existing.providers = mergedProviders + continue + } + const directoryPath = pathPosix.dirname(skillFilePath) + const summary = summarizeSkillMarkdown(markdown) + const sourceKind = sourceKindForSkill(root, skillFilePath, pathPosix) + skillsByCanonicalPath.set(canonicalSkillFilePath, { + id: stablePathId(canonicalSkillFilePath), + name: summary.name ?? pathPosix.basename(directoryPath), + description: summary.description, + // Copy: `root.providers` is shared across every skill/source from this + // root, so a later in-place merge must not mutate the aliased array. + providers: [...root.providers], + sourceKind, + sourceLabel: sourceLabelForSkill(root, sourceKind), + rootPath: root.path, + rootPaths: [root.path], + directoryPath, + skillFilePath, + installed: true, + fileCount: Number.isFinite(fileCount) ? fileCount : 0, + updatedAt: Number.isFinite(updatedAtSeconds) ? updatedAtSeconds * 1000 : null + }) + } + + const sources: SkillDiscoverySource[] = roots.map((root, rootIndex) => { + const exists = rootExists.get(rootIndex) ?? false + return { + ...root, + providers: [...root.providers], + exists, + skippedReason: exists ? undefined : 'missing' + } + }) + return { + skills: [...skillsByCanonicalPath.values()].sort(compareSkills), + sources: sources.sort((a, b) => + a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }) + ), + scannedAt + } +} + +export async function discoverSkillsInWsl(args: { + distro: string + homeDir: string + cwd: string +}): Promise { + // Plugin roots are resolved (in JS) from metadata this first wsl.exe call + // reads, then fed to the scan's own wsl.exe call below — two sequential + // process boots. That is a deliberate one-time-per-pane cost (the renderer + // caches per pane); folding both into one invocation would require porting + // the plugin-install resolution into bash, which is not worth the risk. + // + // Why: plugin-metadata enrichment is optional. A failed/timed-out read must + // degrade to zero plugin roots (matching the native readMetadataFile path), + // not abort the mandatory native/home/repo/bundled scan. + let pluginRoots: SkillScanRoot[] = [] + try { + pluginRoots = await discoverClaudePluginSkillSourcesInWsl(args) + } catch { + pluginRoots = [] + } + const roots = [ + ...buildSkillDiscoverySources({ + homeDir: args.homeDir, + cwd: args.cwd, + repos: [], + pathApi: pathPosix + }), + ...pluginRoots + ] + // Why: UNC traversal applies Windows casing and symlink rules. The distro + // must own enumeration, metadata reads, and canonical path identity. + const output = await executeWslSkillDiscovery(args.distro, buildWslSkillDiscoveryCommand(roots)) + return parseWslSkillDiscoveryOutput(output, roots) +} diff --git a/src/main/skills/skill-freshness-inventory-limits.test.ts b/src/main/skills/skill-freshness-inventory-limits.test.ts index c43d137fcd0..b818f968238 100644 --- a/src/main/skills/skill-freshness-inventory-limits.test.ts +++ b/src/main/skills/skill-freshness-inventory-limits.test.ts @@ -14,7 +14,8 @@ describe('skill freshness inventory limits', () => { label: `Repo ${index}`, path: `/repo-${index}/.agents/skills`, sourceKind: 'repo', - providers: ['agent-skills'] + providers: ['agent-skills'], + owner: null }) ) diff --git a/src/main/telemetry/validator.test.ts b/src/main/telemetry/validator.test.ts index 031df8d208f..15ddfe9d638 100644 --- a/src/main/telemetry/validator.test.ts +++ b/src/main/telemetry/validator.test.ts @@ -165,7 +165,7 @@ describe('validate', () => { expect(result.ok).toBe(true) }) - // ── repo_added.is_git_repo (docs/reference/telemetry-availability.md) + // ── repo_added.is_git_repo // The git-vs-folder signal moved here from onboarding_completed once project // selection left onboarding. Optional so SSH/remote edges can omit it. diff --git a/src/main/wsl-bash-command.ts b/src/main/wsl-bash-command.ts index ff7426ae6ec..1b561a1182f 100644 --- a/src/main/wsl-bash-command.ts +++ b/src/main/wsl-bash-command.ts @@ -1,4 +1,5 @@ -function quoteShell(value: string): string { +/** Single-quote a value for safe interpolation into a Bash command line. */ +export function quoteBashString(value: string): string { return `'${value.replace(/'/g, "'\\''")}'` } @@ -6,5 +7,5 @@ export function buildEncodedWslBashCommand(command: string): string { // Why: wsl.exe preprocesses `$local_shell_vars` in command arguments before // Bash sees them. Base64 keeps validation scripts intact across that boundary. const encoded = Buffer.from(command, 'utf8').toString('base64') - return `set -o pipefail; printf %s ${quoteShell(encoded)} | base64 -d | bash` + return `set -o pipefail; printf %s ${quoteBashString(encoded)} | base64 -d | bash` } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index e5aa341101c..f25cf0f56df 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1448,7 +1448,7 @@ export type PreloadApi = { * the model snapshot. Never delivered in-band on pty:data. */ onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void) => () => void /** Batched derived side-effect facts for PTYs whose bytes transit local - * main; see docs/reference/terminal-side-effect-authority.md. */ + * main. */ onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void) => () => void /** Title-only replay snapshot for (re)attach; attention facts never replay. */ getSideEffectSnapshot: (id: string) => Promise diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx new file mode 100644 index 00000000000..74476af1fb2 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { NativeChatPickerMenu } from './NativeChatAutocompleteMenus' +import type { ComposerAutocomplete } from './native-chat-composer-state' + +function autocomplete( + overrides: Partial> = {} +): Extract { + return { + mode: 'slash', + query: '', + triggerKey: '/:0', + prefix: '/', + grouped: true, + commandsEnabled: true, + skillsEnabled: true, + items: [ + { + kind: 'command', + id: 'command:clear', + name: 'clear', + description: 'Clear history', + skillCollision: false + }, + { + kind: 'skill', + id: 'skill:browser', + name: 'browser', + description: 'Use a browser', + sources: [{ sourceKind: 'repo', skillFilePath: '/repo/browser/SKILL.md' }] + } + ], + skillStatus: 'ready', + ...overrides + } +} + +describe('NativeChatPickerMenu', () => { + afterEach(() => cleanup()) + + it('renders grouped command and skill options with active-descendant ids', () => { + const onChoose = vi.fn() + render( + + ) + expect(screen.getByText('Commands')).toBeTruthy() + expect(screen.getByText('Skills')).toBeTruthy() + expect(screen.getByRole('option', { name: /browser/i }).getAttribute('aria-selected')).toBe( + 'true' + ) + expect(screen.getByText('Project')).toBeTruthy() + }) + + it('completes a command on pointer down instead of dispatching it internally', () => { + const onChoose = vi.fn() + const value = autocomplete() + render( + + ) + fireEvent.pointerDown(screen.getByRole('option', { name: /clear/i })) + expect(onChoose).toHaveBeenCalledWith(value.items[0]) + }) + + it('keeps commands selectable while skills load', () => { + render( + + ) + expect(screen.getByRole('option', { name: /clear/i })).toBeTruthy() + expect(screen.getAllByText('Loading skills...')).toHaveLength(2) + }) + + it('uses command-only empty copy for a picker without skill support', () => { + render( + + ) + expect(screen.getAllByText('No matching commands')).toHaveLength(2) + }) + + it('announces a successful empty skill result distinctly from loading', () => { + render( + + ) + expect(screen.getAllByText('No matching skills')).toHaveLength(2) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx index f0662c52acd..8357931fb49 100644 --- a/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx +++ b/src/renderer/src/components/native-chat/NativeChatAutocompleteMenus.tsx @@ -1,38 +1,262 @@ -import { useEffect, useRef } from 'react' +import { memo, useEffect, useRef } from 'react' +import { Loader2, Package, RotateCcw } from 'lucide-react' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' -import type { SlashCommandSuggestion } from './native-chat-composer-state' -import type { DiscoveredSkill } from '../../../../shared/skills' +import type { SkillSourceKind } from '../../../../shared/skills' +import type { ComposerAutocomplete, NativeChatPickerItem } from './native-chat-composer-state' -export function NativeChatSlashMenu({ - suggestions, +export const NativeChatPickerMenu = memo(function NativeChatPickerMenu({ + autocomplete, activeIndex, - onChoose + listboxId, + onChoose, + onRetry }: { - suggestions: SlashCommandSuggestion[] + autocomplete: Extract activeIndex: number - onChoose: (command: SlashCommandSuggestion) => void + listboxId: string + onChoose: (item: NativeChatPickerItem) => void + onRetry: () => void }): React.JSX.Element { + const activeItemRef = useRef(null) + const commands = autocomplete.items.filter( + (item): item is Extract => item.kind === 'command' + ) + const skills = autocomplete.items.filter( + (item): item is Extract => item.kind === 'skill' + ) + + useEffect(() => { + activeItemRef.current?.scrollIntoView({ block: 'nearest' }) + }, [activeIndex, autocomplete.items]) + + const hasSkillStatus = + autocomplete.skillStatus === 'loading' || autocomplete.skillStatus === 'error' + const showCommandsHeading = autocomplete.grouped && commands.length > 0 + const showSkillsHeading = autocomplete.grouped && (skills.length > 0 || hasSkillStatus) + const noMatches = + autocomplete.skillStatus === 'ready' && commands.length === 0 && skills.length === 0 + const emptyText = noMatches ? getPickerEmptyText(autocomplete) : null + const collision = commands.find((item) => item.skillCollision) + const duplicate = skills.find((item) => item.sources.length > 1) + + let optionIndex = 0 return ( -
- {suggestions.map((command, index) => ( - ) : null} - - ))} + + ) : null} + {skills.map((item) => { + const index = optionIndex++ + return ( + + ) + })} + {noMatches ? {emptyText} : null} +
+ {autocomplete.skillStatus === 'loading' + ? translate('components.native-chat.composer.loadingSkills', 'Loading skills...') + : autocomplete.skillStatus === 'error' + ? translate( + 'components.native-chat.composer.skillsLoadFailed', + 'Could not load skills from this host' + ) + : emptyText + ? emptyText + : autocomplete.skillsEnabled + ? [ + translate('components.native-chat.composer.skillsLoaded', 'Skills loaded'), + collision + ? getPickerAnnotation(collision) + : duplicate + ? getPickerAnnotation(duplicate) + : null + ] + .filter(Boolean) + .join('. ') + : ''} +
) +}) + +function getPickerEmptyText( + autocomplete: Extract +): string { + if (autocomplete.mode === 'skill' || !autocomplete.commandsEnabled) { + return translate('components.native-chat.composer.noSkills', 'No matching skills') + } + if (autocomplete.skillsEnabled) { + return translate( + 'components.native-chat.composer.noCommandsOrSkills', + 'No matching commands or skills' + ) + } + return translate('components.native-chat.composer.noCommands', 'No matching commands') +} + +function PickerGroupHeading({ kind }: { kind: 'commands' | 'skills' }): React.JSX.Element { + return ( +
+ {kind === 'commands' + ? translate('components.native-chat.composer.commands', 'Commands') + : translate('components.native-chat.composer.skills', 'Skills')} +
+ ) +} + +function PickerStatus({ children }: { children: React.ReactNode }): React.JSX.Element { + return ( +
+ {children} +
+ ) +} + +function PickerOption({ + item, + prefix, + index, + activeIndex, + listboxId, + activeItemRef, + onChoose +}: { + item: NativeChatPickerItem + prefix: '/' | '$' + index: number + activeIndex: number + listboxId: string + activeItemRef: React.MutableRefObject + onChoose: (item: NativeChatPickerItem) => void +}): React.JSX.Element { + const annotation = getPickerAnnotation(item) + const selected = index === activeIndex + return ( + + ) +} + +function getPickerAnnotation(item: NativeChatPickerItem): string | null { + if (item.kind === 'command' && item.skillCollision) { + return translate( + 'components.native-chat.composer.skillCommandCollision', + 'Also a skill name - agent decides' + ) + } + if (item.kind === 'skill' && item.sources.length > 1) { + // Why: name the interpolation `sourceCount`, not `count` — a `count` option + // makes i18next resolve plural-suffixed keys that these locales don't define. + return translate( + 'components.native-chat.composer.skillMultipleSources', + '{{sourceCount}} sources - agent resolves', + { sourceCount: item.sources.length } + ) + } + return null +} + +function scopeLabel(sourceKind: SkillSourceKind | undefined): string { + const labels: Record = { + repo: translate('components.native-chat.composer.skillScopeProject', 'Project'), + home: translate('components.native-chat.composer.skillScopePersonal', 'Personal'), + bundled: translate('components.native-chat.composer.skillScopeBuiltIn', 'Built-in'), + plugin: translate('components.native-chat.composer.skillScopePlugin', 'Plugin') + } + return sourceKind ? (labels[sourceKind] ?? '') : '' } export function NativeChatMentionHint({ @@ -45,7 +269,10 @@ export function NativeChatMentionHint({ return ( ) } - -export function NativeChatSkillMenu({ - suggestions, - activeIndex, - onChoose -}: { - suggestions: DiscoveredSkill[] - activeIndex: number - onChoose: (skill: DiscoveredSkill) => void -}): React.JSX.Element { - const activeItemRef = useRef(null) - - useEffect(() => { - activeItemRef.current?.scrollIntoView({ block: 'nearest' }) - }, [activeIndex, suggestions]) - - return ( -
- {suggestions.length === 0 ? ( -
- {translate('components.native-chat.composer.noSkills', 'No matching skills')} -
- ) : null} - {suggestions.map((skill, index) => ( - - ))} -
- ) -} diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 25000521dbc..cb340ee0c13 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -6,6 +6,7 @@ import type { SessionOptionDescriptor, SessionOptionsSurface } from '../../../../shared/native-chat-session-options' +import type * as nativeChatAgentProfiles from '../../../../shared/native-chat-agent-profiles' import { clearNativeChatSessionOptionCacheForTests } from './native-chat-session-option-cache' const mocks = vi.hoisted(() => ({ @@ -63,11 +64,15 @@ vi.mock('./claude-model-switch-confirmation', () => ({ createClaudeModelSwitchConfirmationObserver: (...args: unknown[]) => mocks.createClaudeModelSwitchConfirmationObserver(...args) })) -vi.mock('./native-chat-agent-commands', () => ({ - getAgentSlashCommands: () => [] +vi.mock('../../../../shared/native-chat-agent-profiles', async (importOriginal) => ({ + ...(await importOriginal()), + getVerifiedNativeChatCommands: () => [] })) vi.mock('@/lib/native-chat-telemetry', () => ({ - emitNativeChatMessageSent: vi.fn() + emitNativeChatMessageSent: vi.fn(), + emitNativeChatPickerItemAccepted: vi.fn(), + emitNativeChatPickerOpened: vi.fn(), + emitNativeChatSendClassified: vi.fn() })) vi.mock('./use-native-chat-draft', () => ({ useNativeChatDraft: (scopeKey: string) => { @@ -84,7 +89,9 @@ vi.mock('./NativeChatComposerField', () => ({ return null } })) -vi.mock('./use-native-chat-skills', () => ({ useNativeChatSkills: () => [] })) +vi.mock('./use-native-chat-skills', () => ({ + useNativeChatSkills: () => ({ status: 'ready', skills: [], error: null, retry: () => {} }) +})) vi.mock('./use-native-chat-composer-attachments', () => ({ useNativeChatComposerAttachments: () => ({ imageAttachments: [], diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index e9a6f8f8407..b2c763e6e26 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -8,19 +8,13 @@ import { submitNativeChatPrompt } from './native-chat-runtime-send' import type { NativeChatSendHandle } from './native-chat-runtime-send' -import { getAgentSlashCommands } from './native-chat-agent-commands' +import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' import { applyMentionSuggestion, - applySkillSuggestion, - applySlashSuggestion, - deriveComposerAutocomplete, EMPTY_HISTORY, - isSlashCommandDraft, pushHistory, - slashCommandDispatchText, - type HistoryState, - type SlashCommandSuggestion + type HistoryState } from './native-chat-composer-state' import { readNativeChatDraftCache } from './native-chat-draft-cache' import { useNativeChatDraft } from './use-native-chat-draft' @@ -29,7 +23,6 @@ import { nativeChatComposerTargetIsRemote, type NativeChatResolvedTarget } from './native-chat-composer-target' -import { useNativeChatSkills } from './use-native-chat-skills' import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' @@ -39,6 +32,9 @@ import { useNativeChatSessionOptions } from './use-native-chat-session-options' import { useNativeChatFileAttachmentActions } from './use-native-chat-file-attachment-actions' import { useNativeChatDictationActions } from './use-native-chat-dictation-actions' import { useNativeChatSessionOptionCommand } from './use-native-chat-session-option-command' +import { useNativeChatPickerState } from './use-native-chat-picker-state' +import { useNativeChatPickerCommandDispatch } from './use-native-chat-picker-command-dispatch' +import { useNativeChatTypedInsertion } from './use-native-chat-typed-insertion' import type { NativeChatComposerHandle, NativeChatComposerProps @@ -92,8 +88,8 @@ export const NativeChatComposer = forwardRef(null) const [dictationPressed, setDictationPressed] = useState(false) - const skills = useNativeChatSkills(agent, terminalTabId) const textareaRef = useRef(null) + const isComposingRef = useRef(false) const { cancelPendingSends, trackPendingSend } = useNativeChatSendLifecycle( terminalTabId, targetPtyId, @@ -118,12 +114,27 @@ export const NativeChatComposer = forwardRef getAgentSlashCommands(agent), [agent]) - const autocomplete = useMemo( - () => - deriveComposerAutocomplete(draft, caret, agentCommands, agent === 'codex' ? skills : []), - [draft, caret, agentCommands, agent, skills] - ) + const agentCommands = useMemo(() => getVerifiedNativeChatCommands(agent), [agent]) + const picker = useNativeChatPickerState({ + agent, + terminalTabId, + draftScopeKey, + draft, + caret, + agentCommands, + textareaRef, + setDraft, + setCaret, + setActiveSuggestion + }) + const { + autocomplete, + classifySend, + clearSkillOrigin, + completeItem, + dismiss, + handleDraftOrCaretChange + } = picker // Resolve the live ptyId for this chat leaf; runtime owner settings route // local vs remote (SSH) sends. @@ -134,8 +145,7 @@ export const NativeChatComposer = forwardRef { setCaret(el.selectionStart ?? el.value.length) @@ -155,37 +165,15 @@ export const NativeChatComposer = forwardRef { - const textarea = textareaRef.current - if (!textarea || textarea.disabled) { - return false - } - const selectionStart = textarea.selectionStart ?? caret - const selectionEnd = textarea.selectionEnd ?? selectionStart - const next = `${draft.slice(0, selectionStart)}${text}${draft.slice(selectionEnd)}` - const nextCaret = selectionStart + text.length - textarea.focus() - setDraft(next) - setCaret(nextCaret) - setHistory((prev) => ({ entries: prev.entries, index: null })) - setActiveSuggestion(0) - requestAnimationFrame(() => { - textarea.setSelectionRange(nextCaret, nextCaret) - }) - return true - }, - [caret, draft, setDraft] - ) - - const focus = useCallback((): boolean => { - const textarea = textareaRef.current - if (!textarea || textarea.disabled) { - return false - } - textarea.focus() - return true - }, []) + const { insertTypedText, focus } = useNativeChatTypedInsertion({ + textareaRef, + caret, + draft, + setDraft, + setCaret, + setHistory, + setActiveSuggestion + }) const { attachExternalPaths, resolveAttachmentOwner } = useNativeChatExternalAttachments({ terminalTabId, @@ -249,14 +237,12 @@ export const NativeChatComposer = forwardRef 0) { pendingHandle = sendNativeChatMessageWithImageAttachments( @@ -270,14 +256,16 @@ export const NativeChatComposer = forwardRef pushHistory(prev, text)) setDraft('') setCaret(0) + clearSkillOrigin() clearImageAttachments() setNotice(null) }, [ agent, + classifySend, + clearSkillOrigin, clearImageAttachments, draft, imageAttachments, @@ -323,58 +314,32 @@ export const NativeChatComposer = forwardRef { - const next = applySlashSuggestion(command) - setDraft(next) - setCaret(next.length) - setActiveSuggestion(0) - textareaRef.current?.focus() - }, - [setDraft] - ) - - const dispatchSlash = useCallback( - (command: SlashCommandSuggestion) => { - const next = slashCommandDispatchText(command) - const target = resolveTarget() - if (!target || disabled) { - return - } - trackPendingSend(sendNativeChatMessage(target.settings, target.ptyId, next)) - // Surface the command as a system line (this is the autocomplete-menu - // dispatch path; the typed-Enter path in `send` does the same). - onSlashCommand?.(next.trim()) - sessionOptionsSurface?.recordOutgoingCommand(next.trim()) - emitNativeChatMessageSent({ - agent, - runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local' - }) - setHistory((prev) => pushHistory(prev, next)) - setDraft('') - setCaret(0) - setActiveSuggestion(0) - setNotice(null) - }, - [ - agent, - disabled, - resolveTarget, - onSlashCommand, - sessionOptionsSurface, - setDraft, - trackPendingSend - ] - ) + const dispatchPickerCommand = useNativeChatPickerCommandDispatch({ + agent, + disabled, + isDispatchingSessionOption, + resolveTarget, + onSlashCommand, + sessionOptionsSurface, + trackPendingSend, + setHistory, + setDraft, + setCaret, + setActiveSuggestion, + clearSkillOrigin, + clearImageAttachments, + setNotice + }) const handleKeyDown = useNativeChatComposerKeyDown({ autocomplete, activeSuggestion, draft, - caret, history, - chooseSlash, - dispatchSlash, + isComposing: () => isComposingRef.current, + completePickerItem: completeItem, + dispatchPickerCommand, + dismissPicker: dismiss, interrupt, send, setActiveSuggestion, @@ -404,12 +369,25 @@ export const NativeChatComposer = forwardRef ({ entries: prev.entries, index: null })) syncCaret(element) + handleDraftOrCaretChange(value, element.selectionStart ?? value.length) + setActiveSuggestion(0) + }} + onTextareaSelect={(element) => { + syncCaret(element) + handleDraftOrCaretChange(element.value, element.selectionStart ?? element.value.length) setActiveSuggestion(0) }} - onTextareaSelect={syncCaret} onKeyDown={handleKeyDown} + onCompositionStart={() => { + isComposingRef.current = true + }} + onCompositionEnd={() => { + isComposingRef.current = false + }} onPaste={handlePaste} - onChooseSlash={chooseSlash} + pickerListboxId={picker.listboxId} + onChoosePickerItem={completeItem} + onRetrySkills={picker.retrySkills} onAcceptMention={() => { if (autocomplete.mode !== 'mention') { return @@ -417,14 +395,9 @@ export const NativeChatComposer = forwardRef { - const result = applySkillSuggestion(draft, caret, skill.name) - setDraft(result.draft) - setCaret(result.caret) - setActiveSuggestion(0) - textareaRef.current?.focus() + const textarea = textareaRef.current + textarea?.focus() + requestAnimationFrame(() => textarea?.setSelectionRange(result.caret, result.caret)) }} onRemoveImageAttachment={(id) => removeImageAttachment(id)} onAttach={pickAttachment} diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index fd76146650b..8da288e3b39 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -1,19 +1,19 @@ -import type { ClipboardEventHandler, KeyboardEventHandler, RefObject } from 'react' +import type { + ClipboardEventHandler, + CompositionEventHandler, + KeyboardEventHandler, + RefObject +} from 'react' import { Image as ImageIcon, ImageOff, X } from 'lucide-react' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop' import { basename } from '@/lib/path' import { isNativeChatPastedImagePath } from './native-chat-image-paste' -import type { ComposerAutocomplete, SlashCommandSuggestion } from './native-chat-composer-state' -import { - NativeChatMentionHint, - NativeChatSkillMenu, - NativeChatSlashMenu -} from './NativeChatAutocompleteMenus' +import type { ComposerAutocomplete, NativeChatPickerItem } from './native-chat-composer-state' +import { NativeChatMentionHint, NativeChatPickerMenu } from './NativeChatAutocompleteMenus' import { NativeChatComposerActions } from './NativeChatComposerActions' import { nativeChatComposerPlaceholder } from './native-chat-composer-target' -import type { DiscoveredSkill } from '../../../../shared/skills' import type { SessionOptionDescriptor, SessionOptionsSurface @@ -38,10 +38,13 @@ export type NativeChatComposerFieldProps = { onDraftChange: (value: string, element: HTMLTextAreaElement) => void onTextareaSelect: (element: HTMLTextAreaElement) => void onKeyDown: KeyboardEventHandler + onCompositionStart: CompositionEventHandler + onCompositionEnd: CompositionEventHandler onPaste: ClipboardEventHandler - onChooseSlash: (command: SlashCommandSuggestion) => void + pickerListboxId: string + onChoosePickerItem: (item: NativeChatPickerItem) => void + onRetrySkills: () => void onAcceptMention: () => void - onChooseSkill: (skill: DiscoveredSkill) => void onRemoveImageAttachment: (id: string) => void onAttach: () => void onDictationToggle: () => void @@ -77,10 +80,13 @@ export function NativeChatComposerField({ onDraftChange, onTextareaSelect, onKeyDown, + onCompositionStart, + onCompositionEnd, onPaste, - onChooseSlash, + pickerListboxId, + onChoosePickerItem, + onRetrySkills, onAcceptMention, - onChooseSkill, onRemoveImageAttachment, onAttach, onDictationToggle, @@ -96,23 +102,18 @@ export function NativeChatComposerField({ {/* Extra bottom padding keeps the input box off the window rim. */}
- {autocomplete.mode === 'slash' && autocomplete.suggestions.length > 0 ? ( - ) : null} {autocomplete.mode === 'mention' ? ( ) : null} - {autocomplete.mode === 'skill' ? ( - - ) : null} {notice ? (
@@ -168,8 +169,22 @@ export function NativeChatComposerField({ rows={2} onChange={(e) => onDraftChange(e.target.value, e.currentTarget)} onKeyDown={onKeyDown} + onCompositionStart={onCompositionStart} + onCompositionEnd={onCompositionEnd} onPaste={onPaste} onSelect={(e) => onTextareaSelect(e.currentTarget)} + aria-expanded={autocomplete.mode === 'slash' || autocomplete.mode === 'skill'} + aria-controls={ + autocomplete.mode === 'slash' || autocomplete.mode === 'skill' + ? pickerListboxId + : undefined + } + aria-activedescendant={ + (autocomplete.mode === 'slash' || autocomplete.mode === 'skill') && + autocomplete.items.length > 0 + ? `${pickerListboxId}-option-${Math.min(activeSuggestion, autocomplete.items.length - 1)}` + : undefined + } placeholder={nativeChatComposerPlaceholder(hasPty, canSend)} // Why: coarse-pointer min-height follows the app's touch target convention. // scrollbar-sleek keeps the overflow gutter from showing the heavy diff --git a/src/renderer/src/components/native-chat/native-chat-agent-commands.ts b/src/renderer/src/components/native-chat/native-chat-agent-commands.ts deleted file mode 100644 index 3c363bf5e53..00000000000 --- a/src/renderer/src/components/native-chat/native-chat-agent-commands.ts +++ /dev/null @@ -1,8 +0,0 @@ -// The per-agent slash-command catalog now lives in src/shared so the desktop -// renderer and the mobile app share one source of truth (no drift). This file -// re-exports it for existing desktop import sites. - -export { - getAgentSlashCommands, - type SlashCommandSuggestion -} from '../../../../shared/native-chat-slash-commands' diff --git a/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts b/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts index 499d2b1610c..802ecfe5c45 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-state.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from 'vitest' import { applyMentionSuggestion, - applySkillSuggestion, + applyPickerSuggestion, applySlashSuggestion, + buildNativeChatPickerItems, + classifyNativeChatSend, deriveComposerAutocomplete, + editReplacesTriggerToken, EMPTY_HISTORY, - filterSkillSuggestions, filterSlashCommands, isSlashCommandDraft, pushHistory, @@ -15,6 +17,7 @@ import { type SlashCommandSuggestion } from './native-chat-composer-state' import type { DiscoveredSkill } from '../../../../shared/skills' +import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' const COMMANDS: SlashCommandSuggestion[] = [ { name: 'clear' }, @@ -48,7 +51,7 @@ describe('deriveComposerAutocomplete — slash', () => { return } expect(result.query).toBe('cl') - expect(result.suggestions.map((c) => c.name)).toEqual(['clear']) + expect(result.items.map((item) => item.name)).toEqual(['clear']) }) it('a bare `/` returns the full command list', () => { @@ -57,7 +60,7 @@ describe('deriveComposerAutocomplete — slash', () => { if (result.mode !== 'slash') { return } - expect(result.suggestions).toHaveLength(3) + expect(result.items).toHaveLength(3) }) it('does not fire slash mode after a space', () => { @@ -106,7 +109,7 @@ describe('deriveComposerAutocomplete — skill', () => { return } expect(result.query).toBe('type') - expect(result.suggestions.map((entry) => entry.name)).toEqual(['typescript']) + expect(result.items.map((entry) => entry.name)).toEqual(['typescript']) }) it('fires at the start of input too', () => { @@ -132,20 +135,6 @@ describe('isSlashCommandDraft', () => { }) }) -describe('filterSkillSuggestions', () => { - it('filters installed skills by name or directory basename', () => { - const skills = [ - skill({ name: 'TypeScript' }), - skill({ name: 'Display Name', directoryPath: '/repo/.agents/skills/ref-oss' }), - skill({ name: 'hidden', installed: false }) - ] - expect(filterSkillSuggestions(skills, 'ref').map((entry) => entry.name)).toEqual([ - 'Display Name' - ]) - expect(filterSkillSuggestions(skills, 'h')).toEqual([]) - }) -}) - describe('history recall', () => { it('up-arrow on empty composer recalls the last sent input', () => { const history = pushHistory(EMPTY_HISTORY, 'first') @@ -205,9 +194,192 @@ describe('apply suggestions', () => { expect(result.caret).toBe('open @src/app.ts '.length) }) - it('applySkillSuggestion replaces the active $token at the caret', () => { - const result = applySkillSuggestion('use $typ now', 8, 'typescript') + it('applyPickerSuggestion replaces the active $token at the caret', () => { + const result = applyPickerSuggestion( + 'use $typ now', + 8, + { kind: 'skill', id: 'skill:typescript', name: 'typescript', description: null, sources: [] }, + '$' + ) expect(result.draft).toBe('use $typescript now') expect(result.caret).toBe('use $typescript '.length) }) }) + +describe('native skill and command picker', () => { + it('keeps Codex commands under slash and skills under dollar', () => { + const profile = getNativeChatAgentProfile('codex') + const slash = deriveComposerAutocomplete('/', 1, COMMANDS, [skill({})], profile) + expect(slash.mode).toBe('slash') + if (slash.mode === 'slash') { + expect(slash.items.every((item) => item.kind === 'command')).toBe(true) + } + const dollar = deriveComposerAutocomplete('$', 1, COMMANDS, [skill({})], profile) + expect(dollar.mode).toBe('skill') + if (dollar.mode === 'skill') { + expect(dollar.items.every((item) => item.kind === 'skill')).toBe(true) + } + }) + + it('groups Claude commands and skills under slash', () => { + const result = deriveComposerAutocomplete( + '/', + 1, + COMMANDS, + [skill({ name: 'browser' })], + getNativeChatAgentProfile('claude') + ) + expect(result.mode).toBe('slash') + if (result.mode === 'slash') { + expect(result.grouped).toBe(true) + expect(result.items.map((item) => item.kind)).toContain('command') + expect(result.items.map((item) => item.kind)).toContain('skill') + } + }) + + it('ranks exact, prefix, fuzzy, then description matches within a group', () => { + const items = buildNativeChatPickerItems( + [], + [ + skill({ name: 'deploy', skillFilePath: '/1/SKILL.md' }), + skill({ name: 'deployment', skillFilePath: '/2/SKILL.md' }), + skill({ name: 'd-e-p-l-o-y', skillFilePath: '/3/SKILL.md' }), + skill({ + name: 'release', + description: 'Deploy an application', + skillFilePath: '/4/SKILL.md' + }) + ], + 'deploy', + '$' + ) + expect(items.map((item) => item.name)).toEqual([ + 'deploy', + 'deployment', + 'd-e-p-l-o-y', + 'release' + ]) + }) + + it('merges duplicate names but annotates command collisions on one command row', () => { + const duplicateSkills = [ + skill({ name: 'clear', skillFilePath: '/project/clear/SKILL.md', sourceKind: 'repo' }), + skill({ name: 'clear', skillFilePath: '/home/clear/SKILL.md', sourceKind: 'home' }) + ] + const skillOnly = buildNativeChatPickerItems([], duplicateSkills, '', '$') + expect(skillOnly).toEqual([ + expect.objectContaining({ kind: 'skill', name: 'clear', sources: expect.any(Array) }) + ]) + expect(skillOnly[0].kind === 'skill' ? skillOnly[0].sources : []).toHaveLength(2) + + const collision = buildNativeChatPickerItems(COMMANDS, duplicateSkills, 'clear', '/') + expect(collision).toEqual([ + expect.objectContaining({ kind: 'command', name: 'clear', skillCollision: true }) + ]) + }) + + it('keeps a long token-safe name intact for insertion instead of truncating it', () => { + const longName = `skill-${'x'.repeat(100)}` + const items = buildNativeChatPickerItems( + [], + [skill({ name: longName, skillFilePath: '/long/SKILL.md' })], + '', + '$' + ) + expect(items.map((item) => item.name)).toEqual([longName]) + const applied = applyPickerSuggestion('$sk', 3, items[0], '$') + expect(applied.draft).toBe(`$${longName} `) + }) + + it('rejects names carrying zero-width characters instead of inserting them', () => { + const items = buildNativeChatPickerItems( + [], + [ + skill({ + name: 'cle\u200bar', + directoryPath: '/repo/.agents/skills/safe-dir', + skillFilePath: '/repo/.agents/skills/safe-dir/SKILL.md' + }) + ], + '', + '$' + ) + expect(items.map((item) => item.name)).toEqual(['safe-dir']) + }) + + it('falls back to a token-safe directory name and strips unsafe display text', () => { + const items = buildNativeChatPickerItems( + [], + [ + skill({ + name: 'Spoof\u202e Name', + directoryPath: '/repo/.agents/skills/safe-name', + skillFilePath: '/repo/.agents/skills/safe-name/SKILL.md' + }) + ], + '', + '$' + ) + expect(items.map((item) => item.name)).toEqual(['safe-name']) + }) + + it('replaces only the active slash token and preserves text after the caret', () => { + const result = applyPickerSuggestion( + '/bro trailing', + 4, + { kind: 'skill', id: 'skill:browser', name: 'browser', description: null, sources: [] }, + '/' + ) + expect(result.draft).toBe('/browser trailing') + expect(result.caret).toBe('/browser '.length) + }) + + it('classifies sends only from the origin tag and exact command catalog', () => { + expect(classifyNativeChatSend('/browser do work', COMMANDS, '/browser', '/')).toBe('chat') + expect(classifyNativeChatSend('/clear', COMMANDS, null, '/')).toBe('command') + expect(classifyNativeChatSend('/Clear', COMMANDS, null, '/')).toBe('unknown-token') + expect(classifyNativeChatSend('/usr/bin/python is missing', COMMANDS, null, '/')).toBe( + 'unknown-token' + ) + expect(classifyNativeChatSend('ordinary prompt', COMMANDS, null, '/')).toBe('chat') + }) + + it('leading whitespace makes a slash draft prose, never a dispatched command', () => { + expect(classifyNativeChatSend(' /clear', COMMANDS, null, '/')).toBe('chat') + }) + + it('treats a leading $ token as unknown only for the $-prefix (Codex) profile', () => { + expect(classifyNativeChatSend('$deploy now', COMMANDS, null, '$')).toBe('unknown-token') + expect(classifyNativeChatSend('$PATH is wrong', COMMANDS, null, '/')).toBe('chat') + expect(classifyNativeChatSend('$50 is the budget', COMMANDS, null, null)).toBe('chat') + }) + + it('treats a one-edit token swap as a new trigger occurrence', () => { + expect(editReplacesTriggerToken('/foo', '/bar', '/:0')).toBe(true) + expect(editReplacesTriggerToken('use $foo', 'use $bar', '$:4')).toBe(true) + }) + + it('keeps suppression while typing or deleting inside the dismissed token', () => { + expect(editReplacesTriggerToken('/foo', '/food', '/:0')).toBe(false) + expect(editReplacesTriggerToken('/food', '/foo', '/:0')).toBe(false) + expect(editReplacesTriggerToken('use $foo now', 'ran $foo now', '$:4')).toBe(false) + }) + + it('suppresses only the dismissed trigger occurrence', () => { + const profile = getNativeChatAgentProfile('codex') + expect(deriveComposerAutocomplete('use $bro', 8, COMMANDS, [skill({})], profile).mode).toBe( + 'skill' + ) + expect( + deriveComposerAutocomplete( + 'use $bro', + 8, + COMMANDS, + [skill({})], + profile, + { status: 'ready', skills: [skill({})] }, + '$:4' + ).mode + ).toBe('none') + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-composer-state.ts b/src/renderer/src/components/native-chat/native-chat-composer-state.ts index 9871bba1b95..c7c320af52e 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-state.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-state.ts @@ -1,11 +1,5 @@ -// Pure state machine for the native chat composer. Given the current draft, -// caret position, sent-input history, and the active agent's known slash -// commands/skills, it derives the autocomplete mode, the -// query, and the filtered suggestions. Keeping this DOM-free makes the slash / -// mention / skill / history behavior unit-testable; the .tsx only owns rendering and -// the actual textarea/caret wiring. - import type { DiscoveredSkill } from '../../../../shared/skills' +import type { NativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' import { filterSlashCommands, isSlashCommandDraft, @@ -13,145 +7,165 @@ import { slashCommandDispatchText, type SlashCommandSuggestion } from '../../../../shared/native-chat-slash-commands' +import { + buildNativeChatPickerItems, + type NativeChatPickerItem, + type NativeChatSkillDiscoverySnapshot +} from './native-chat-picker-items' export type { SlashCommandSuggestion } export { filterSlashCommands, isSlashCommandDraft, applySlashSuggestion, slashCommandDispatchText } +export { + applyPickerSuggestion, + buildNativeChatPickerItems, + classifyNativeChatSend, + type NativeChatPickerItem, + type NativeChatSendClassification, + type NativeChatSkillDiscoverySnapshot +} from './native-chat-picker-items' export type ComposerAutocompleteMode = 'none' | 'slash' | 'mention' | 'skill' -export type ComposerAutocomplete = - | { mode: 'none' } - | { mode: 'slash'; query: string; suggestions: SlashCommandSuggestion[] } - | { mode: 'mention'; query: string } - | { mode: 'skill'; query: string; suggestions: DiscoveredSkill[] } - -export type ComposerDerivation = { - autocomplete: ComposerAutocomplete +type PickerAutocomplete = { + query: string + items: NativeChatPickerItem[] + triggerKey: string + prefix: '/' | '$' + grouped: boolean + commandsEnabled: boolean + skillsEnabled: boolean + skillStatus: NativeChatSkillDiscoverySnapshot['status'] + skillErrorKind?: NativeChatSkillDiscoverySnapshot['errorKind'] } -/** - * Detect the active autocomplete trigger from the text before the caret. - * - * Rules (intentionally conservative to avoid firing inside ordinary prose): - * - Slash: the draft starts with `/` and the caret is within that first token - * (no whitespace between `/` and the caret). Agent slash commands are only - * valid at the very start of the input, mirroring how the TUIs accept them. - * - Mention: an `@` immediately preceded by start-of-input or whitespace, with - * no whitespace between it and the caret. Works anywhere in the line so you - * can reference a file mid-sentence. - */ +export type ComposerAutocomplete = + | { mode: 'none' } + | ({ mode: 'slash' } & PickerAutocomplete) + | { mode: 'mention'; query: string } + | ({ mode: 'skill' } & PickerAutocomplete) + +const EMPTY_DISCOVERY: NativeChatSkillDiscoverySnapshot = { status: 'ready', skills: [] } + export function deriveComposerAutocomplete( draft: string, caret: number, agentCommands: readonly SlashCommandSuggestion[], - skills: readonly DiscoveredSkill[] = [] + skills: readonly DiscoveredSkill[] = [], + profile: NativeChatAgentProfile | null = null, + discovery: NativeChatSkillDiscoverySnapshot = { ...EMPTY_DISCOVERY, skills }, + dismissedTriggerKey: string | null = null ): ComposerAutocomplete { const before = draft.slice(0, caret) - - // Slash: only at the absolute start of the input, and only while the caret is - // still inside the unbroken command token. if (before.startsWith('/') && !/\s/.test(before)) { - const query = before.slice(1) - return { mode: 'slash', query, suggestions: filterSlashCommands(agentCommands, query) } + return deriveSlashAutocomplete(before, agentCommands, profile, discovery, dismissedTriggerKey) } - const mentionMatch = before.match(/(?:^|\s)@(\S*)$/) if (mentionMatch) { return { mode: 'mention', query: mentionMatch[1] } } - - const skillMatch = before.match(/(?:^|\s)\$(\S*)$/) - if (skillMatch) { - const query = skillMatch[1] - return { mode: 'skill', query, suggestions: filterSkillSuggestions(skills, query) } + const skillMatch = + profile?.skillPrefix === '$' || (!profile && skills.length > 0) + ? before.match(/(?:^|\s)\$(\S*)$/) + : null + if (!skillMatch) { + return { mode: 'none' } } - - return { mode: 'none' } -} - -export function filterSkillSuggestions( - skills: readonly DiscoveredSkill[], - query: string -): DiscoveredSkill[] { - const normalized = query.toLowerCase() - const installed = skills.filter((skill) => skill.installed) - if (normalized === '') { - return installed.slice(0, 12) + const triggerKey = `$:${before.length - skillMatch[1].length - 1}` + if (dismissedTriggerKey === triggerKey) { + return { mode: 'none' } } - return installed - .filter((skill) => { - const name = skill.name.toLowerCase() - const dirName = skill.directoryPath.split(/[\\/]/).findLast(Boolean)?.toLowerCase() - return name.startsWith(normalized) || dirName?.startsWith(normalized) - }) - .slice(0, 12) -} - -export type HistoryState = { - /** Most-recent-last list of previously sent drafts. */ - entries: readonly string[] - /** Cursor into `entries`; null means "live draft" (not recalling). */ - index: number | null -} - -export const EMPTY_HISTORY: HistoryState = { entries: [], index: null } - -/** Append a sent draft to history and reset the recall cursor. Blank sends and - * immediate duplicates of the last entry are not recorded (shell-style). */ -export function pushHistory(history: HistoryState, sent: string): HistoryState { - if (sent.trim() === '') { - return { entries: history.entries, index: null } - } - if (history.entries.at(-1) === sent) { - return { entries: history.entries, index: null } - } - return { entries: [...history.entries, sent], index: null } -} - -export type HistoryRecall = { - history: HistoryState - /** The draft text to show, or null to leave the live draft untouched. */ - draft: string | null -} - -/** - * Move one step toward older entries (Up arrow). From the live draft this jumps - * to the most recent entry; thereafter it walks backward and clamps at the - * oldest. Returns the recalled draft, or null when there is nothing to recall. - */ -export function recallPrevious(history: HistoryState): HistoryRecall { - if (history.entries.length === 0) { - return { history, draft: null } - } - const nextIndex = - history.index === null ? history.entries.length - 1 : Math.max(0, history.index - 1) + const query = skillMatch[1] return { - history: { entries: history.entries, index: nextIndex }, - draft: history.entries[nextIndex] + mode: 'skill', + query, + triggerKey, + prefix: '$', + grouped: false, + commandsEnabled: false, + skillsEnabled: true, + items: buildNativeChatPickerItems([], discovery.skills, query, '$'), + skillStatus: discovery.status === 'idle' ? 'loading' : discovery.status, + ...(discovery.errorKind ? { skillErrorKind: discovery.errorKind } : {}) } } -/** - * Move one step toward newer entries (Down arrow). Walking past the newest entry - * returns to the live (empty) draft and clears the recall cursor. Returns null - * draft when not currently recalling. - */ -export function recallNext(history: HistoryState): HistoryRecall { - if (history.index === null) { - return { history, draft: null } - } - const nextIndex = history.index + 1 - if (nextIndex >= history.entries.length) { - return { history: { entries: history.entries, index: null }, draft: '' } +function deriveSlashAutocomplete( + before: string, + agentCommands: readonly SlashCommandSuggestion[], + profile: NativeChatAgentProfile | null, + discovery: NativeChatSkillDiscoverySnapshot, + dismissedTriggerKey: string | null +): ComposerAutocomplete { + const triggerKey = '/:0' + if (dismissedTriggerKey === triggerKey) { + return { mode: 'none' } } + const query = before.slice(1) + const hasSlashSkills = profile?.skillPrefix === '/' + // Why: the caller owns catalog policy (e.g. Grok ships skills-only until a + // verified catalog lands); this derivation must not re-gate per agent. + const items = buildNativeChatPickerItems( + agentCommands, + hasSlashSkills ? discovery.skills : [], + query, + '/' + ) return { - history: { entries: history.entries, index: nextIndex }, - draft: history.entries[nextIndex] + mode: 'slash', + query, + triggerKey, + prefix: '/', + grouped: profile?.groupedSlash === true, + commandsEnabled: agentCommands.length > 0, + skillsEnabled: hasSlashSkills, + items, + skillStatus: hasSlashSkills + ? discovery.status === 'idle' + ? 'loading' + : discovery.status + : 'ready', + ...(hasSlashSkills && discovery.errorKind ? { skillErrorKind: discovery.errorKind } : {}) } } -/** Replace the active `@query` token before the caret with the chosen path. - * Returns the new full draft and the caret offset to place after insertion. */ +/** True when one edit both removed and inserted text across the dismissed + * trigger token — a wholesale replacement (e.g. select-all + paste), which is + * a new trigger occurrence even though a trigger character lands back on the + * same draft position. Typing or deleting inside the token is not. */ +export function editReplacesTriggerToken( + previous: string, + next: string, + triggerKey: string +): boolean { + const triggerPosition = Number.parseInt(triggerKey.slice(triggerKey.indexOf(':') + 1), 10) + if (!Number.isFinite(triggerPosition) || previous === next) { + return false + } + let commonPrefix = 0 + const maxPrefix = Math.min(previous.length, next.length) + while (commonPrefix < maxPrefix && previous[commonPrefix] === next[commonPrefix]) { + commonPrefix += 1 + } + let commonSuffix = 0 + while ( + commonSuffix < previous.length - commonPrefix && + commonSuffix < next.length - commonPrefix && + previous[previous.length - 1 - commonSuffix] === next[next.length - 1 - commonSuffix] + ) { + commonSuffix += 1 + } + const removed = previous.length - commonPrefix - commonSuffix + const inserted = next.length - commonPrefix - commonSuffix + if (removed === 0 || inserted === 0) { + return false + } + let tokenEnd = triggerPosition + 1 + while (tokenEnd < previous.length && !/\s/.test(previous[tokenEnd])) { + tokenEnd += 1 + } + return commonPrefix < tokenEnd && previous.length - commonSuffix > triggerPosition +} + export function applyMentionSuggestion( draft: string, caret: number, @@ -163,25 +177,38 @@ export function applyMentionSuggestion( if (!match) { return { draft, caret } } - const tokenStart = before.length - match[2].length - 1 // -1 for the '@' - const insertion = `@${path} ` - const nextBefore = before.slice(0, tokenStart) + insertion + const tokenStart = before.length - match[2].length - 1 + const nextBefore = `${before.slice(0, tokenStart)}@${path} ` return { draft: nextBefore + after, caret: nextBefore.length } } -export function applySkillSuggestion( - draft: string, - caret: number, - skillName: string -): { draft: string; caret: number } { - const before = draft.slice(0, caret) - const after = draft.slice(caret) - const match = before.match(/(^|\s)\$(\S*)$/) - if (!match) { - return { draft, caret } +export type HistoryState = { entries: readonly string[]; index: number | null } +export const EMPTY_HISTORY: HistoryState = { entries: [], index: null } + +export function pushHistory(history: HistoryState, sent: string): HistoryState { + if (sent.trim() === '' || history.entries.at(-1) === sent) { + return { entries: history.entries, index: null } } - const tokenStart = before.length - match[2].length - 1 // -1 for the '$' - const insertion = `$${skillName} ` - const nextBefore = before.slice(0, tokenStart) + insertion - return { draft: nextBefore + after, caret: nextBefore.length } + return { entries: [...history.entries, sent], index: null } +} + +export type HistoryRecall = { history: HistoryState; draft: string | null } + +export function recallPrevious(history: HistoryState): HistoryRecall { + if (history.entries.length === 0) { + return { history, draft: null } + } + const index = history.index === null ? history.entries.length - 1 : Math.max(0, history.index - 1) + return { history: { entries: history.entries, index }, draft: history.entries[index] } +} + +export function recallNext(history: HistoryState): HistoryRecall { + if (history.index === null) { + return { history, draft: null } + } + const index = history.index + 1 + if (index >= history.entries.length) { + return { history: { entries: history.entries, index: null }, draft: '' } + } + return { history: { entries: history.entries, index }, draft: history.entries[index] } } diff --git a/src/renderer/src/components/native-chat/native-chat-picker-items.ts b/src/renderer/src/components/native-chat/native-chat-picker-items.ts new file mode 100644 index 00000000000..e1c2c4fc78f --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-picker-items.ts @@ -0,0 +1,248 @@ +import type { DiscoveredSkill, SkillSourceKind } from '../../../../shared/skills' +import type { SlashCommandSuggestion } from '../../../../shared/native-chat-slash-commands' +import { + isSafeDisplayCharacter, + stripUnsafeDisplayCharacters +} from '../../../../shared/skill-display-text' + +export type NativeChatPickerItem = + | { + kind: 'command' + id: string + name: string + description?: string + skillCollision: boolean + } + | { + kind: 'skill' + id: string + name: string + description: string | null + sources: { sourceKind: SkillSourceKind; skillFilePath: string }[] + } + +export type NativeChatSkillDiscoverySnapshot = { + status: 'idle' | 'loading' | 'ready' | 'error' + skills: readonly DiscoveredSkill[] + errorKind?: 'unavailable' | 'timeout' | 'host' | 'unknown' +} + +const PICKER_RESULT_LIMIT = 50 +const SCOPE_PRIORITY: Record = { + repo: 0, + home: 1, + bundled: 2, + plugin: 3 +} + +export function buildNativeChatPickerItems( + commands: readonly SlashCommandSuggestion[], + skills: readonly DiscoveredSkill[], + query: string, + prefix: '/' | '$' +): NativeChatPickerItem[] { + const mergedSkills = mergeNativeChatSkills(skills) + const skillNames = new Set(mergedSkills.map((skill) => skill.name)) + const commandNames = new Set(commands.map((command) => command.name)) + const commandItems = rankItems( + commands.map((command, index) => ({ + item: { + kind: 'command' as const, + // Why: the name is the dispatch token and the catalog is curated, so + // it is inserted verbatim; only untrusted skill text gets sanitized. + id: `command:${command.name}`, + name: command.name, + description: command.description ? sanitizePickerText(command.description, 240) : undefined, + skillCollision: prefix === '/' && skillNames.has(command.name) + }, + stableOrder: index + })), + query + ) + const skillItems = rankItems( + mergedSkills + .filter((skill) => !(prefix === '/' && commandNames.has(skill.name))) + .map((item, index) => ({ item, stableOrder: index })), + query + ) + return [ + ...commandItems.slice(0, PICKER_RESULT_LIMIT), + ...skillItems.slice(0, PICKER_RESULT_LIMIT) + ] +} + +function mergeNativeChatSkills( + skills: readonly DiscoveredSkill[] +): Extract[] { + const exactPaths = new Map() + for (const skill of skills) { + if (skill.installed && !exactPaths.has(skill.skillFilePath)) { + exactPaths.set(skill.skillFilePath, skill) + } + } + const byName = new Map() + for (const skill of exactPaths.values()) { + const safeName = getSafeSkillName(skill) + if (!safeName) { + continue + } + byName.set(safeName, [...(byName.get(safeName) ?? []), { ...skill, name: safeName }]) + } + return [...byName.entries()] + .map(([name, namedSkills]) => { + const sorted = [...namedSkills].sort(compareDiscoveredSkills) + return { + kind: 'skill' as const, + id: `skill:${name}`, + name, + description: sorted[0]?.description ? sanitizePickerText(sorted[0].description, 240) : null, + sources: sorted.map((skill) => ({ + sourceKind: skill.sourceKind, + skillFilePath: skill.skillFilePath + })) + } + }) + .sort(comparePickerSkills) +} + +function rankItems( + entries: { item: T; stableOrder: number }[], + query: string +): T[] { + if (!query) { + return entries.map((entry) => entry.item) + } + return entries + .map((entry) => ({ ...entry, rank: getMatchRank(entry.item, query) })) + .filter((entry) => entry.rank !== null) + .sort((a, b) => a.rank! - b.rank! || a.stableOrder - b.stableOrder) + .map((entry) => entry.item) +} + +function getMatchRank( + item: Pick, + query: string +): number | null { + const normalizedQuery = query.toLocaleLowerCase() + const name = item.name.toLocaleLowerCase() + if (name === normalizedQuery) { + return 0 + } + if (name.startsWith(normalizedQuery)) { + return 1 + } + if (name.includes(normalizedQuery)) { + return 2 + } + if (isSubsequence(normalizedQuery, name)) { + return 3 + } + if (item.description?.toLocaleLowerCase().includes(normalizedQuery)) { + return 4 + } + return null +} + +function isSubsequence(query: string, value: string): boolean { + let queryIndex = 0 + for (const character of value) { + if (character === query[queryIndex]) { + queryIndex += 1 + } + if (queryIndex === query.length) { + return true + } + } + return false +} + +// Why: the row's visual truncation is CSS; the name IS the inserted PTY token, +// so it must never be sliced. Token safety instead rejects absurd lengths. +const MAX_TOKEN_SAFE_NAME_LENGTH = 200 + +function getSafeSkillName(skill: DiscoveredSkill): string | null { + if (isTokenSafe(skill.name)) { + return skill.name + } + const directoryName = skill.directoryPath.split(/[\\/]/).findLast(Boolean) ?? '' + return isTokenSafe(directoryName) ? directoryName : null +} + +function isTokenSafe(value: string): boolean { + return ( + value.length > 0 && + value.length <= MAX_TOKEN_SAFE_NAME_LENGTH && + !/\s/u.test(value) && + [...value].every(isSafeDisplayCharacter) + ) +} + +function sanitizePickerText(value: string, maxLength: number): string { + return stripUnsafeDisplayCharacters(value).slice(0, maxLength) +} + +function compareDiscoveredSkills(a: DiscoveredSkill, b: DiscoveredSkill): number { + return ( + SCOPE_PRIORITY[a.sourceKind] - SCOPE_PRIORITY[b.sourceKind] || + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) || + a.skillFilePath.localeCompare(b.skillFilePath) + ) +} + +function comparePickerSkills( + a: Extract, + b: Extract +): number { + return ( + SCOPE_PRIORITY[a.sources[0].sourceKind] - SCOPE_PRIORITY[b.sources[0].sourceKind] || + a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) + ) +} + +export function applyPickerSuggestion( + draft: string, + caret: number, + item: NativeChatPickerItem, + prefix: '/' | '$' +): { draft: string; caret: number; insertedToken: string } { + const before = draft.slice(0, caret) + const after = draft.slice(caret) + const match = prefix === '/' ? before.match(/^\/(\S*)$/) : before.match(/(^|\s)\$(\S*)$/) + if (!match) { + return { draft, caret, insertedToken: '' } + } + const query = match.at(-1) ?? '' + const tokenStart = before.length - query.length - 1 + const insertedToken = `${prefix}${item.name}` + const nextBefore = `${before.slice(0, tokenStart)}${insertedToken} ` + return { draft: nextBefore + after, caret: nextBefore.length, insertedToken } +} + +export type NativeChatSendClassification = 'chat' | 'command' | 'unknown-token' + +export function classifyNativeChatSend( + draft: string, + commands: readonly SlashCommandSuggestion[], + pickerSkillOriginToken: string | null, + skillPrefix: '/' | '$' | null +): NativeChatSendClassification { + // Why: the supported TUIs only treat a line-leading token as a command, so a + // draft with leading whitespace is prose; trimming here would claim a "Ran" + // line for text the agent never dispatched. + const firstToken = draft.split(/\s/, 1)[0] ?? '' + if (pickerSkillOriginToken && firstToken === pickerSkillOriginToken) { + return 'chat' + } + if (commands.some((command) => firstToken === `/${command.name}`)) { + return 'command' + } + if (firstToken.startsWith('/')) { + return 'unknown-token' + } + // Why: `$` is Codex grammar only. For other agents a leading `$PATH`-style + // token is ordinary prose and must keep its bubble and attachments. + if (skillPrefix === '$' && firstToken.startsWith('$')) { + return 'unknown-token' + } + return 'chat' +} diff --git a/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts b/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts new file mode 100644 index 00000000000..17e05af6a5c --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts @@ -0,0 +1,156 @@ +import type { AppState } from '../../store/types' +import type { SkillDiscoveryTarget } from '../../../../shared/skills' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { + getExplicitRuntimeEnvironmentIdForWorktree, + getExecutionHostIdForWorktree +} from '@/lib/worktree-runtime-owner' +import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' + +export type NativeChatSkillStateInputs = Pick< + AppState, + | 'activeRepoId' + | 'activeWorktreeId' + | 'folderWorkspaces' + | 'projectGroups' + | 'projects' + | 'repos' + | 'restoredRuntimeHostIdByWorkspaceSessionKey' + | 'settings' + | 'tabsByWorktree' + | 'worktreesByRepo' +> + +type NativeChatSkillTab = { id: string; startupCwd?: string } + +type NativeChatSkillWorktreeState = { + tabsByWorktree: Record + worktreesByRepo: Record +} + +export type NativeChatSkillDiscoveryContext = { + key: string + cwd: string + executionHostKind: 'local' | 'runtime' | 'ssh' + runtimeTarget: RuntimeClientTarget + discoveryTarget: SkillDiscoveryTarget +} + +export function selectNativeChatSkillStateInputs(state: AppState): NativeChatSkillStateInputs { + return { + activeRepoId: state.activeRepoId, + activeWorktreeId: state.activeWorktreeId, + folderWorkspaces: state.folderWorkspaces, + projectGroups: state.projectGroups, + projects: state.projects, + repos: state.repos, + restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey, + settings: state.settings, + tabsByWorktree: state.tabsByWorktree, + worktreesByRepo: state.worktreesByRepo + } +} + +export function resolveNativeChatSkillDiscoveryCwd( + state: NativeChatSkillWorktreeState, + terminalTabId: string +): string | null { + const found = findTerminalTab(state.tabsByWorktree, terminalTabId) + if (!found) { + return null + } + // Why: the agent runs where its pane started. A pane launched in a + // subdirectory must not scan (or share a cache key with) the worktree root. + const startupCwd = found.tab.startupCwd?.trim() + if (startupCwd) { + return startupCwd + } + for (const worktrees of Object.values(state.worktreesByRepo)) { + const worktree = worktrees.find((entry) => entry.id === found.worktreeId) + if (worktree) { + return worktree.path + } + } + return null +} + +export function resolveNativeChatSkillDiscoveryContext( + state: NativeChatSkillStateInputs, + terminalTabId: string +): NativeChatSkillDiscoveryContext | null { + const worktreeId = findTerminalTab(state.tabsByWorktree, terminalTabId)?.worktreeId ?? null + if (!worktreeId) { + return null + } + const workspaceScope = parseWorkspaceKey(worktreeId) + const cwd = + resolveNativeChatSkillDiscoveryCwd(state, terminalTabId) ?? + (workspaceScope?.type === 'folder' + ? state.folderWorkspaces.find( + (workspace) => workspace.id === workspaceScope.folderWorkspaceId + )?.folderPath + : null) + if (!cwd) { + return null + } + + const hostId = getExecutionHostIdForWorktree(state, worktreeId) + const parsedHost = parseExecutionHostId(hostId) + if (parsedHost?.kind === 'ssh') { + return { + key: JSON.stringify(['ssh', hostId, cwd]), + cwd, + executionHostKind: 'ssh', + runtimeTarget: { kind: 'local' }, + discoveryTarget: { cwd, worktreeId } + } + } + + const runtimeEnvironmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, worktreeId) + // Why: a selected global runtime is not proof that it owns this pane. Modern + // panes carry an owner stamp; ambiguous legacy panes stay not-ready. + if (parsedHost?.kind === 'runtime' && !runtimeEnvironmentId) { + return null + } + const runtimeTarget: RuntimeClientTarget = runtimeEnvironmentId + ? { kind: 'environment', environmentId: runtimeEnvironmentId } + : { kind: 'local' } + const projectRuntime = runtimeEnvironmentId + ? undefined + : getLocalProjectExecutionRuntimeContext(state, worktreeId) + const projectRuntimeKey = + projectRuntime?.status === 'resolved' + ? projectRuntime.runtime.cacheKey + : projectRuntime?.repair.cacheKey + return { + key: JSON.stringify([ + runtimeTarget.kind, + runtimeTarget.kind === 'environment' ? runtimeTarget.environmentId : null, + hostId, + projectRuntimeKey ?? null, + cwd + ]), + cwd, + executionHostKind: runtimeEnvironmentId ? 'runtime' : 'local', + runtimeTarget, + // Why: worktreeId lets the owning runtime resolve its own WSL project + // preference when this client cannot supply projectRuntime (environment- + // owned panes resolve host semantics on the runtime, never here). + discoveryTarget: { cwd, worktreeId, ...(projectRuntime ? { projectRuntime } : {}) } + } +} + +function findTerminalTab( + tabsByWorktree: Record, + terminalTabId: string +): { worktreeId: string; tab: NativeChatSkillTab } | null { + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + const tab = tabs.find((entry) => entry.id === terminalTabId) + if (tab) { + return { worktreeId, tab } + } + } + return null +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx new file mode 100644 index 00000000000..ed3814e8476 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { EMPTY_HISTORY, type ComposerAutocomplete } from './native-chat-composer-state' +import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' + +const COMMAND = { + kind: 'command' as const, + id: 'command:clear', + name: 'clear', + description: 'Clear history', + skillCollision: false +} + +function picker(items = [COMMAND]): Extract { + return { + mode: 'slash', + query: '', + items, + triggerKey: '/:0', + prefix: '/', + grouped: false, + commandsEnabled: true, + skillsEnabled: false, + skillStatus: 'ready' + } +} + +function setup(autocomplete: ComposerAutocomplete = picker(), composing = false) { + const callbacks = { + completePickerItem: vi.fn(), + dispatchPickerCommand: vi.fn(), + dismissPicker: vi.fn(), + interrupt: vi.fn(), + send: vi.fn(), + setActiveSuggestion: vi.fn(), + setDraft: vi.fn(), + setCaret: vi.fn(), + setHistory: vi.fn() + } + const hook = renderHook(() => + useNativeChatComposerKeyDown({ + autocomplete, + activeSuggestion: 0, + draft: '/', + history: EMPTY_HISTORY, + isComposing: () => composing, + ...callbacks + }) + ) + return { handler: hook.result.current, callbacks } +} + +function keyEvent(key: string, isComposing = false) { + return { + key, + shiftKey: false, + keyCode: isComposing ? 229 : 0, + nativeEvent: { isComposing }, + preventDefault: vi.fn() + } +} + +describe('useNativeChatComposerKeyDown', () => { + it('dispatches command Enter but completes command Tab', () => { + const enter = setup() + enter.handler(keyEvent('Enter') as never) + expect(enter.callbacks.dispatchPickerCommand).toHaveBeenCalledWith(COMMAND) + expect(enter.callbacks.completePickerItem).not.toHaveBeenCalled() + + const tab = setup() + tab.handler(keyEvent('Tab') as never) + expect(tab.callbacks.completePickerItem).toHaveBeenCalledWith(COMMAND) + expect(tab.callbacks.dispatchPickerCommand).not.toHaveBeenCalled() + }) + + it('falls through to composer send when the open picker has no options', () => { + const { handler, callbacks } = setup(picker([])) + handler(keyEvent('Enter') as never) + expect(callbacks.send).toHaveBeenCalledOnce() + }) + + it('dismisses Escape without interrupting the agent', () => { + const { handler, callbacks } = setup() + handler(keyEvent('Escape') as never) + expect(callbacks.dismissPicker).toHaveBeenCalledWith('/:0') + expect(callbacks.interrupt).not.toHaveBeenCalled() + }) + + it('does not accept or submit while IME composition is active', () => { + const { handler, callbacks } = setup(picker(), true) + const event = keyEvent('Enter', true) + handler(event as never) + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(callbacks.dispatchPickerCommand).not.toHaveBeenCalled() + expect(callbacks.send).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts index ac28aae3482..d287ab4fdce 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-keydown.ts @@ -1,22 +1,21 @@ import { useCallback, type Dispatch, type KeyboardEventHandler, type SetStateAction } from 'react' -import type { DiscoveredSkill } from '../../../../shared/skills' import { - applySkillSuggestion, recallNext, recallPrevious, type ComposerAutocomplete, type HistoryState, - type SlashCommandSuggestion + type NativeChatPickerItem } from './native-chat-composer-state' export type UseNativeChatComposerKeyDownArgs = { autocomplete: ComposerAutocomplete activeSuggestion: number draft: string - caret: number history: HistoryState - chooseSlash: (command: SlashCommandSuggestion) => void - dispatchSlash: (command: SlashCommandSuggestion) => void + isComposing: () => boolean + completePickerItem: (item: NativeChatPickerItem) => void + dispatchPickerCommand: (item: Extract) => void + dismissPicker: (triggerKey: string) => void interrupt: () => void send: () => void setActiveSuggestion: Dispatch> @@ -29,10 +28,11 @@ export function useNativeChatComposerKeyDown({ autocomplete, activeSuggestion, draft, - caret, history, - chooseSlash, - dispatchSlash, + isComposing, + completePickerItem, + dispatchPickerCommand, + dismissPicker, interrupt, send, setActiveSuggestion, @@ -42,60 +42,40 @@ export function useNativeChatComposerKeyDown({ }: UseNativeChatComposerKeyDownArgs): KeyboardEventHandler { return useCallback( (event) => { - if (autocomplete.mode === 'slash' && autocomplete.suggestions.length > 0) { - if (event.key === 'ArrowDown') { - event.preventDefault() - setActiveSuggestion((i) => (i + 1) % autocomplete.suggestions.length) - return - } - if (event.key === 'ArrowUp') { - event.preventDefault() - setActiveSuggestion( - (i) => (i - 1 + autocomplete.suggestions.length) % autocomplete.suggestions.length - ) - return - } + if (isComposing() || event.nativeEvent.isComposing || event.keyCode === 229) { + // Why: IME Enter confirms composition; allowing it to fall through + // would accept a picker row or submit a partial draft. if (event.key === 'Enter') { event.preventDefault() - dispatchSlash(autocomplete.suggestions[activeSuggestion] ?? autocomplete.suggestions[0]) - return - } - if (event.key === 'Tab') { - event.preventDefault() - chooseSlash(autocomplete.suggestions[activeSuggestion] ?? autocomplete.suggestions[0]) - return - } - if (event.key === 'Escape') { - event.preventDefault() - setDraft('') - setCaret(0) - return } + return } - if (autocomplete.mode === 'skill') { - if (event.key === 'ArrowDown' && autocomplete.suggestions.length > 0) { + if (autocomplete.mode === 'slash' || autocomplete.mode === 'skill') { + const items = autocomplete.items + if (event.key === 'ArrowDown' && items.length > 0) { event.preventDefault() - setActiveSuggestion((i) => (i + 1) % autocomplete.suggestions.length) + setActiveSuggestion((index) => (index + 1) % items.length) return } - if (event.key === 'ArrowUp' && autocomplete.suggestions.length > 0) { + if (event.key === 'ArrowUp' && items.length > 0) { event.preventDefault() - setActiveSuggestion( - (i) => (i - 1 + autocomplete.suggestions.length) % autocomplete.suggestions.length - ) + setActiveSuggestion((index) => (index - 1 + items.length) % items.length) return } - if ((event.key === 'Enter' || event.key === 'Tab') && autocomplete.suggestions.length > 0) { + if ((event.key === 'Enter' || event.key === 'Tab') && items.length > 0) { event.preventDefault() - const skill = autocomplete.suggestions[activeSuggestion] ?? autocomplete.suggestions[0] - applySkill({ skill, draft, caret, setDraft, setCaret, setActiveSuggestion }) + const item = items[activeSuggestion] ?? items[0] + if (event.key === 'Enter' && item.kind === 'command') { + dispatchPickerCommand(item) + } else { + completePickerItem(item) + } return } if (event.key === 'Escape') { event.preventDefault() - setDraft('') - setCaret(0) + dismissPicker(autocomplete.triggerKey) return } } @@ -105,13 +85,11 @@ export function useNativeChatComposerKeyDown({ interrupt() return } - if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault() send() return } - if (event.key === 'ArrowUp' && (draft === '' || history.index !== null)) { const recall = recallPrevious(history) if (recall.draft !== null) { @@ -133,15 +111,16 @@ export function useNativeChatComposerKeyDown({ } }, [ - autocomplete, activeSuggestion, - chooseSlash, - dispatchSlash, - interrupt, - send, + autocomplete, + completePickerItem, + dismissPicker, + dispatchPickerCommand, draft, - caret, history, + interrupt, + isComposing, + send, setActiveSuggestion, setCaret, setDraft, @@ -149,24 +128,3 @@ export function useNativeChatComposerKeyDown({ ] ) } - -function applySkill({ - skill, - draft, - caret, - setDraft, - setCaret, - setActiveSuggestion -}: { - skill: DiscoveredSkill - draft: string - caret: number - setDraft: Dispatch> - setCaret: Dispatch> - setActiveSuggestion: Dispatch> -}): void { - const result = applySkillSuggestion(draft, caret, skill.name) - setDraft(result.draft) - setCaret(result.caret) - setActiveSuggestion(0) -} diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index bcca032fac7..74a4d12b6c3 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -16,6 +16,8 @@ import { reset as resetAssembler } from './native-chat-incremental-assembler' import { mergeNativeChatLiveSession } from './native-chat-live-status' +import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles' +import { surfaceSkillInvocationUserTurns } from '../../../../shared/native-chat-command-envelope' import { hasMoreNativeChatHistory, NATIVE_CHAT_INITIAL_LIMIT, @@ -386,9 +388,22 @@ export function useNativeChatLiveSession( // eslint-disable-next-line react-hooks/exhaustive-deps }, [baseMessages, appended, sessionId, agent]) + // Why: Claude-family harnesses record slash inputs as command envelopes, + // which the noise filter hides. A skill invocation is the user's chat turn, + // so surface it here — upstream of pending-echo pruning, view state, and the + // list — as the literal token; catalog commands keep their `Ran /x` marker. + const surfacedMessages = useMemo( + () => + surfaceSkillInvocationUserTurns( + assembledMessages, + new Set(getVerifiedNativeChatCommands(agent).map((command) => command.name)) + ), + [assembledMessages, agent] + ) + return useMemo(() => { const session = mergeNativeChatLiveSession({ - sources: { transcript: assembledMessages }, + sources: { transcript: surfacedMessages }, sessionId, agent, hookState, @@ -404,7 +419,7 @@ export function useNativeChatLiveSession( }) return { ...session, hasMore, loadingEarlier, loadEarlier } }, [ - assembledMessages, + surfacedMessages, read, sessionId, agent, diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts new file mode 100644 index 00000000000..66f34362fce --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-command-dispatch.ts @@ -0,0 +1,97 @@ +import { useCallback, type Dispatch, type SetStateAction } from 'react' +import type { AgentType } from '../../../../shared/agent-status-types' +import { + emitNativeChatMessageSent, + emitNativeChatPickerItemAccepted, + emitNativeChatSendClassified +} from '@/lib/native-chat-telemetry' +import { sendNativeChatMessage } from './native-chat-runtime-send' +import { + nativeChatComposerTargetIsRemote, + type NativeChatResolvedTarget +} from './native-chat-composer-target' +import { + pushHistory, + type HistoryState, + type NativeChatPickerItem +} from './native-chat-composer-state' +import type { NativeChatSendLifecycle } from './use-native-chat-send-lifecycle' +import type { NativeChatPtySessionOptionsSurface } from './native-chat-pty-session-options' + +export function useNativeChatPickerCommandDispatch(args: { + agent: AgentType + disabled: boolean + isDispatchingSessionOption: boolean + resolveTarget: () => NativeChatResolvedTarget | null + onSlashCommand?: (command: string) => void + sessionOptionsSurface: NativeChatPtySessionOptionsSurface | null + trackPendingSend: NativeChatSendLifecycle['trackPendingSend'] + setHistory: Dispatch> + setDraft: (value: string) => void + setCaret: Dispatch> + setActiveSuggestion: Dispatch> + clearSkillOrigin: () => void + clearImageAttachments: () => void + setNotice: Dispatch> +}): (command: Extract) => void { + const { + agent, + disabled, + isDispatchingSessionOption, + resolveTarget, + onSlashCommand, + sessionOptionsSurface, + trackPendingSend, + setHistory, + setDraft, + setCaret, + setActiveSuggestion, + clearSkillOrigin, + clearImageAttachments, + setNotice + } = args + return useCallback( + (command) => { + const text = `/${command.name}` + const target = resolveTarget() + if (!target || disabled || isDispatchingSessionOption) { + return + } + trackPendingSend(sendNativeChatMessage(target.settings, target.ptyId, text)) + emitNativeChatPickerItemAccepted({ agent, itemKind: 'command' }) + // Why: picker dispatch is a catalog-verified command send; it must leave + // the same telemetry and composer state as the typed path — including + // disarming attachments, or a stale image rides the next prompt. + emitNativeChatSendClassified({ agent, outcome: 'command' }) + onSlashCommand?.(text) + sessionOptionsSurface?.recordOutgoingCommand(text) + emitNativeChatMessageSent({ + agent, + runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local' + }) + setHistory((previous) => pushHistory(previous, text)) + setDraft('') + setCaret(0) + setActiveSuggestion(0) + clearSkillOrigin() + clearImageAttachments() + setNotice(null) + }, + [ + agent, + clearImageAttachments, + clearSkillOrigin, + disabled, + isDispatchingSessionOption, + onSlashCommand, + resolveTarget, + sessionOptionsSurface, + setActiveSuggestion, + setCaret, + setDraft, + setHistory, + setNotice, + trackPendingSend + ] + ) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts new file mode 100644 index 00000000000..d66228356a2 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-picker-state.ts @@ -0,0 +1,198 @@ +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + type Dispatch, + type RefObject, + type SetStateAction +} from 'react' +import type { AgentType } from '../../../../shared/agent-status-types' +import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' +import type { SlashCommandSuggestion } from '../../../../shared/native-chat-slash-commands' +import { + applyPickerSuggestion, + classifyNativeChatSend, + deriveComposerAutocomplete, + editReplacesTriggerToken, + type ComposerAutocomplete, + type NativeChatPickerItem, + type NativeChatSendClassification +} from './native-chat-composer-state' +import { useNativeChatSkills } from './use-native-chat-skills' +import { + emitNativeChatPickerItemAccepted, + emitNativeChatPickerOpened, + emitNativeChatSendClassified +} from '@/lib/native-chat-telemetry' + +export type NativeChatPickerState = { + autocomplete: ComposerAutocomplete + listboxId: string + retrySkills: () => void + classifySend: (draft: string) => NativeChatSendClassification + clearSkillOrigin: () => void + completeItem: (item: NativeChatPickerItem) => void + dismiss: (triggerKey: string) => void + handleDraftOrCaretChange: (value: string, caret: number) => void +} + +export function useNativeChatPickerState(args: { + agent: AgentType + terminalTabId: string + draftScopeKey: string + draft: string + caret: number + agentCommands: readonly SlashCommandSuggestion[] + textareaRef: RefObject + setDraft: (value: string) => void + setCaret: Dispatch> + setActiveSuggestion: Dispatch> +}): NativeChatPickerState { + const { + agent, + terminalTabId, + draftScopeKey, + draft, + caret, + agentCommands, + textareaRef, + setDraft, + setCaret, + setActiveSuggestion + } = args + const profile = useMemo(() => getNativeChatAgentProfile(agent), [agent]) + const beforeCaret = draft.slice(0, caret) + const skillPickerTriggered = + profile?.skillPrefix === '$' + ? /(?:^|\s)\$\S*$/.test(beforeCaret) + : profile?.skillPrefix === '/' + ? beforeCaret.startsWith('/') && !/\s/.test(beforeCaret) + : false + const discovery = useNativeChatSkills(agent, terminalTabId, skillPickerTriggered) + const listboxId = `native-chat-picker-${useId().replaceAll(':', '')}` + const dismissalContext = `${draftScopeKey}:${agent}` + const [dismissed, setDismissed] = useState<{ context: string; triggerKey: string } | null>(null) + const skillOriginRef = useRef(null) + const lastOpenKeyRef = useRef(null) + const autocomplete = useMemo( + () => + deriveComposerAutocomplete( + draft, + caret, + agentCommands, + discovery.skills, + profile, + discovery, + dismissed?.context === dismissalContext ? dismissed.triggerKey : null + ), + [agentCommands, caret, dismissalContext, dismissed, discovery, draft, profile] + ) + + useEffect(() => { + // Why: suppression is per-trigger-occurrence AND per-context. The composer + // is reused across pane/agent switches, so a stale dismissal must clear or + // the picker stays closed for an in-progress token when that context returns. + skillOriginRef.current = null + setDismissed(null) + }, [dismissalContext]) + + useEffect(() => { + if (autocomplete.mode !== 'slash' && autocomplete.mode !== 'skill') { + lastOpenKeyRef.current = null + return + } + const openKey = `${dismissalContext}:${autocomplete.triggerKey}` + if (lastOpenKeyRef.current !== openKey) { + lastOpenKeyRef.current = openKey + emitNativeChatPickerOpened({ agent, prefix: autocomplete.prefix }) + } + }, [agent, autocomplete, dismissalContext]) + + const completeItem = useCallback( + (item: NativeChatPickerItem) => { + if (autocomplete.mode !== 'slash' && autocomplete.mode !== 'skill') { + return + } + const result = applyPickerSuggestion(draft, caret, item, autocomplete.prefix) + setDraft(result.draft) + setCaret(result.caret) + setActiveSuggestion(0) + setDismissed(null) + skillOriginRef.current = item.kind === 'skill' ? result.insertedToken : null + emitNativeChatPickerItemAccepted({ agent, itemKind: item.kind }) + const textarea = textareaRef.current + textarea?.focus() + requestAnimationFrame(() => textarea?.setSelectionRange(result.caret, result.caret)) + }, + [agent, autocomplete, caret, draft, setActiveSuggestion, setCaret, setDraft, textareaRef] + ) + + const handleDraftOrCaretChange = useCallback( + (value: string, nextCaret: number) => { + const firstToken = value.split(/\s/, 1)[0] ?? '' + if (skillOriginRef.current && firstToken !== skillOriginRef.current) { + skillOriginRef.current = null + } + if (!dismissed || dismissed.context !== dismissalContext) { + return + } + // Why: a single edit that replaces the dismissed token wholesale (e.g. + // select-all + paste) is a new trigger occurrence even though a trigger + // character lands back on the same draft position. + if (editReplacesTriggerToken(draft, value, dismissed.triggerKey)) { + setDismissed(null) + return + } + const next = deriveComposerAutocomplete( + value, + nextCaret, + agentCommands, + discovery.skills, + profile, + discovery + ) + if ( + (next.mode !== 'slash' && next.mode !== 'skill') || + next.triggerKey !== dismissed.triggerKey + ) { + setDismissed(null) + } + }, + [agentCommands, dismissalContext, dismissed, discovery, draft, profile] + ) + + const classifySend = useCallback( + (value: string) => { + const outcome = classifyNativeChatSend( + value, + agentCommands, + skillOriginRef.current, + profile?.skillPrefix ?? null + ) + emitNativeChatSendClassified({ agent, outcome }) + return outcome + }, + [agent, agentCommands, profile] + ) + const clearSkillOrigin = useCallback(() => { + skillOriginRef.current = null + }, []) + const dismiss = useCallback( + (triggerKey: string) => setDismissed({ context: dismissalContext, triggerKey }), + [dismissalContext] + ) + + return { + autocomplete, + listboxId, + retrySkills: discovery.retry, + classifySend, + clearSkillOrigin, + completeItem, + dismiss, + handleDraftOrCaretChange + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx new file mode 100644 index 00000000000..647a07b2f4f --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx @@ -0,0 +1,144 @@ +// @vitest-environment happy-dom + +import { cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatSkillDiscovery } from './use-native-chat-skills' + +const mocks = vi.hoisted(() => ({ + callRuntimeRpc: vi.fn(), + state: {} as Record, + snapshots: [] as NativeChatSkillDiscovery[] +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => selector(mocks.state) +})) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: (...args: unknown[]) => mocks.callRuntimeRpc(...args) +})) +vi.mock('@/lib/local-preflight-context', () => ({ + getLocalProjectExecutionRuntimeContext: () => undefined +})) +vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatSkillDiscovery: vi.fn() })) + +import { + resetNativeChatSkillDiscoveryCacheForTests, + useNativeChatSkills +} from './use-native-chat-skills' + +function stateForHost(hostId: string) { + return { + activeRepoId: 'repo-1', + activeWorktreeId: 'worktree-1', + folderWorkspaces: [], + projectGroups: [], + projects: [], + repos: [ + { + id: 'repo-1', + path: '/repo', + connectionId: null, + executionHostId: hostId + } + ], + restoredRuntimeHostIdByWorkspaceSessionKey: {}, + settings: { activeRuntimeEnvironmentId: null }, + tabsByWorktree: { 'worktree-1': [{ id: 'tab-1' }] }, + worktreesByRepo: { + 'repo-1': [{ id: 'worktree-1', repoId: 'repo-1', path: '/repo/worktree', hostId }] + } + } +} + +function Probe({ enabled }: { enabled: boolean }): null { + mocks.snapshots.push(useNativeChatSkills('codex', 'tab-1', enabled)) + return null +} + +describe('useNativeChatSkills', () => { + beforeEach(() => { + mocks.state = stateForHost('local') + mocks.snapshots = [] + mocks.callRuntimeRpc.mockReset() + mocks.callRuntimeRpc.mockResolvedValue({ + skills: [ + { + id: 'browser', + name: 'browser', + description: null, + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + rootPath: '/home/test/.agents/skills', + directoryPath: '/home/test/.agents/skills/browser', + skillFilePath: '/home/test/.agents/skills/browser/SKILL.md', + installed: true, + fileCount: 1, + updatedAt: null + } + ], + sources: [ + { + id: 'home-agents', + label: 'Agent skills home', + path: '/home/test/.agents/skills', + sourceKind: 'home', + providers: ['agent-skills'], + owner: null, + exists: true + } + ], + scannedAt: 1 + }) + resetNativeChatSkillDiscoveryCacheForTests() + }) + + afterEach(() => cleanup()) + + it('starts lazily and exposes loading separately from ready results', async () => { + const view = render() + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + expect(mocks.snapshots.at(-1)?.status).toBe('idle') + + view.rerender() + expect(mocks.snapshots.at(-1)?.status).toBe('loading') + await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('ready')) + expect(mocks.snapshots.at(-1)?.skills.map((skill) => skill.name)).toEqual(['browser']) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'local' }, + 'skills.discover', + { cwd: '/repo/worktree', worktreeId: 'worktree-1' }, + { timeoutMs: 10_000 } + ) + }) + + it('shares one in-flight request between sibling panes', async () => { + render( + <> + + + + ) + await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('ready')) + expect(mocks.callRuntimeRpc).toHaveBeenCalledTimes(1) + }) + + it('marks SSH discovery unavailable without scanning another host', async () => { + mocks.state = stateForHost('ssh:connection-1') + render() + await waitFor(() => expect(mocks.snapshots.at(-1)?.errorKind).toBe('unavailable')) + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + }) + + it('routes runtime-owned panes through their saved environment', async () => { + mocks.state = stateForHost('runtime:env-1') + render() + await waitFor(() => expect(mocks.snapshots.at(-1)?.status).toBe('ready')) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-1' }, + 'skills.discover', + { cwd: '/repo/worktree', worktreeId: 'worktree-1' }, + { timeoutMs: 10_000 } + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts index 9efe7a23d0e..7154abb1043 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { DiscoveredSkill } from '../../../../shared/skills' +import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../../shared/skills' import { isNativeChatSkillForAgent, resolveNativeChatSkillDiscoveryCwd @@ -23,6 +23,22 @@ function skill(overrides: Partial): DiscoveredSkill { } } +function discovery(owner: string | null, rootPath = '/Users/test/.agents/skills') { + return { + sources: [ + { + id: 'source', + label: 'Source', + path: rootPath, + sourceKind: 'home' as const, + providers: ['agent-skills' as const], + owner, + exists: true + } + ] + } satisfies Pick +} + describe('isNativeChatSkillForAgent', () => { it('shows Codex-native and generic agent skills for Codex chat', () => { expect(isNativeChatSkillForAgent('codex', skill({ providers: ['codex'] }))).toBe(true) @@ -36,6 +52,59 @@ describe('isNativeChatSkillForAgent', () => { it('does not enable skill autocomplete for other agents yet', () => { expect(isNativeChatSkillForAgent('claude', skill({ providers: ['agent-skills'] }))).toBe(false) }) + + it('uses explicit source ownership and keeps shared roots visible', () => { + const shared = discovery(null) + expect(isNativeChatSkillForAgent('codex', skill({}), shared)).toBe(true) + expect(isNativeChatSkillForAgent('claude', skill({}), shared)).toBe(true) + expect(isNativeChatSkillForAgent('grok', skill({}), shared)).toBe(true) + }) + + it('aliases OpenClaude to Claude roots without exposing them to other agents', () => { + const claude = discovery('claude') + expect(isNativeChatSkillForAgent('claude', skill({}), claude)).toBe(true) + expect(isNativeChatSkillForAgent('openclaude', skill({}), claude)).toBe(true) + expect(isNativeChatSkillForAgent('codex', skill({}), claude)).toBe(false) + expect(isNativeChatSkillForAgent('grok', skill({}), claude)).toBe(false) + }) + + it('grants visibility through any contributing root, not just the dedup survivor', () => { + const result = { + sources: [ + { + id: 'codex-home', + label: 'Codex home', + path: '/Users/test/.codex/skills', + sourceKind: 'home' as const, + providers: ['codex' as const], + owner: 'codex', + exists: true + }, + { + id: 'shared-home', + label: 'Agent skills home', + path: '/Users/test/.agents/skills', + sourceKind: 'home' as const, + providers: ['agent-skills' as const], + owner: null, + exists: true + } + ] + } satisfies Pick + // A symlinked skill deduped under the Codex root but also reachable + // through the shared root stays visible to every agent. + const merged = skill({ + rootPath: '/Users/test/.codex/skills', + rootPaths: ['/Users/test/.codex/skills', '/Users/test/.agents/skills'] + }) + expect(isNativeChatSkillForAgent('claude', merged, result)).toBe(true) + expect(isNativeChatSkillForAgent('codex', merged, result)).toBe(true) + const codexOnly = skill({ + rootPath: '/Users/test/.codex/skills', + rootPaths: ['/Users/test/.codex/skills'] + }) + expect(isNativeChatSkillForAgent('claude', codexOnly, result)).toBe(false) + }) }) describe('resolveNativeChatSkillDiscoveryCwd', () => { @@ -69,4 +138,23 @@ describe('resolveNativeChatSkillDiscoveryCwd', () => { resolveNativeChatSkillDiscoveryCwd({ tabsByWorktree: {}, worktreesByRepo: {} }, 'tab-1') ).toBeNull() }) + + it('prefers the pane startupCwd over the worktree root', () => { + expect( + resolveNativeChatSkillDiscoveryCwd( + { + tabsByWorktree: { + 'repo-1::/repo/worktree': [ + { id: 'tab-1', startupCwd: '/repo/worktree/packages/app' }, + { id: 'tab-2' } + ] + }, + worktreesByRepo: { + 'repo-1': [{ id: 'repo-1::/repo/worktree', path: '/repo/worktree' }] + } + }, + 'tab-1' + ) + ).toBe('/repo/worktree/packages/app') + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.ts index 04327478956..42c7702d017 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.ts @@ -1,71 +1,249 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '../../store' import type { AgentType } from '../../../../shared/agent-status-types' -import type { DiscoveredSkill } from '../../../../shared/skills' +import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../../shared/skills' +import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { emitNativeChatSkillDiscovery } from '@/lib/native-chat-telemetry' +import { + resolveNativeChatSkillDiscoveryContext, + selectNativeChatSkillStateInputs, + type NativeChatSkillDiscoveryContext +} from './native-chat-skill-discovery-context' -type NativeChatSkillWorktreeState = { - tabsByWorktree: Record - worktreesByRepo: Record +export { + resolveNativeChatSkillDiscoveryContext, + resolveNativeChatSkillDiscoveryCwd +} from './native-chat-skill-discovery-context' + +// The host scan budget honored by runtime targets that respect timeoutMs. +const DISCOVERY_TIMEOUT_MS = 10_000 +// Renderer wall-clock backstop for the local branch (which ignores timeoutMs). +// It must exceed the host's summed worst case — a WSL scan runs a metadata read +// (5s) then the tree walk (10s) in sequence — so the host's own precise timeout +// wins and a WSL cold boot does not surface a healthy scan as a spurious timeout. +const DISCOVERY_BACKSTOP_TIMEOUT_MS = 18_000 + +export type NativeChatSkillDiscovery = { + status: 'idle' | 'loading' | 'ready' | 'error' + skills: DiscoveredSkill[] + error: Error | null + errorKind?: 'unavailable' | 'timeout' | 'host' | 'unknown' + retry: () => void } -export function isNativeChatSkillForAgent(agent: AgentType, skill: DiscoveredSkill): boolean { - if (agent !== 'codex') { +type StoredDiscoveryState = Omit & { + contextKey: string | null +} + +const IDLE_STATE: StoredDiscoveryState = { + status: 'idle', + skills: [], + error: null, + contextKey: null +} +const inFlightDiscovery = new Map>() + +export function isNativeChatSkillForAgent( + agent: AgentType, + skill: DiscoveredSkill, + result?: Pick +): boolean { + const profile = getNativeChatAgentProfile(agent) + if (!profile) { return false } - // Why: Codex sessions can see both Codex-native skills and generic agent - // skills (for example ~/.agents/skills), so the picker should mirror that. - return skill.providers.includes('codex') || skill.providers.includes('agent-skills') + if (!result) { + return ( + agent === 'codex' && + (skill.providers.includes('codex') || skill.providers.includes('agent-skills')) + ) + } + // Why: canonical-path dedup keeps one row per file, but a symlinked skill can + // be reachable through several roots; any shared or agent-owned root grants + // visibility regardless of which root the scanner happened to list first. + const rootPaths = skill.rootPaths?.length ? skill.rootPaths : [skill.rootPath] + return rootPaths.some((rootPath) => { + const source = result.sources.find((entry) => entry.path === rootPath) + return source?.owner === null || source?.owner === profile.skillSourceOwner + }) } -export function resolveNativeChatSkillDiscoveryCwd( - state: NativeChatSkillWorktreeState, - terminalTabId: string -): string | null { - let ownerWorktreeId: string | null = null - for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) { - if (tabs.some((tab) => tab.id === terminalTabId)) { - ownerWorktreeId = worktreeId - break - } - } - if (!ownerWorktreeId) { - return null - } - for (const worktrees of Object.values(state.worktreesByRepo)) { - const worktree = worktrees.find((entry) => entry.id === ownerWorktreeId) - if (worktree) { - return worktree.path - } - } - return null -} - -export function useNativeChatSkills(agent: AgentType, terminalTabId: string): DiscoveredSkill[] { - const [skills, setSkills] = useState([]) - const cwd = useAppStore((state) => resolveNativeChatSkillDiscoveryCwd(state, terminalTabId)) +export function useNativeChatSkills( + agent: AgentType, + terminalTabId: string, + enabled = false +): NativeChatSkillDiscovery { + const inputs = useAppStore(useShallow(selectNativeChatSkillStateInputs)) + const context = useMemo( + () => resolveNativeChatSkillDiscoveryContext(inputs, terminalTabId), + [inputs, terminalTabId] + ) + const [state, setState] = useState(IDLE_STATE) + const [retryGeneration, setRetryGeneration] = useState(0) + const paneDiscoveryCache = useRef(new Map()) + const profile = getNativeChatAgentProfile(agent) useEffect(() => { let cancelled = false - if (agent !== 'codex') { - setSkills([]) + if (!profile || !enabled || !context) { + setState(IDLE_STATE) return } - void window.api.skills - .discover(cwd ? { cwd } : undefined) - .then((result) => { - if (!cancelled) { - setSkills(result.skills.filter((skill) => isNativeChatSkillForAgent(agent, skill))) - } + if (context.executionHostKind === 'ssh') { + emitNativeChatSkillDiscovery({ + agent, + outcome: 'unavailable', + executionHostKind: 'ssh' }) - .catch(() => { - if (!cancelled) { - setSkills([]) - } + setState({ + status: 'error', + skills: [], + error: new Error('Skill discovery is unavailable for SSH hosts.'), + errorKind: 'unavailable', + contextKey: context.key }) + return + } + + const paneCacheKey = context.key + const cached = paneDiscoveryCache.current.get(paneCacheKey) + if (cached) { + emitNativeChatSkillDiscovery({ + agent, + outcome: 'ready', + executionHostKind: context.executionHostKind + }) + setState({ status: 'ready', skills: cached.skills, error: null, contextKey: context.key }) + return + } + setState({ status: 'loading', skills: [], error: null, contextKey: context.key }) + const request = getOrStartDiscovery(context) + void request.then( + (result) => { + paneDiscoveryCache.current.set(paneCacheKey, result) + if (cancelled) { + return + } + emitNativeChatSkillDiscovery({ + agent, + outcome: 'ready', + executionHostKind: context.executionHostKind + }) + setState({ status: 'ready', skills: result.skills, error: null, contextKey: paneCacheKey }) + }, + (reason) => { + if (cancelled) { + return + } + const error = reason instanceof Error ? reason : new Error(String(reason)) + const timedOut = /timed?\s*out|timeout/i.test(error.message) + emitNativeChatSkillDiscovery({ + agent, + outcome: timedOut ? 'timeout' : 'error', + executionHostKind: context.executionHostKind + }) + setState({ + status: 'error', + skills: [], + error, + errorKind: timedOut + ? 'timeout' + : context.executionHostKind === 'runtime' + ? 'host' + : 'unknown', + contextKey: paneCacheKey + }) + } + ) return () => { cancelled = true } - }, [agent, cwd]) + }, [agent, context, enabled, profile, retryGeneration]) - return skills + const effectiveState = useMemo( + () => + !profile || !enabled || !context + ? IDLE_STATE + : state.contextKey === context.key + ? state + : { status: 'loading' as const, skills: [], error: null, contextKey: context.key }, + [context, enabled, profile, state] + ) + const visibleSkills = useMemo(() => { + if (!profile || effectiveState.status !== 'ready') { + return [] + } + const result = context ? paneDiscoveryCache.current.get(context.key) : undefined + return result + ? effectiveState.skills.filter((skill) => isNativeChatSkillForAgent(agent, skill, result)) + : [] + }, [agent, context, effectiveState, profile]) + + const retry = useCallback(() => { + if (context) { + paneDiscoveryCache.current.delete(context.key) + setState({ status: 'loading', skills: [], error: null, contextKey: context.key }) + } + setRetryGeneration((generation) => generation + 1) + }, [context]) + return useMemo( + () => ({ + status: effectiveState.status, + skills: visibleSkills, + error: effectiveState.error, + ...(effectiveState.errorKind ? { errorKind: effectiveState.errorKind } : {}), + retry + }), + [effectiveState, retry, visibleSkills] + ) +} + +function getOrStartDiscovery( + context: NativeChatSkillDiscoveryContext +): Promise { + const existing = inFlightDiscovery.get(context.key) + if (existing) { + return existing + } + // Why: the local runtime.call branch ignores timeoutMs, so the renderer must + // enforce the design's scan timeout itself or a stalled local scan loads forever. + const request = withDiscoveryTimeout( + callRuntimeRpc( + context.runtimeTarget, + 'skills.discover', + context.discoveryTarget, + { + timeoutMs: DISCOVERY_TIMEOUT_MS + } + ), + DISCOVERY_BACKSTOP_TIMEOUT_MS + ).finally(() => { + if (inFlightDiscovery.get(context.key) === request) { + inFlightDiscovery.delete(context.key) + } + }) + inFlightDiscovery.set(context.key, request) + return request +} + +function withDiscoveryTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Skill discovery timed out.')), timeoutMs) + promise.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (reason) => { + clearTimeout(timer) + reject(reason) + } + ) + }) +} + +export function resetNativeChatSkillDiscoveryCacheForTests(): void { + inFlightDiscovery.clear() } diff --git a/src/renderer/src/components/native-chat/use-native-chat-typed-insertion.ts b/src/renderer/src/components/native-chat/use-native-chat-typed-insertion.ts new file mode 100644 index 00000000000..c82e808e9fa --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-typed-insertion.ts @@ -0,0 +1,50 @@ +import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react' +import type { HistoryState } from './native-chat-composer-state' + +/** Imperative text insertion and focus for the composer textarea, used by the + * paste pipeline and the composer's imperative handle. */ +export function useNativeChatTypedInsertion(args: { + textareaRef: RefObject + caret: number + draft: string + setDraft: (value: string) => void + setCaret: Dispatch> + setHistory: Dispatch> + setActiveSuggestion: Dispatch> +}): { insertTypedText: (text: string) => boolean; focus: () => boolean } { + const { textareaRef, caret, draft, setDraft, setCaret, setHistory, setActiveSuggestion } = args + + const insertTypedText = useCallback( + (text: string): boolean => { + const textarea = textareaRef.current + if (!textarea || textarea.disabled) { + return false + } + const selectionStart = textarea.selectionStart ?? caret + const selectionEnd = textarea.selectionEnd ?? selectionStart + const next = `${draft.slice(0, selectionStart)}${text}${draft.slice(selectionEnd)}` + const nextCaret = selectionStart + text.length + textarea.focus() + setDraft(next) + setCaret(nextCaret) + setHistory((prev) => ({ entries: prev.entries, index: null })) + setActiveSuggestion(0) + requestAnimationFrame(() => { + textarea.setSelectionRange(nextCaret, nextCaret) + }) + return true + }, + [caret, draft, setActiveSuggestion, setCaret, setDraft, setHistory, textareaRef] + ) + + const focus = useCallback((): boolean => { + const textarea = textareaRef.current + if (!textarea || textarea.disabled) { + return false + } + textarea.focus() + return true + }, [textareaRef]) + + return { insertTypedText, focus } +} diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts index 12fe7d1bf00..c0354249fc5 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts @@ -119,7 +119,7 @@ export function useCloseWith({ const total = Math.max(0, Date.now() - startTimeRef.current) // Why: no `is_git_repo` — project selection now happens in the Add // Project modal after this fires, so the signal moved to - // `repo_added.is_git_repo`. See docs/reference/telemetry-availability.md. + // `repo_added.is_git_repo`. track('onboarding_completed', { path: completedPath, total_duration_ms: total diff --git a/src/renderer/src/components/terminal-pane/agent-task-complete-policy.ts b/src/renderer/src/components/terminal-pane/agent-task-complete-policy.ts index 7b08ca0c5c1..6275cc9553c 100644 --- a/src/renderer/src/components/terminal-pane/agent-task-complete-policy.ts +++ b/src/renderer/src/components/terminal-pane/agent-task-complete-policy.ts @@ -3,8 +3,7 @@ * * Why extracted from pty-connection.ts: the parked byte watcher and the * pty:sideEffect facts handler apply the exact live-path semantics without a - * pane, and policy must not drift between the three consumers - * (docs/reference/terminal-side-effect-authority.md). This module is + * pane, and policy must not drift between the three consumers. This module is * deliberately dependency-light — no pane/xterm imports — so pane-less * consumers can use it. */ diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts index c4907718589..359f8815b68 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts @@ -11,8 +11,7 @@ * gate ON answers from main's '2031-subscribe' fact (no parked bytes exist), * gate OFF keeps the byte sidecar (parked-terminal-mode2031-responder.ts). * Either way the reply is sent from the renderer — query authority never - * moves to main. See docs/reference/terminal-hidden-view-parking.md and - * docs/reference/terminal-side-effect-authority.md. + * moves to main. */ import { isClaudeAgent } from '../../../../shared/agent-detection' import { makePaneKey } from '../../../../shared/stable-pane-id' diff --git a/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts b/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts index a695b77d82f..56519c09e90 100644 --- a/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts +++ b/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts @@ -1,6 +1,6 @@ /** * Renderer-side delivery-interest registry for the Phase-4 hidden-delivery - * gate (docs/reference/terminal-side-effect-authority.md, Open Items). + * gate. * * Why: main only drops hidden PTY byte delivery while NO renderer party needs * raw bytes. Dispatcher sidecars and eager pre-mount buffers register diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index 5926cc8c153..d58460161ab 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -8,7 +8,6 @@ * reveal, tab close, PTY exit, or worktree teardown. The bookkeeping maps * live in terminal-parked-watcher-registry so the terminals store slice can * dispose watchers without importing this store-coupled module. - * See docs/reference/terminal-hidden-view-parking.md. */ import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import type { TerminalTab } from '../../../../shared/types' diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts index 06d80b03754..d9f3c8d7d31 100644 --- a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -1,8 +1,8 @@ /** * Renderer consumer registry for the `pty:sideEffect` channel. * - * Why: with main as the side-effect parser for local-daemon/SSH PTYs - * (docs/reference/terminal-side-effect-authority.md), the renderer no longer + * Why: with main as the side-effect parser for local-daemon/SSH PTYs, the + * renderer no longer * derives title/bell/agent facts from bytes for those PTYs. This module is * the single channel subscriber; mounted panes and parked-tab watchers * register exactly one fact consumer per PTY (their existing policy diff --git a/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts index 304ff399f6c..aeecb1a6f48 100644 --- a/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts +++ b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts @@ -1,6 +1,6 @@ /** - * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute - * bridge): renderer→main `pty:terminalViewAttributes` publication. Composes + * Phase 5 slice 2 (View-attribute bridge): renderer→main + * `pty:terminalViewAttributes` publication. Composes * the reply-relevant slots of the active terminal theme exactly the way * xterm's browser ThemeService resolves an ITheme (defaults, cursor blend, * 256-entry palette), so main's hidden-PTY responder replies byte-identically diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts index 56172164490..fa7de03116e 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -4,7 +4,7 @@ * Why: owns the cold-park policy bookkeeping (hiddenSince tracking, recheck * timers, parked-set selection) and the parked byte-watcher reconciliation so * the overlay layer only consumes the final parked tab set when deciding to - * render a slot as null. See docs/reference/terminal-hidden-view-parking.md. + * render a slot as null. */ import { useEffect, useMemo, useRef, useState } from 'react' import type { TerminalTab } from '../../../../shared/types' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 427fa58d0e5..c6bafdd07a6 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -13436,6 +13436,21 @@ "startDictation": "Start dictation", "stopDictation": "Stop dictation", "noSkills": "No matching skills", + "noCommandsOrSkills": "No matching commands or skills", + "noCommands": "No matching commands", + "commands": "Commands", + "skills": "Skills", + "loadingSkills": "Loading skills...", + "skillsLoaded": "Skills loaded", + "skillsUnavailableHost": "Skills are unavailable for this host", + "skillsLoadFailed": "Could not load skills from this host", + "retrySkills": "Retry", + "skillCommandCollision": "Also a skill name - agent decides", + "skillMultipleSources": "{{sourceCount}} sources - agent resolves", + "skillScopeProject": "Project", + "skillScopePersonal": "Personal", + "skillScopeBuiltIn": "Built-in", + "skillScopePlugin": "Plugin", "localAttachmentUnsupported": "Local attachments are not available for remote sessions.", "removeAttachment": "Remove attachment", "pastedImageLabel": "Pasted image", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c7e2e7ccc1c..62827c20dec 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13443,7 +13443,22 @@ "max": "Máximo", "on": "Activado", "off": "Desactivado" - } + }, + "noCommandsOrSkills": "No hay comandos o habilidades coincidentes", + "commands": "Comandos", + "skills": "Habilidades", + "loadingSkills": "Cargando habilidades…", + "skillsUnavailableHost": "Las habilidades no están disponibles para este host", + "skillsLoadFailed": "No se pudieron cargar las habilidades de este host", + "retrySkills": "Reintentar", + "skillCommandCollision": "También es un nombre de habilidad - el agente decide", + "skillMultipleSources": "{{sourceCount}} fuentes - el agente resuelve", + "skillScopeProject": "Proyecto", + "skillScopePersonal": "Personal", + "skillScopeBuiltIn": "Integrado", + "skillScopePlugin": "Plugin", + "skillsLoaded": "Habilidades cargadas", + "noCommands": "No hay comandos coincidentes" }, "tool": { "running": "Ejecutando…", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6c4390633c4..d1c9f5e043c 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13443,7 +13443,22 @@ "max": "最大", "on": "オン", "off": "オフ" - } + }, + "noCommandsOrSkills": "一致するコマンドやスキルがありません", + "commands": "コマンド", + "skills": "スキル", + "loadingSkills": "スキルを読み込み中…", + "skillsUnavailableHost": "このホストではスキルが利用できません", + "skillsLoadFailed": "このホストからスキルを読み込めませんでした", + "retrySkills": "再試行", + "skillCommandCollision": "スキル名でもあります - エージェントが判断します", + "skillMultipleSources": "{{sourceCount}} 個のソース - エージェントが解決します", + "skillScopeProject": "プロジェクト", + "skillScopePersonal": "個人", + "skillScopeBuiltIn": "組み込み", + "skillScopePlugin": "プラグイン", + "skillsLoaded": "スキルを読み込みました", + "noCommands": "一致するコマンドがありません" }, "tool": { "running": "実行中…", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5da5129544d..875dafd5756 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13443,7 +13443,22 @@ "max": "최대", "on": "켜짐", "off": "꺼짐" - } + }, + "noCommandsOrSkills": "일치하는 명령어 또는 스킬이 없습니다", + "commands": "명령어", + "skills": "스킬", + "loadingSkills": "스킬 로딩 중…", + "skillsUnavailableHost": "이 호스트에서는 스킬을 사용할 수 없습니다", + "skillsLoadFailed": "이 호스트에서 스킬을 불러올 수 없습니다", + "retrySkills": "재시도", + "skillCommandCollision": "스킬 이름이기도 합니다 - 에이전트가 결정합니다", + "skillMultipleSources": "{{sourceCount}}개 소스 - 에이전트가 해결합니다", + "skillScopeProject": "프로젝트", + "skillScopePersonal": "개인", + "skillScopeBuiltIn": "내장", + "skillScopePlugin": "플러그인", + "skillsLoaded": "스킬 로드 완료", + "noCommands": "일치하는 명령어가 없습니다" }, "tool": { "running": "실행 중…", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index be2ed642cc5..3716898ceb2 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13443,7 +13443,22 @@ "max": "最大", "on": "开", "off": "关" - } + }, + "noCommandsOrSkills": "没有匹配的命令或技能", + "commands": "命令", + "skills": "技能", + "loadingSkills": "正在加载技能…", + "skillsUnavailableHost": "此主机不可用技能", + "skillsLoadFailed": "无法从此主机加载技能", + "retrySkills": "重试", + "skillCommandCollision": "也是技能名称 - 由代理决定", + "skillMultipleSources": "{{sourceCount}} 个来源 - 由代理解析", + "skillScopeProject": "项目", + "skillScopePersonal": "个人", + "skillScopeBuiltIn": "内置", + "skillScopePlugin": "插件", + "skillsLoaded": "技能已加载", + "noCommands": "没有匹配的命令" }, "tool": { "running": "正在运行…", diff --git a/src/renderer/src/lib/native-chat-telemetry.ts b/src/renderer/src/lib/native-chat-telemetry.ts index efc6e90d33e..0d6f96584d2 100644 --- a/src/renderer/src/lib/native-chat-telemetry.ts +++ b/src/renderer/src/lib/native-chat-telemetry.ts @@ -58,3 +58,45 @@ export function emitNativeChatMessageSent(args: { runtime: args.runtime }) } + +export function emitNativeChatPickerOpened(args: { + agent: NativeChatTelemetryAgent + prefix: '/' | '$' +}): void { + track('native_chat_picker_opened', { + agent_kind: resolveAgentKind(args.agent), + prefix: args.prefix === '/' ? 'slash' : 'dollar' + }) +} + +export function emitNativeChatPickerItemAccepted(args: { + agent: NativeChatTelemetryAgent + itemKind: 'command' | 'skill' +}): void { + track('native_chat_picker_item_accepted', { + agent_kind: resolveAgentKind(args.agent), + item_kind: args.itemKind + }) +} + +export function emitNativeChatSendClassified(args: { + agent: NativeChatTelemetryAgent + outcome: 'chat' | 'command' | 'unknown-token' +}): void { + track('native_chat_send_classified', { + agent_kind: resolveAgentKind(args.agent), + outcome: args.outcome + }) +} + +export function emitNativeChatSkillDiscovery(args: { + agent: NativeChatTelemetryAgent + outcome: 'ready' | 'error' | 'timeout' | 'unavailable' + executionHostKind: 'local' | 'runtime' | 'ssh' +}): void { + track('native_chat_skill_discovery', { + agent_kind: resolveAgentKind(args.agent), + outcome: args.outcome, + execution_host_kind: args.executionHostKind + }) +} diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 79e3a846b49..bb95dcad904 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -1822,6 +1822,35 @@ describe('web UI preload API', () => { ) }) + it('rejects paired web skill discovery failures instead of returning an empty scan', async () => { + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + call(method: string): Promise> { + if (method === 'skills.discover') { + return Promise.reject(new Error('runtime disconnected')) + } + return Promise.resolve({ + id: method, + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + }) + } + + close(): void {} + } + })) + + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage) + const { installWebPreloadApi } = await import('./web-preload-api') + installWebPreloadApi() + + await expect(globals.window.api.skills.discover({ cwd: '/repo' })).rejects.toThrow( + 'runtime disconnected' + ) + }) + it('rejects paired web computer-use status failures instead of marking the helper unavailable', async () => { vi.doMock('./web-runtime-client', () => ({ WebRuntimeClient: class { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index d5f8f2c59f2..41fb9833684 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2766,11 +2766,7 @@ function createComputerUsePermissionsApi(): NonNullable< function createSkillsApi(): NonNullable['skills']> { return { discover: (target) => - callRuntimeResult('skills.discover', target, 15_000).catch(() => ({ - skills: [], - sources: [], - scannedAt: Date.now() - })), + callRuntimeResult('skills.discover', target, 15_000), // Why: browser clients have no local skill homes, and remote-host // freshness stays disabled until its update rail has equivalent coverage. freshnessInventory: (): Promise => diff --git a/src/shared/command-code-output-status.ts b/src/shared/command-code-output-status.ts index 8bba8a7472b..459831aee79 100644 --- a/src/shared/command-code-output-status.ts +++ b/src/shared/command-code-output-status.ts @@ -3,8 +3,7 @@ * agent-status rows are seeded from its rendered status words and idle * composer. Shared because main runs this per-PTY under side-effect authority * (emitting command-code facts) while the renderer keeps the byte path for - * remote-runtime PTYs and the kill switch - * (docs/reference/terminal-side-effect-authority.md). + * remote-runtime PTYs and the kill switch. */ import { cleanCommandCodePromptCandidate, diff --git a/src/shared/feature-interaction-catalog.ts b/src/shared/feature-interaction-catalog.ts index c77fcf06c16..5d313734e69 100644 --- a/src/shared/feature-interaction-catalog.ts +++ b/src/shared/feature-interaction-catalog.ts @@ -59,8 +59,8 @@ export type FeatureInteractionDefinition = { interaction: string } -// Why: these ids become persisted product state; see -// docs/reference/feature-discovery-interaction-tracking.md before changing them. +// Why: these ids become persisted product state; changing them breaks +// feature-discovery interaction tracking. export const FEATURE_INTERACTIONS = [ { id: 'workspace-board', interaction: 'workspace board opened' }, { diff --git a/src/shared/native-chat-agent-profiles.test.ts b/src/shared/native-chat-agent-profiles.test.ts new file mode 100644 index 00000000000..27b7e07d33b --- /dev/null +++ b/src/shared/native-chat-agent-profiles.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { getNativeChatAgentProfile } from './native-chat-agent-profiles' + +describe('native chat agent picker profiles', () => { + it('keeps Codex dollar skills separate from slash commands', () => { + expect(getNativeChatAgentProfile('codex')).toMatchObject({ + skillPrefix: '$', + groupedSlash: false, + skillSourceOwner: 'codex' + }) + }) + + it('groups Claude-family and Grok skills under slash', () => { + expect(getNativeChatAgentProfile('claude')).toMatchObject({ + skillPrefix: '/', + groupedSlash: true, + skillSourceOwner: 'claude' + }) + expect(getNativeChatAgentProfile('openclaude')).toMatchObject({ skillSourceOwner: 'claude' }) + expect(getNativeChatAgentProfile('grok')).toMatchObject({ + skillPrefix: '/', + groupedSlash: true, + skillSourceOwner: 'grok' + }) + }) + + it('does not grant custom or unverified agents a skill grammar', () => { + expect(getNativeChatAgentProfile('custom-agent')).toBeNull() + }) +}) diff --git a/src/shared/native-chat-agent-profiles.ts b/src/shared/native-chat-agent-profiles.ts new file mode 100644 index 00000000000..ae52b48efef --- /dev/null +++ b/src/shared/native-chat-agent-profiles.ts @@ -0,0 +1,45 @@ +import type { AgentType } from './agent-status-types' +import { getAgentSlashCommands, type SlashCommandSuggestion } from './native-chat-slash-commands' + +export type NativeChatAgentProfile = { + skillPrefix: '$' | '/' + groupedSlash: boolean + /** OpenClaude reads Claude-owned roots, so this can differ from the agent. */ + skillSourceOwner: AgentType +} + +const NATIVE_CHAT_AGENT_PROFILES: Partial> = { + codex: { + skillPrefix: '$', + groupedSlash: false, + skillSourceOwner: 'codex' + }, + claude: { + skillPrefix: '/', + groupedSlash: true, + skillSourceOwner: 'claude' + }, + openclaude: { + skillPrefix: '/', + groupedSlash: true, + skillSourceOwner: 'claude' + }, + grok: { + skillPrefix: '/', + groupedSlash: true, + skillSourceOwner: 'grok' + } +} + +export function getNativeChatAgentProfile( + agent: AgentType | null | undefined +): NativeChatAgentProfile | null { + return agent ? (NATIVE_CHAT_AGENT_PROFILES[agent] ?? null) : null +} + +/** The catalog that send classification, collision detection, and transcript + * envelope surfacing key off. Grok has no verified catalog yet, so its slash + * surface stays skills-only — this is the single place that policy lives. */ +export function getVerifiedNativeChatCommands(agent: AgentType): readonly SlashCommandSuggestion[] { + return agent === 'grok' ? [] : getAgentSlashCommands(agent) +} diff --git a/src/shared/native-chat-command-envelope.test.ts b/src/shared/native-chat-command-envelope.test.ts new file mode 100644 index 00000000000..6f878e90903 --- /dev/null +++ b/src/shared/native-chat-command-envelope.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from './native-chat-types' +import { + parseNativeChatCommandEnvelope, + surfaceSkillInvocationUserTurns +} from './native-chat-command-envelope' + +const CATALOG = new Set(['clear', 'model']) + +function userTurn(text: string, overrides: Partial = {}): NativeChatMessage { + return { + id: 'user-1', + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: 100, + source: 'transcript', + ...overrides + } +} + +describe('parseNativeChatCommandEnvelope', () => { + it('parses name-first and message-first envelope orderings', () => { + expect( + parseNativeChatCommandEnvelope( + '/model\n model\n ' + ) + ).toEqual({ name: '/model', args: '' }) + expect( + parseNativeChatCommandEnvelope( + 'ce-brainstorm\n/ce-brainstorm\nimprove the picker' + ) + ).toEqual({ name: '/ce-brainstorm', args: 'improve the picker' }) + }) + + it('ignores ordinary prompts and non-leading envelope tags', () => { + expect(parseNativeChatCommandEnvelope('deploy the app')).toBeNull() + expect(parseNativeChatCommandEnvelope('see /x')).toBeNull() + expect(parseNativeChatCommandEnvelope('/x')).toBeNull() + }) +}) + +describe('surfaceSkillInvocationUserTurns', () => { + it('renders a skill-invocation envelope as the literal user token', () => { + const messages = [ + userTurn( + 'ce-brainstorm\n/ce-brainstorm\nimprove the picker' + ) + ] + const out = surfaceSkillInvocationUserTurns(messages, CATALOG) + expect(out[0].blocks).toEqual([{ type: 'text', text: '/ce-brainstorm improve the picker' }]) + expect(out[0].id).toBe('user-1') + }) + + it('shortens plugin-qualified names back to the token the user sent', () => { + const messages = [ + userTurn( + 'compound-engineering:ce-brainstorm\n/compound-engineering:ce-brainstorm\nhi' + ) + ] + const out = surfaceSkillInvocationUserTurns(messages, CATALOG) + expect(out[0].blocks).toEqual([{ type: 'text', text: '/ce-brainstorm hi' }]) + }) + + it('never hides a plugin skill whose short name shadows a catalog command', () => { + const messages = [ + userTurn('/some-plugin:clear\n') + ] + const out = surfaceSkillInvocationUserTurns(messages, CATALOG) + expect(out[0].blocks).toEqual([{ type: 'text', text: '/clear' }]) + }) + + it('leaves catalog command envelopes for the noise filter and Ran marker', () => { + const messages = [ + userTurn('/model\n') + ] + expect(surfaceSkillInvocationUserTurns(messages, CATALOG)).toBe(messages) + }) + + it('does not touch assistant turns, plain prompts, or non-text blocks', () => { + const messages: NativeChatMessage[] = [ + userTurn('hello there'), + userTurn('/skill', { + id: 'user-2', + role: 'assistant' + }), + userTurn('/skill', { + id: 'user-3', + blocks: [ + { type: 'text', text: '/skill' }, + { type: 'image-ref', path: '/tmp/a.png' } + ] + }) + ] + expect(surfaceSkillInvocationUserTurns(messages, CATALOG)).toBe(messages) + }) +}) diff --git a/src/shared/native-chat-command-envelope.ts b/src/shared/native-chat-command-envelope.ts new file mode 100644 index 00000000000..1ac398d58f1 --- /dev/null +++ b/src/shared/native-chat-command-envelope.ts @@ -0,0 +1,63 @@ +// Claude-family harnesses record a slash input's user turn as a command +// envelope (`/x…`), not as the typed text. The +// noise filter rightly hides those for catalog commands — Orca shows a local +// `Ran /x` line instead — but a skill invocation IS the user's chat turn, so +// dropping its envelope makes the assistant appear to answer an empty +// conversation. Surface non-catalog envelopes back as plain user text. + +import { isTextBlock, type NativeChatMessage } from './native-chat-types' + +const COMMAND_NAME = /([\s\S]*?)<\/command-name>/ +const COMMAND_ARGS = /([\s\S]*?)<\/command-args>/ + +export type NativeChatCommandEnvelope = { name: string; args: string } + +/** Parse a user turn recorded as a command envelope. Returns null for any text + * that does not lead with an envelope tag (ordinary prompts, XML pastes). */ +export function parseNativeChatCommandEnvelope(text: string): NativeChatCommandEnvelope | null { + const trimmed = text.trimStart() + if (!trimmed.toLowerCase().startsWith(' +): NativeChatMessage[] { + let changed = false + const out = messages.map((message) => { + if (message.role !== 'user' || !message.blocks.every(isTextBlock)) { + return message + } + const envelope = parseNativeChatCommandEnvelope( + message.blocks.map((block) => block.text).join('\n') + ) + if (!envelope || catalogCommandNames.has(envelope.name.replace(/^\//, ''))) { + return message + } + // Why: the harness canonicalizes a plugin skill to `/plugin:name`, but the + // user (and the picker) sent the short frontmatter name. Render the short + // token so the bubble shows what was typed and the optimistic echo prunes + // instead of duplicating the turn. + const shortName = envelope.name.replace(/^\//, '').split(':').at(-1) ?? '' + const token = `/${shortName}` + changed = true + return { + ...message, + blocks: [{ type: 'text' as const, text: envelope.args ? `${token} ${envelope.args}` : token }] + } + }) + return changed ? out : (messages as NativeChatMessage[]) +} diff --git a/src/shared/skill-display-text.ts b/src/shared/skill-display-text.ts new file mode 100644 index 00000000000..d2e61b4ac8b --- /dev/null +++ b/src/shared/skill-display-text.ts @@ -0,0 +1,26 @@ +// Untrusted skill and plugin names/descriptions are shown in the picker and the +// Settings skill list. Control, C1, zero-width, and bidi-override codepoints can +// spoof a row or hide characters, so both the main-process label builder and the +// renderer picker strip them through this one predicate rather than maintaining +// divergent copies. +export function isSafeDisplayCharacter(character: string): boolean { + const code = character.codePointAt(0) ?? 0 + return !( + code <= 0x1f || + (code >= 0x7f && code <= 0x9f) || + code === 0x200b || + code === 0x200e || + code === 0x200f || + code === 0x061c || + code === 0x2060 || + code === 0xfeff || + code === 0x2028 || + code === 0x2029 || + (code >= 0x202a && code <= 0x202e) || + (code >= 0x2066 && code <= 0x2069) + ) +} + +export function stripUnsafeDisplayCharacters(value: string): string { + return [...value].filter(isSafeDisplayCharacter).join('') +} diff --git a/src/shared/skills.ts b/src/shared/skills.ts index 0b386624cbf..c908954d3a9 100644 --- a/src/shared/skills.ts +++ b/src/shared/skills.ts @@ -1,3 +1,5 @@ +import { z } from 'zod' +import type { AgentType } from './agent-status-types' import type { ProjectExecutionRuntimeResolution } from './project-execution-runtime' export type SkillProvider = 'codex' | 'claude' | 'agent-skills' @@ -12,6 +14,9 @@ export type DiscoveredSkill = { sourceKind: SkillSourceKind sourceLabel: string rootPath: string + /** Every root that reached this file. Canonical-path dedup keeps one row but + * must not erase co-owning roots, or shared symlinked skills lose agents. */ + rootPaths?: string[] directoryPath: string skillFilePath: string installed: boolean @@ -25,6 +30,8 @@ export type SkillDiscoverySource = { path: string sourceKind: SkillSourceKind providers: SkillProvider[] + /** Agent that owns this root; null is the explicit shared-skills scope. */ + owner: AgentType | null exists: boolean skippedReason?: 'missing' | 'remote-repo' } @@ -40,9 +47,62 @@ export type SkillDiscoveryTarget = { wslDistro?: string | null /** Workspace path whose local .agents/.claude skill roots should be scanned. */ cwd?: string | null + /** Lets the owning runtime resolve the project runtime from its own store + * when the caller (e.g. a remote client) cannot supply `projectRuntime`. */ + worktreeId?: string | null projectRuntime?: ProjectExecutionRuntimeResolution } +const ResolvedProjectRuntimeSchema = z.object({ + status: z.literal('resolved'), + runtime: z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('local-host'), + hostPlatform: z.string(), + projectId: z.string(), + reason: z.literal('non-windows'), + cacheKey: z.string() + }), + z.object({ + kind: z.literal('windows-host'), + hostPlatform: z.literal('win32'), + projectId: z.string(), + reason: z.enum(['project-override', 'global-default', 'migration-fallback']), + cacheKey: z.string() + }), + z.object({ + kind: z.literal('wsl'), + hostPlatform: z.literal('wsl'), + projectId: z.string(), + distro: z.string(), + reason: z.enum(['project-override', 'global-default']), + cacheKey: z.string() + }) + ]) +}) + +const RepairProjectRuntimeSchema = z.object({ + status: z.literal('repair-required'), + repair: z.object({ + projectId: z.string(), + preferredRuntime: z.object({ kind: z.literal('wsl'), distro: z.string().nullable() }), + reason: z.enum(['wsl-unavailable', 'wsl-distro-required', 'wsl-distro-missing']), + source: z.enum(['project-override', 'global-default']), + cacheKey: z.string() + }) +}) + +/** Both desktop IPC and runtime RPC parse the complete discovery target here. */ +export const SkillDiscoveryTargetSchema: z.ZodType = z.object({ + runtime: z.enum(['host', 'wsl']).optional(), + wslDistro: z.string().nullable().optional(), + cwd: z.string().nullable().optional(), + worktreeId: z.string().nullable().optional(), + projectRuntime: z + .discriminatedUnion('status', [ResolvedProjectRuntimeSchema, RepairProjectRuntimeSchema]) + .optional() +}) + export type SkillFrontmatterSummary = { name: string | null description: string | null diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index ee34b53ec3c..f7f43c076d9 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -470,6 +470,22 @@ const nativeChatMessageSentSchema = z runtime: nativeChatRuntimeSchema }) .strict() +const nativeChatPickerOpenedSchema = z + .object({ agent_kind: agentKindSchema, prefix: z.enum(['slash', 'dollar']) }) + .strict() +const nativeChatPickerItemAcceptedSchema = z + .object({ agent_kind: agentKindSchema, item_kind: z.enum(['command', 'skill']) }) + .strict() +const nativeChatSendClassifiedSchema = z + .object({ agent_kind: agentKindSchema, outcome: z.enum(['chat', 'command', 'unknown-token']) }) + .strict() +const nativeChatSkillDiscoverySchema = z + .object({ + agent_kind: agentKindSchema, + outcome: z.enum(['ready', 'error', 'timeout', 'unavailable']), + execution_host_kind: z.enum(['local', 'runtime', 'ssh']) + }) + .strict() const telemetryOptedInSchema = z.object({ via: optInViaSchema }).strict() const telemetryOptedOutSchema = z.object({ via: optInViaSchema }).strict() @@ -1432,6 +1448,10 @@ export const eventSchemas = { native_chat_toggled: nativeChatToggledSchema, native_chat_message_sent: nativeChatMessageSentSchema, + native_chat_picker_opened: nativeChatPickerOpenedSchema, + native_chat_picker_item_accepted: nativeChatPickerItemAcceptedSchema, + native_chat_send_classified: nativeChatSendClassifiedSchema, + native_chat_skill_discovery: nativeChatSkillDiscoverySchema, telemetry_opted_in: telemetryOptedInSchema, telemetry_opted_out: telemetryOptedOutSchema, diff --git a/src/shared/terminal-bell-detector.ts b/src/shared/terminal-bell-detector.ts index cce08a7dbd0..7b909e50b32 100644 --- a/src/shared/terminal-bell-detector.ts +++ b/src/shared/terminal-bell-detector.ts @@ -3,8 +3,8 @@ * occurring inside OSC escape sequences. * * Shared between the renderer transport processor and main's per-PTY - * side-effect tracker (docs/reference/terminal-side-effect-authority.md): - * bell semantics must not drift between the two parsing authorities. + * side-effect tracker: bell semantics must not drift between the two parsing + * authorities. * * Why stateful: PTY data arrives in arbitrary chunks, so an OSC sequence * may span multiple calls. The detector tracks in-progress escape state diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts index 02bf28efa80..4f00c9f1753 100644 --- a/src/shared/terminal-output-side-effects.ts +++ b/src/shared/terminal-output-side-effects.ts @@ -3,8 +3,8 @@ * both the renderer transport (`createPtyOutputProcessor`) and main's * per-PTY tracker in `OrcaRuntimeService.onPtyData`. * - * Why shared: docs/reference/terminal-side-effect-authority.md makes main the - * side-effect parser for every PTY whose bytes transit local main. Title + * Why shared: main is the side-effect parser for every PTY whose bytes transit + * local main. Title * semantics (all-titles ordering, cursor-agent literal drop, normalization, * stale-working-title clearing) must not drift between the two paths. */ diff --git a/src/shared/terminal-side-effect-facts.ts b/src/shared/terminal-side-effect-facts.ts index 1a546324006..8dee0e6242c 100644 --- a/src/shared/terminal-side-effect-facts.ts +++ b/src/shared/terminal-side-effect-facts.ts @@ -3,7 +3,6 @@ * (main → renderer). Events are facts, not decisions: main parses every * local-daemon/SSH PTY byte exactly once and emits what it observed; the * renderer store handler owns notification/unread policy. - * See docs/reference/terminal-side-effect-authority.md. */ import type { TerminalGitHubPRLink } from './terminal-github-pr-link-detector' diff --git a/src/shared/terminal-view-attributes.ts b/src/shared/terminal-view-attributes.ts index fa587a17b5c..638ee1df176 100644 --- a/src/shared/terminal-view-attributes.ts +++ b/src/shared/terminal-view-attributes.ts @@ -1,6 +1,6 @@ /** - * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute - * bridge): payload contract for the renderer→main `pty:terminalViewAttributes` + * Phase 5 slice 2 (View-attribute bridge): payload contract for the + * renderer→main `pty:terminalViewAttributes` * push, plus main/renderer mirrors of xterm's XParseColor color-spec grammar * so main's responder replies byte-identically to a visible renderer xterm. */ diff --git a/src/shared/types.ts b/src/shared/types.ts index ec80f68b690..b28bf97d451 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2822,14 +2822,12 @@ export type GlobalSettings = { terminalScopeHistoryByWorktree: boolean /** Kill switch for hidden terminal view parking — unmounting long-hidden * terminal panes while a pane-less watcher keeps PTY side effects alive. - * Defaults to true; `false` disables parking entirely. - * See docs/reference/terminal-hidden-view-parking.md. */ + * Defaults to true; `false` disables parking entirely. */ terminalHiddenViewParking?: boolean /** Kill switch for main-process terminal side-effect authority: when true * (default), local-daemon/SSH PTY title/bell/agent facts are consumed from * the `pty:sideEffect` channel and renderer byte parsers stay unregistered - * for those PTYs; `false` restores renderer byte parsing. - * See docs/reference/terminal-side-effect-authority.md. */ + * for those PTYs; `false` restores renderer byte parsing. */ terminalMainSideEffectAuthority?: boolean /** Kill switch for main's hidden-delivery gate (Phase 4): when true * (default) AND terminalMainSideEffectAuthority is on, main drops PTY byte @@ -2839,8 +2837,7 @@ export type GlobalSettings = { /** Kill switch for the main model query responder (Phase 5): when true * (default) AND both Phase-4 gate switches are on, main answers terminal * queries (DA1/CPR/DECRPM, …) embedded in hidden-dropped chunks from the - * runtime emulator. `false` silences the responder without changing drops. - * See docs/reference/terminal-query-authority.md. */ + * runtime emulator. `false` silences the responder without changing drops. */ terminalModelQueryAuthority?: boolean /** Which agent to pre-select in the new-workspace composer. * - null: auto (first detected agent) From e3721e8cf2d296b301fa81f9119a7d633cfd035d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20S=C3=A1ez?= Date: Sun, 19 Jul 2026 20:42:41 -0400 Subject: [PATCH 09/45] fix(pi): detect ask_user_question and surface as blocked state (#9457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(pi): detect ask_user_question and surface as blocked state Maps Pi tool_call/tool_execution_start events with ask_user_question to blocked state (was working), so worktrees surface in attention sort and trigger notifications — matching Claude/Codex/Grok behavior. Guards interactivePrompt derivation to Pi-only, OMP unchanged. 5 new tests covering blocked transition, regression, malformed input, and OMP guard. * fix(pi): gate ask_user_question blocked on raw tool_name and cover state exit Address code-review findings on the Pi ask_user_question detection: - Gate the Pi blocked classification on the event's own tool_name (matching the Claude/Grok normalizers) instead of the merged snapshot, so a partial follow-up event can't inherit a stale ask_user_question name from the tool cache and spuriously re-enter blocked. resolveToolState moves back after the state-name guard, so it no longer runs on discarded events. - Make extractPiToolFields' agentKind parameter required; the sole call site always supplies it, and optional risked a future Pi caller silently falling back to OMP-safe (no interactivePrompt) behavior with no type error. - Add coverage for the transition OUT of blocked (tool_execution_end -> working, agent_end -> done) and that a following regular Pi tool clears interactivePrompt. * test(pty-connection): gate confirming null sample in idle-exit veto test CI failed on a flaky call-count assertion: one timer advance can start multiple getForegroundProcess reads, so the confirming null sample could land before the replacement hook owner was installed. Hold 2nd+ null samples until the veto owner is in place instead of requiring exactly one extra call. --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- .../terminal-pane/pty-connection.test.ts | 37 ++- src/shared/agent-hook-listener.test.ts | 275 ++++++++++++++++++ src/shared/agent-hook-listener.ts | 39 ++- 3 files changed, 329 insertions(+), 22 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 7b119905d47..1c9323dd0a9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -17733,7 +17733,26 @@ describe('connectPanePty', () => { const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.5) try { const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) - getForegroundProcess.mockResolvedValue('codex') + // Why: call-count alone is flaky — one advanceTimersToNextTimerAsync can start + // multiple getForegroundProcess reads (completion cadence + pane tracker / + // confirmForegroundProcess alias). Gate the 2nd+ null sample until the + // replacement hook owner is installed so the confirming sample always sees it. + let idleMode = false + let nullSamplesStarted = 0 + let releaseConfirmingNullSample: (() => void) | undefined + const confirmingNullSampleGate = new Promise((resolve) => { + releaseConfirmingNullSample = resolve + }) + getForegroundProcess.mockImplementation(async () => { + if (!idleMode) { + return 'codex' + } + nullSamplesStarted += 1 + if (nullSamplesStarted >= 2) { + await confirmingNullSampleGate + } + return null + }) const paneKey = makePaneKey('tab-1', LEAF_1) const pane = createPane(1) const manager = createManager(1) @@ -17753,21 +17772,16 @@ describe('connectPanePty', () => { titleHandler('Codex working', 'Codex working') await vi.advanceTimersByTimeAsync(2_500) - const inspectionsBeforeIdle = getForegroundProcess.mock.calls.length - getForegroundProcess.mockResolvedValue(null) - // Why: active process polling is jittered, so a fixed window can consume - // both null samples before the replacement hook row is installed. - for ( - let attempts = 0; - getForegroundProcess.mock.calls.length === inspectionsBeforeIdle; - attempts += 1 - ) { + idleMode = true + for (let attempts = 0; nullSamplesStarted < 1; attempts += 1) { if (attempts >= 10) { throw new Error('Expected the first idle process inspection') } await vi.advanceTimersToNextTimerAsync() } - expect(getForegroundProcess).toHaveBeenCalledTimes(inspectionsBeforeIdle + 1) + // Why: let the first null sample apply pendingProcessExitAgent before the + // replacement owner is installed; later samples stay gated until then. + await flushAsyncTicks() mockStoreState.agentStatusByPaneKey[paneKey] = { state: 'working', @@ -17778,6 +17792,7 @@ describe('connectPanePty', () => { paneKey, stateHistory: [] } + releaseConfirmingNullSample?.() idleHandler('Claude done') await vi.advanceTimersByTimeAsync(800) await vi.advanceTimersByTimeAsync(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 08c8f00ca15..cc99a807837 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -372,6 +372,247 @@ describe('shared agent-hook-listener', () => { expect(event?.payload.interactivePrompt).toBe(JSON.stringify(properties)) }) + it('maps Pi tool_call ask_user_question to blocked with interactivePrompt', () => { + const questions = { + questions: [ + { + question: 'What is your priority?', + options: ['A', 'B', 'C'] + } + ] + } + const blocked = normalizeHookPayload( + state, + 'pi', + { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt', + env: 'production', + version: '1', + payload: { + hook_event_name: 'tool_call', + tool_name: 'ask_user_question', + tool_input: questions + } + }, + 'production' + ) + expect(blocked?.payload).toMatchObject({ + state: 'blocked', + agentType: 'pi', + toolName: 'ask_user_question' + }) + expect(blocked?.payload.interactivePrompt).toBe(JSON.stringify(questions)) + }) + + it('maps Pi tool_execution_start ask_user_question to blocked with interactivePrompt', () => { + const questions = { + questions: [ + { + question: 'Pick a path', + options: ['path-1', 'path-2'] + } + ] + } + const blocked = normalizeHookPayload( + state, + 'pi', + { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt', + env: 'production', + version: '1', + payload: { + hook_event_name: 'tool_execution_start', + tool_name: 'ask_user_question', + tool_input: questions + } + }, + 'production' + ) + expect(blocked?.payload).toMatchObject({ + state: 'blocked', + agentType: 'pi', + toolName: 'ask_user_question' + }) + expect(blocked?.payload.interactivePrompt).toBe(JSON.stringify(questions)) + }) + + it('keeps Pi regular tool_call notifications as working', () => { + const working = normalizeHookPayload( + state, + 'pi', + { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt', + env: 'production', + version: '1', + payload: { + hook_event_name: 'tool_call', + tool_name: 'bash', + tool_input: { command: 'git status' } + } + }, + 'production' + ) + expect(working?.payload).toMatchObject({ + state: 'working', + agentType: 'pi', + toolName: 'bash', + toolInput: 'git status' + }) + expect(working?.payload.interactivePrompt).toBeUndefined() + }) + + it('keeps Pi ask_user_question blocked when tool_input is missing', () => { + const blocked = normalizeHookPayload( + state, + 'pi', + { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt', + env: 'production', + version: '1', + payload: { + hook_event_name: 'tool_call', + tool_name: 'ask_user_question' + } + }, + 'production' + ) + expect(blocked?.payload).toMatchObject({ + state: 'blocked', + agentType: 'pi', + toolName: 'ask_user_question' + }) + expect(blocked?.payload.interactivePrompt).toBeUndefined() + }) + + it('clears Pi ask_user_question blocked once the tool_execution_end arrives', () => { + const questions = { + questions: [{ question: 'Ship it?', options: ['yes', 'no'] }] + } + const base = { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt', + env: 'production' as const, + version: '1' + } + const blocked = normalizeHookPayload( + state, + 'pi', + { + ...base, + payload: { + hook_event_name: 'tool_call', + tool_name: 'ask_user_question', + tool_input: questions + } + }, + 'production' + ) + expect(blocked?.payload.state).toBe('blocked') + expect(blocked?.payload.interactivePrompt).toBe(JSON.stringify(questions)) + + // Why: the answered question must leave the blocked/needs-attention state so + // the notification and attention sort clear; tool_execution_end is working. + const cleared = normalizeHookPayload( + state, + 'pi', + { + ...base, + payload: { + hook_event_name: 'tool_execution_end', + tool_name: 'ask_user_question' + } + }, + 'production' + ) + expect(cleared?.payload.state).toBe('working') + expect(cleared?.payload.interactivePrompt).toBeUndefined() + }) + + it('marks Pi done when agent_end follows an ask_user_question block', () => { + const base = { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt', + env: 'production' as const, + version: '1' + } + normalizeHookPayload( + state, + 'pi', + { + ...base, + payload: { + hook_event_name: 'tool_call', + tool_name: 'ask_user_question', + tool_input: { questions: [{ question: 'Pick', options: ['a', 'b'] }] } + } + }, + 'production' + ) + const done = normalizeHookPayload( + state, + 'pi', + { ...base, payload: { hook_event_name: 'agent_end' } }, + 'production' + ) + expect(done?.payload.state).toBe('done') + expect(done?.payload.interactivePrompt).toBeUndefined() + }) + + it('clears the ask_user_question interactivePrompt when a regular Pi tool runs next', () => { + const base = { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt', + env: 'production' as const, + version: '1' + } + normalizeHookPayload( + state, + 'pi', + { + ...base, + payload: { + hook_event_name: 'tool_call', + tool_name: 'ask_user_question', + tool_input: { questions: [{ question: 'Pick', options: ['a', 'b'] }] } + } + }, + 'production' + ) + // Why: a follow-up regular tool must not inherit the prior question's blocked + // state or its live interactivePrompt card. + const working = normalizeHookPayload( + state, + 'pi', + { + ...base, + payload: { + hook_event_name: 'tool_call', + tool_name: 'bash', + tool_input: { command: 'ls' } + } + }, + 'production' + ) + expect(working?.payload).toMatchObject({ + state: 'working', + agentType: 'pi', + toolName: 'bash', + toolInput: 'ls' + }) + expect(working?.payload.interactivePrompt).toBeUndefined() + }) + it('normalizes OMP Pi-compatible hooks with OMP attribution', () => { const event = normalizeHookPayload( state, @@ -419,6 +660,40 @@ describe('shared agent-hook-listener', () => { toolName: 'bash', toolInput: 'pnpm test' }) + expect(tool?.payload.interactivePrompt).toBeUndefined() + }) + + it('keeps OMP ask_user_question behavior on Pi-compatible events', () => { + const tool = normalizeHookPayload( + state, + 'omp', + { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt', + env: 'production', + version: '1', + payload: { + hook_event_name: 'tool_call', + tool_name: 'ask_user_question', + tool_input: { + questions: [ + { + question: 'Choose', + options: ['x', 'y'] + } + ] + } + } + }, + 'production' + ) + expect(tool?.payload).toMatchObject({ + state: 'working', + agentType: 'omp', + toolName: 'ask_user_question' + }) + expect(tool?.payload.interactivePrompt).toBeUndefined() }) it('captures Pi session ids on Pi-compatible status events', () => { diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index c4df60fb28f..f983d409e79 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -1898,7 +1898,8 @@ function extractCopilotToolFields( function extractPiToolFields( eventName: unknown, - hookPayload: Record + hookPayload: Record, + agentKind: 'pi' | 'omp' ): ToolSnapshot { if ( eventName === 'tool_call' || @@ -1906,9 +1907,16 @@ function extractPiToolFields( eventName === 'tool_execution_end' ) { const toolName = readString(hookPayload, 'tool_name') - const toolInput = deriveToolInputPreview(toolName, hookPayload.tool_input) + const rawToolInput = hookPayload.tool_input + const toolInput = deriveToolInputPreview(toolName, rawToolInput) + // Why: OMP shares this extractor. interactivePrompt derivation should + // only apply to Pi so OMP ask_user_question metadata stays unchanged. + const interactivePrompt = + agentKind === 'pi' && (eventName === 'tool_call' || eventName === 'tool_execution_start') + ? deriveInteractivePrompt(toolName, rawToolInput, eventName) + : undefined return toolUpdate( - { toolName, toolInput }, + { toolName, toolInput, interactivePrompt }, { hasToolInputField: hasOwnField(hookPayload, 'tool_input') } ) } @@ -2386,7 +2394,7 @@ function extractToolFields( return extractCursorToolFields(eventName, hookPayload) case 'pi': case 'omp': - return extractPiToolFields(eventName, hookPayload) + return extractPiToolFields(eventName, hookPayload, source) case 'droid': return extractDroidToolFields(eventName, hookPayload) case 'command-code': @@ -3422,13 +3430,22 @@ function normalizePiCompatibleEvent( return null } - const stateName = - eventName === 'before_agent_start' || - eventName === 'agent_start' || - eventName === 'tool_call' || - eventName === 'tool_execution_start' || - eventName === 'tool_execution_end' || - eventName === 'message_end' + // Why: gate on the event's own tool_name (matching the Claude/Grok normalizers), + // not a merged snapshot, so a partial follow-up event can't inherit a stale + // ask_user_question name from the tool cache and spuriously re-enter blocked. + const isPiAskUserQuestion = + agentType === 'pi' && + isAskUserQuestionTool(readString(hookPayload, 'tool_name')) && + (eventName === 'tool_call' || eventName === 'tool_execution_start') + + const stateName = isPiAskUserQuestion + ? 'blocked' + : eventName === 'before_agent_start' || + eventName === 'agent_start' || + eventName === 'tool_call' || + eventName === 'tool_execution_start' || + eventName === 'tool_execution_end' || + eventName === 'message_end' ? 'working' : eventName === 'agent_end' ? 'done' From 808299cd1f786ade0e54180f13282abc81d3ab25 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 19 Jul 2026 18:59:22 -0700 Subject: [PATCH 10/45] fix(cli): avoid Windows PATH status timeout (#9483) --- config/electron-builder.config.cjs | 11 +- config/packaged-runtime-node-modules.cjs | 14 +- config/scripts/ensure-native-runtime.mjs | 28 ++- config/scripts/ensure-native-runtime.test.mjs | 14 ++ ...package-electron-runtime-contract.test.mjs | 42 +++++ config/scripts/rebuild-native-deps.mjs | 81 +++++++- config/scripts/rebuild-native-deps.test.mjs | 105 ++++++++++- package.json | 3 +- pnpm-lock.yaml | 18 ++ src/main/cli/cli-installer.test.ts | 178 +++++++++++++++--- src/main/cli/cli-installer.ts | 152 +++++++++++---- .../cli/windows-user-path-registry.test.ts | 127 +++++++++++++ src/main/cli/windows-user-path-registry.ts | 160 ++++++++++++++++ src/main/cli/wsl-cli-installer.test.ts | 21 +++ .../mobile-emulator-agent-setup-cli-state.ts | 2 +- .../feature-tip-cli-install-action.ts | 2 +- .../feature-tips/feature-tip-startup-gate.ts | 2 +- .../onboarding-feature-setup.test.ts | 20 ++ .../onboarding/onboarding-feature-setup.ts | 10 +- .../components/settings/BrowserUsePane.tsx | 3 +- .../components/settings/CliSection.test.tsx | 50 ++++- .../src/components/settings/CliSection.tsx | 15 +- .../settings/CliSkillRuntimeSetup.tsx | 12 +- src/renderer/src/i18n/locales/en.json | 6 +- src/renderer/src/i18n/locales/es.json | 6 +- src/renderer/src/i18n/locales/ja.json | 6 +- src/renderer/src/i18n/locales/ko.json | 6 +- src/renderer/src/i18n/locales/zh.json | 6 +- .../lib/agent-skill-cli-prerequisite.test.ts | 22 +++ .../src/lib/agent-skill-cli-prerequisite.ts | 22 ++- src/shared/cli-install-types.ts | 3 +- 31 files changed, 1033 insertions(+), 114 deletions(-) create mode 100644 src/main/cli/windows-user-path-registry.test.ts create mode 100644 src/main/cli/windows-user-path-registry.ts diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 98beb0c1c1f..85e3d244d68 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -35,13 +35,7 @@ const relayExtraResource = { // from package directories where pnpm's symlink farm is absent. Copy the exact // runtime dependency closure to Resources/node_modules so bare require() calls // do not fall through to a developer checkout's node_modules. -const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources() - -const commonExtraResources = [ - relayExtraResource, - ...packagedRuntimeNodeModuleResources, - skillFreshnessResources -] +const commonExtraResources = [relayExtraResource, skillFreshnessResources] const macSpeechNativeResource = { from: 'node_modules/sherpa-onnx-darwin-${arch}', to: 'node_modules/sherpa-onnx-darwin-${arch}' @@ -198,6 +192,7 @@ module.exports = { }, extraResources: [ ...commonExtraResources, + ...createPackagedRuntimeNodeModuleResources('win32'), winSpeechNativeResource, { from: 'resources/win32/bin/orca.cmd', @@ -261,6 +256,7 @@ module.exports = { notarize: isMacRelease, extraResources: [ ...commonExtraResources, + ...createPackagedRuntimeNodeModuleResources('darwin'), macSpeechNativeResource, { from: 'resources/darwin/bin/orca', @@ -324,6 +320,7 @@ module.exports = { }, extraResources: [ ...commonExtraResources, + ...createPackagedRuntimeNodeModuleResources('linux'), linuxSpeechNativeResource, { from: 'resources/linux/bin/orca-ide', diff --git a/config/packaged-runtime-node-modules.cjs b/config/packaged-runtime-node-modules.cjs index d3f81817cee..8854f866f42 100644 --- a/config/packaged-runtime-node-modules.cjs +++ b/config/packaged-runtime-node-modules.cjs @@ -31,6 +31,7 @@ const PACKAGED_RUNTIME_PACKAGE_ROOTS = [ 'yaml', 'zod' ] +const WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS = ['windows-native-registry'] const NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM = { darwin: 'darwin-', @@ -128,7 +129,7 @@ function isKnownOmittedServeSimDependency(packageName, fromDir) { } } -function collectPackagedRuntimePackages() { +function collectPackagedRuntimePackages(electronPlatformName = process.platform) { const packages = new Map() const visit = (packageName, fromDir = projectDir) => { if (packageName === 'electron' || packages.has(packageName)) { @@ -156,7 +157,12 @@ function collectPackagedRuntimePackages() { } } - for (const packageName of PACKAGED_RUNTIME_PACKAGE_ROOTS) { + // Why: cross-builds must select native dependencies from the artifact target, not the build host. + const packageRoots = [ + ...PACKAGED_RUNTIME_PACKAGE_ROOTS, + ...(electronPlatformName === 'win32' ? WINDOWS_PACKAGED_RUNTIME_PACKAGE_ROOTS : []) + ] + for (const packageName of packageRoots) { visit(packageName) } @@ -183,8 +189,8 @@ function collectPackagedRuntimePackages() { return [...packages.entries()].sort(([left], [right]) => left.localeCompare(right)) } -function createPackagedRuntimeNodeModuleResources() { - return collectPackagedRuntimePackages().map(([packageName, packageDir]) => ({ +function createPackagedRuntimeNodeModuleResources(electronPlatformName = process.platform) { + return collectPackagedRuntimePackages(electronPlatformName).map(([packageName, packageDir]) => ({ from: packageDir, to: join('node_modules', ...packageName.split('/')) })) diff --git a/config/scripts/ensure-native-runtime.mjs b/config/scripts/ensure-native-runtime.mjs index eac27a90965..98b064cd30d 100644 --- a/config/scripts/ensure-native-runtime.mjs +++ b/config/scripts/ensure-native-runtime.mjs @@ -11,7 +11,11 @@ const scriptPath = import.meta.filename const projectDir = resolve(import.meta.dirname, '../..') const runtime = readRuntimeArg() -const NATIVE_MODULES = ['node-pty'] +const NATIVE_MODULES = [ + 'node-pty', + ...(process.platform === 'win32' ? ['windows-native-registry'] : []) +] +const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe'] const CHILD_CHECK_FLAG = '--check-only' if (process.argv.includes(CHILD_CHECK_FLAG)) { @@ -240,6 +244,12 @@ function collectNativeModuleFailures() { } function loadNativeModule(moduleName) { + if (moduleName === 'windows-native-registry') { + const registry = require(moduleName) + // Why: the package defers loading its .node addon until the first registry call. + registry.getRegistryKey(registry.HK.CU, 'Environment') + return + } if (moduleName === 'node-pty') { loadNodePtyNativeModule() return @@ -256,13 +266,27 @@ function loadNodePtyNativeModule() { // Why: node-pty's Windows JS wrapper defers conpty.node/pty.node until a // terminal is created, so require('node-pty') alone can miss ABI mismatches. const native = loadNativeModule(nativeName) - if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native.dir)) { + assertNodePtyWindowsConptyRuntime(native?.dir) + if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native?.dir)) { throw new Error( `node-pty resolved to ${native.dir}; expected build/Release so Orca's node-pty patch is active` ) } } +function assertNodePtyWindowsConptyRuntime(nativeDir) { + if (process.platform !== 'win32' || !isNodePtyReleaseBuildDir(nativeDir)) { + return + } + const runtimeDir = resolve(projectDir, 'node_modules', 'node-pty', 'build', 'Release', 'conpty') + const missingFile = NODE_PTY_CONPTY_RUNTIME_FILES.find( + (filename) => !existsSync(resolve(runtimeDir, filename)) + ) + if (missingFile) { + throw new Error(`node-pty ConPTY runtime file is missing: ${resolve(runtimeDir, missingFile)}`) + } +} + function getNodePtyNativeModuleName() { if (process.platform !== 'win32') { return 'pty' diff --git a/config/scripts/ensure-native-runtime.test.mjs b/config/scripts/ensure-native-runtime.test.mjs index 1041ca924f2..0bc8e5ae077 100644 --- a/config/scripts/ensure-native-runtime.test.mjs +++ b/config/scripts/ensure-native-runtime.test.mjs @@ -193,6 +193,7 @@ exports.loadNativeModule = function loadNativeModule(nativeName) { } ` ) + writeFakeWindowsRegistry(projectDir) } function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) { @@ -214,6 +215,19 @@ exports.loadNativeModule = function loadNativeModule(nativeName) { } ` ) + writeFakeWindowsRegistry(projectDir) +} + +function writeFakeWindowsRegistry(projectDir) { + if (process.platform !== 'win32') { + return + } + const registryDir = join(projectDir, 'node_modules', 'windows-native-registry') + mkdirSync(registryDir, { recursive: true }) + writeFileSync( + join(registryDir, 'index.js'), + 'exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => ({})\n' + ) } function writeNodePtyPatchFile(projectDir) { diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index c18272d56a4..204ca03b2f9 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -1,9 +1,12 @@ import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' import { join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' const projectDir = resolve(import.meta.dirname, '../..') +const require = createRequire(import.meta.url) +const { createPackagedRuntimeNodeModuleResources } = require('../packaged-runtime-node-modules.cjs') const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')) describe('Electron runtime package contract', () => { @@ -25,6 +28,45 @@ describe('Electron runtime package contract', () => { expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('electron') }) + it('keeps the native Windows registry addon optional and platform-gated', () => { + const rebuildScript = readFileSync( + join(projectDir, 'config/scripts/rebuild-native-deps.mjs'), + 'utf8' + ) + const ensureScript = readFileSync( + join(projectDir, 'config/scripts/ensure-native-runtime.mjs'), + 'utf8' + ) + expect(packageJson.optionalDependencies['windows-native-registry']).toBe('3.2.2') + // Why: pnpm installs optional target architectures on every host; the root + // Windows-only rebuild owns this addon so macOS/Linux never run node-gyp for it. + expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('windows-native-registry') + expect(rebuildScript).toContain( + "rebuildPlatform === 'win32' ? ['windows-native-registry'] : []" + ) + expect(ensureScript).toContain( + "process.platform === 'win32' ? ['windows-native-registry'] : []" + ) + const packageTargets = { + win32: createPackagedRuntimeNodeModuleResources('win32'), + darwin: createPackagedRuntimeNodeModuleResources('darwin'), + linux: createPackagedRuntimeNodeModuleResources('linux') + } + expect(packageTargets.win32).toEqual( + expect.arrayContaining([ + expect.objectContaining({ to: join('node_modules', 'windows-native-registry') }), + expect.objectContaining({ to: join('node_modules', 'node-addon-api') }) + ]) + ) + for (const platform of ['darwin', 'linux']) { + expect(packageTargets[platform]).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ to: join('node_modules', 'windows-native-registry') }) + ]) + ) + } + }) + it('guards package scripts that launch Electron tooling', () => { const scripts = packageJson.scripts const guardedScripts = [ diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index 199149edaa1..cf91055d1e3 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -20,9 +20,18 @@ import { rebuild } from '@electron/rebuild' import { execFileSync, spawnSync } from 'node:child_process' -import { existsSync, globSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + copyFileSync, + existsSync, + globSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' import { platform as osPlatform } from 'node:os' -import { resolve } from 'node:path' +import { join, resolve } from 'node:path' const projectDir = process.cwd() let cliOptions @@ -40,6 +49,7 @@ const electronVersion = JSON.parse( ).version const ignoreModules = ['cpu-features'] +const NODE_PTY_CONPTY_RUNTIME_FILES = ['conpty.dll', 'OpenConsole.exe'] if (ignoreModules.length > 0) { console.log(`[rebuild] Skipping optional Electron rebuild modules: ${ignoreModules.join(', ')}`) @@ -49,7 +59,11 @@ if (ignoreModules.length > 0) { // modules inside pnpm's .pnpm/ store. Passing an explicit list of modules to // rebuild via `onlyModules` ensures they're recompiled against Electron's Node // ABI regardless of the package manager's store layout. -const NATIVE_MODULES = ['node-pty', 'cpu-features'] +const NATIVE_MODULES = [ + 'node-pty', + 'cpu-features', + ...(rebuildPlatform === 'win32' ? ['windows-native-registry'] : []) +] const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m)) const forceRebuild = process.env.ORCA_FORCE_NATIVE_REBUILD === '1' || @@ -58,6 +72,7 @@ const forceRebuild = rebuildArch !== process.arch ensureElectronPackageInstalled() +restoreNodePtyWindowsConptyRuntime() const patchedNodePtyRebuildReason = forceRebuild ? null : getPatchedNodePtyRebuildReason() @@ -124,6 +139,7 @@ try { // Node before postinstall runs this script. force: true }) + restoreNodePtyWindowsConptyRuntime() } catch (/** @type {any} */ err) { console.error('[rebuild] Native module rebuild failed:', err?.message ?? err) if (isWindowsNativeLockError(err)) { @@ -143,6 +159,37 @@ try { process.exit(1) } +function restoreNodePtyWindowsConptyRuntime() { + if (rebuildPlatform !== 'win32' || !onlyModules.includes('node-pty')) { + return + } + + const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty') + if (!existsSync(join(nodePtyDir, 'build', 'Release', 'conpty.node'))) { + return + } + const conptyRoot = join(nodePtyDir, 'third_party', 'conpty') + const sourceDir = readdirSync(conptyRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(conptyRoot, entry.name, `win10-${rebuildArch}`)) + .find((candidate) => existsSync(candidate)) + if (!sourceDir) { + throw new Error(`node-pty has no ConPTY runtime payload for win10-${rebuildArch}`) + } + + const runtimeDir = join(nodePtyDir, 'build', 'Release', 'conpty') + mkdirSync(runtimeDir, { recursive: true }) + for (const filename of NODE_PTY_CONPTY_RUNTIME_FILES) { + const sourceFile = join(sourceDir, filename) + if (!existsSync(sourceFile)) { + throw new Error(`node-pty is missing ${sourceFile}`) + } + copyFileSync(sourceFile, join(runtimeDir, filename)) + } + // Why: @electron/rebuild bypasses node-pty's postinstall step that normally copies these DLLs. + console.log(`[rebuild] Restored node-pty ConPTY runtime files for win10-${rebuildArch}.`) +} + function ensureElectronPackageInstalled() { if (electronPackageIsUsable()) { return @@ -390,6 +437,7 @@ function probeElectronNativeModules(moduleNames) { const probeSource = ` const { createRequire } = require('node:module') +const { existsSync } = require('node:fs') const { release } = require('node:os') const { resolve } = require('node:path') const projectRequire = createRequire(resolve(process.cwd(), 'package.json')) @@ -411,10 +459,17 @@ if (failures.length > 0) { } function loadNativeModule(moduleName) { + if (moduleName === 'windows-native-registry') { + const registry = projectRequire(moduleName) + // Why: the package defers loading its .node addon until the first registry call. + registry.getRegistryKey(registry.HK.CU, 'Environment') + return + } if (moduleName === 'node-pty') { projectRequire('node-pty') const { loadNativeModule } = projectRequire('node-pty/lib/utils') const native = loadNativeModule(getNodePtyNativeModuleName()) + assertNodePtyWindowsConptyRuntime(native.dir) if (requirePatchedNodePtySourceBuild && !isNodePtyReleaseBuildDir(native.dir)) { throw new Error( 'node-pty resolved to ' + @@ -427,6 +482,26 @@ function loadNativeModule(moduleName) { projectRequire(moduleName) } +function assertNodePtyWindowsConptyRuntime(nativeDir) { + if (process.platform !== 'win32' || !isNodePtyReleaseBuildDir(nativeDir)) { + return + } + const runtimeDir = resolve( + process.cwd(), + 'node_modules', + 'node-pty', + 'build', + 'Release', + 'conpty' + ) + for (const filename of ${JSON.stringify(NODE_PTY_CONPTY_RUNTIME_FILES)}) { + const runtimeFile = resolve(runtimeDir, filename) + if (!existsSync(runtimeFile)) { + throw new Error('node-pty ConPTY runtime file is missing: ' + runtimeFile) + } + } +} + function isNodePtyReleaseBuildDir(nativeDir) { return typeof nativeDir === 'string' && nativeDir.replace(/\\\\/g, '/').includes('build/Release/') } diff --git a/config/scripts/rebuild-native-deps.test.mjs b/config/scripts/rebuild-native-deps.test.mjs index 15e1e616aa9..1eacf9aa127 100644 --- a/config/scripts/rebuild-native-deps.test.mjs +++ b/config/scripts/rebuild-native-deps.test.mjs @@ -124,6 +124,61 @@ describe('rebuild-native-deps Electron install fallback', () => { }) describe('rebuild-native-deps patched node-pty rebuild', () => { + it.skipIf(process.platform !== 'win32')( + 'repairs a missing ConPTY runtime before probing without recompiling node-pty', + () => { + const projectDir = mkTempProject() + + try { + const rebuildLogPath = join(projectDir, 'electron-rebuild.log') + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) + writeFakeLoadableNodePty(projectDir, { nativeDir: '../build/Release/' }) + writeFakeWindowsRegistry(projectDir) + writeFakeNodePtyConptyPayload(projectDir, process.arch) + + const result = runRebuildScript(projectDir, { + ORCA_REBUILD_TEST_LOG: rebuildLogPath, + npm_config_platform: 'win32', + npm_config_arch: process.arch + }) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('Restored node-pty ConPTY runtime files') + expect(result.stdout).toContain( + 'Native modules already load in Electron; skipping rebuild.' + ) + expect(existsSync(rebuildLogPath)).toBe(false) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + } + ) + + it('restores the ConPTY runtime payload after a Windows Electron rebuild', () => { + const projectDir = mkTempProject() + + try { + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir) + writeFakeNodePtyConptyPayload(projectDir, 'x64') + + const result = runRebuildScript( + projectDir, + { npm_config_platform: 'win32', npm_config_arch: 'x64' }, + ['--platform=win32', '--arch=x64', '--force'] + ) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('Restored node-pty ConPTY runtime files for win10-x64') + const runtimeDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release', 'conpty') + expect(readFileSync(join(runtimeDir, 'conpty.dll'), 'utf8')).toBe('conpty.dll x64') + expect(readFileSync(join(runtimeDir, 'OpenConsole.exe'), 'utf8')).toBe('OpenConsole.exe x64') + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + it.skipIf(process.platform === 'win32')( 'rebuilds when Electron can load node-pty but patched build artifacts are missing', () => { @@ -225,7 +280,7 @@ function mkTempProject() { return projectDir } -function runRebuildScript(projectDir, extraEnv = {}) { +function runRebuildScript(projectDir, extraEnv = {}, args = []) { const env = { ...process.env, npm_config_platform: 'linux', @@ -239,7 +294,7 @@ function runRebuildScript(projectDir, extraEnv = {}) { delete env[key] } } - return spawnSync(process.execPath, ['config/scripts/rebuild-native-deps.mjs'], { + return spawnSync(process.execPath, ['config/scripts/rebuild-native-deps.mjs', ...args], { cwd: projectDir, encoding: 'utf8', env: { @@ -360,16 +415,20 @@ export async function rebuild(options) { ) } -function writeFakeUsableElectronPackage(projectDir) { +function writeFakeUsableElectronPackage(projectDir, { platform = 'linux' } = {}) { writeFakeElectronPackage(projectDir) const electronDir = join(projectDir, 'node_modules', 'electron') - const electronPath = join(electronDir, 'dist', 'electron') + const platformExecutable = platform === 'win32' ? 'electron.exe' : 'electron' + const electronPath = join(electronDir, 'dist', platformExecutable) mkdirSync(join(electronDir, 'dist'), { recursive: true }) - writeFileSync(join(electronDir, 'path.txt'), 'electron') + writeFileSync(join(electronDir, 'path.txt'), platformExecutable) writeFileSync(join(electronDir, 'dist', 'version'), 'v41.5.0') - writeFileSync( - electronPath, - `#!/usr/bin/env node + if (platform === 'win32') { + copyFileSync(process.execPath, electronPath) + } else { + writeFileSync( + electronPath, + `#!/usr/bin/env node const { spawnSync } = require('node:child_process') const result = spawnSync(process.execPath, process.argv.slice(2), { @@ -385,8 +444,27 @@ if (result.error) { process.exit(result.status ?? 0) ` + ) + chmodSync(electronPath, 0o755) + } +} + +function writeFakeNodePtyConptyPayload(projectDir, arch) { + const releaseDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release') + mkdirSync(releaseDir, { recursive: true }) + writeFileSync(join(releaseDir, 'conpty.node'), 'native addon') + const sourceDir = join( + projectDir, + 'node_modules', + 'node-pty', + 'third_party', + 'conpty', + '0.1.0', + `win10-${arch}` ) - chmodSync(electronPath, 0o755) + mkdirSync(sourceDir, { recursive: true }) + writeFileSync(join(sourceDir, 'conpty.dll'), `conpty.dll ${arch}`) + writeFileSync(join(sourceDir, 'OpenConsole.exe'), `OpenConsole.exe ${arch}`) } function writeFakeLoadableNodePty(projectDir, { nativeDir = 'prebuilds/pty' } = {}) { @@ -403,6 +481,15 @@ exports.loadNativeModule = function loadNativeModule(nativeName) { ) } +function writeFakeWindowsRegistry(projectDir) { + const registryDir = join(projectDir, 'node_modules', 'windows-native-registry') + mkdirSync(registryDir, { recursive: true }) + writeFileSync( + join(registryDir, 'index.js'), + 'exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => ({})\n' + ) +} + function writeNodePtyPatchFile(projectDir) { mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true }) writeFileSync(join(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch'), 'patch marker\n') diff --git a/package.json b/package.json index 5035c621383..e5734083688 100644 --- a/package.json +++ b/package.json @@ -230,7 +230,8 @@ "sherpa-onnx-darwin-x64": "1.12.37", "sherpa-onnx-linux-arm64": "1.12.37", "sherpa-onnx-linux-x64": "1.12.37", - "sherpa-onnx-win-x64": "1.12.37" + "sherpa-onnx-win-x64": "1.12.37", + "windows-native-registry": "3.2.2" }, "lint-staged": { "*.{ts,tsx,js,jsx,mjs,mts,cts}": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6ee54760e0..0ed99b766af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -399,6 +399,9 @@ importers: sherpa-onnx-win-x64: specifier: 1.12.37 version: 1.12.37 + windows-native-registry: + specifier: 3.2.2 + version: 3.2.2 packages: @@ -5453,6 +5456,9 @@ packages: node-addon-api@1.7.2: resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==} + node-addon-api@4.3.0: + resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==} + node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -6745,6 +6751,10 @@ packages: engines: {node: '>=8'} hasBin: true + windows-native-registry@3.2.2: + resolution: {integrity: sha512-yg1ZnuzUExn+Uq3cODB23Ju15URw0F/sRrSpjavVhHMi46s6Pcukp4+PtumoELmefoeHarCgci0XV34COpuYkw==} + os: [win32] + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -12003,6 +12013,9 @@ snapshots: node-addon-api@1.7.2: optional: true + node-addon-api@4.3.0: + optional: true + node-addon-api@7.1.1: {} node-api-version@0.2.1: @@ -13465,6 +13478,11 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + windows-native-registry@3.2.2: + dependencies: + node-addon-api: 4.3.0 + optional: true + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index a42805313c0..9c17fd21116 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -29,6 +29,14 @@ vi.mock('node:child_process', () => ({ import { CliInstaller } from './cli-installer' import { buildAppImageCliWrapper } from './appimage-cli-wrapper' +import { + WindowsUserPathRegistryReader, + type WindowsUserPathReadResult +} from './windows-user-path-registry' + +function userPathRead(value: string | null, expandable = false): WindowsUserPathReadResult { + return { state: 'success', value, expandable } +} async function makeFixture(): Promise<{ root: string @@ -323,7 +331,7 @@ describe('CliInstaller', () => { execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', appPath: fixture.appPath, commandPathOverride: installPath, - userPathReader: async () => userPath, + userPathReader: async () => userPathRead(userPath), userPathWriter: async (value) => { userPath = value } @@ -358,7 +366,7 @@ describe('CliInstaller', () => { execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', appPath: fixture.appPath, commandPathOverride: installPath, - userPathReader: async () => 'C:\\Windows\\System32', + userPathReader: async () => userPathRead('C:\\Windows\\System32'), userPathWriter: async () => { // The .NET error id survives localized or mojibake PowerShell output. const error = new Error( @@ -390,7 +398,7 @@ describe('CliInstaller', () => { execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', appPath: fixture.appPath, commandPathOverride: join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd'), - userPathReader: async () => 'C:\\Windows\\System32', + userPathReader: async () => userPathRead('C:\\Windows\\System32'), userPathWriter }) @@ -416,7 +424,7 @@ describe('CliInstaller', () => { execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', appPath: fixture.appPath, commandPathOverride: installPath, - userPathReader: async () => 'C:\\Windows\\System32', + userPathReader: async () => userPathRead('C:\\Windows\\System32'), userPathWriter: async () => { throw new Error(message) } @@ -428,36 +436,133 @@ describe('CliInstaller', () => { } ) - it('settles when the Windows PATH query hangs', async () => { - vi.useFakeTimers() + it('reports an unknown Windows PATH without spawning PowerShell', async () => { const fixture = await makeFixture() const installPath = join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd') - const killMock = vi.fn() - execFileMock.mockImplementation(() => ({ kill: killMock })) const installer = new CliInstaller({ platform: 'win32', isPackaged: false, userDataPath: fixture.userDataPath, execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', appPath: fixture.appPath, - commandPathOverride: installPath + commandPathOverride: installPath, + userPathReader: async () => ({ + state: 'unknown', + detail: 'Orca could not read the Windows user PATH registry value.' + }) }) - const promise = installer.getStatus() - let settled = false - void promise - .catch(() => undefined) - .finally(() => { - settled = true + await expect(installer.getStatus()).resolves.toMatchObject({ + state: 'not_installed', + pathConfigured: null, + detail: expect.stringContaining('could not read') + }) + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('fails closed without writing when a Windows PATH mutation cannot read the registry', async () => { + const fixture = await makeFixture() + const userPathWriter = vi.fn() + const installer = new CliInstaller({ + platform: 'win32', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', + appPath: fixture.appPath, + commandPathOverride: join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd'), + userPathReader: async () => ({ + state: 'unknown', + detail: 'Orca could not read the Windows user PATH registry value.' + }), + userPathWriter + }) + + await expect(installer.install()).rejects.toThrow('No PATH changes were made') + expect(userPathWriter).not.toHaveBeenCalled() + }) + + it('bypasses cached status data before a Windows PATH mutation', async () => { + const fixture = await makeFixture() + const installPath = join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd') + const pathDirectory = dirname(installPath) + let registryPath = 'C:\\Tools' + const registryReader = new WindowsUserPathRegistryReader({ + platform: 'win32', + registryLoader: async () => ({ + HK: { CU: 0x80000001 }, + getRegistryKey: () => ({ + Path: { name: 'Path', type: 2, value: registryPath } + }) }) + }) + const userPathWriter = vi.fn(async (value: string) => { + registryPath = value + }) + const installer = new CliInstaller({ + platform: 'win32', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', + appPath: fixture.appPath, + commandPathOverride: installPath, + userPathReader: () => registryReader.read(), + userPathMutationReader: () => registryReader.readFresh(), + userPathWriter, + userPathCacheInvalidator: () => registryReader.invalidate() + }) - await vi.waitFor(() => expect(execFileMock).toHaveBeenCalled()) - await vi.advanceTimersByTimeAsync(5_000) - await Promise.resolve() + await installer.getStatus() + registryPath = 'C:\\Tools;C:\\AddedByAnotherInstaller' + await installer.install() - expect(settled).toBe(true) - await expect(promise).rejects.toThrow('Windows PATH command timed out') - expect(killMock).toHaveBeenCalled() + expect(userPathWriter).toHaveBeenCalledWith( + `C:\\Tools;C:\\AddedByAnotherInstaller;${pathDirectory}` + ) + }) + + it('matches expandable Windows PATH entries case-insensitively without rewriting them', async () => { + const fixture = await makeFixture() + const installPath = join(fixture.root, 'Local App Data', 'Orca', 'bin', 'orca.cmd') + const userPathWriter = vi.fn() + const installer = new CliInstaller({ + platform: 'win32', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', + appPath: fixture.appPath, + commandPathOverride: installPath, + windowsEnvironment: { LOCALAPPDATA: join(fixture.root, 'Local App Data') }, + userPathReader: async () => userPathRead('%localappdata%\\Orca\\bin\\', true), + userPathWriter + }) + + await expect(installer.install()).resolves.toMatchObject({ + state: 'installed', + pathConfigured: true + }) + expect(userPathWriter).not.toHaveBeenCalled() + }) + + it('does not expand environment variables stored in a REG_SZ Windows PATH', async () => { + const fixture = await makeFixture() + const installPath = join(fixture.root, 'Local App Data', 'Orca', 'bin', 'orca.cmd') + const pathDirectory = dirname(installPath) + const userPathWriter = vi.fn() + const installer = new CliInstaller({ + platform: 'win32', + isPackaged: false, + userDataPath: fixture.userDataPath, + execPath: 'C:\\Users\\me\\AppData\\Local\\Orca\\Orca.exe', + appPath: fixture.appPath, + commandPathOverride: installPath, + windowsEnvironment: { LOCALAPPDATA: join(fixture.root, 'Local App Data') }, + userPathReader: async () => userPathRead('%LOCALAPPDATA%\\Orca\\bin'), + userPathWriter + }) + + await installer.install() + + expect(userPathWriter).toHaveBeenCalledWith(`%LOCALAPPDATA%\\Orca\\bin;${pathDirectory}`) }) // Why: this test creates a Unix symlink to /tmp/not-orca, which only applies on macOS/Linux. @@ -1189,7 +1294,7 @@ describe('CliInstaller', () => { userDataPath: fixture.userDataPath, execPath: join(fixture.root, 'D Custom Orca', 'Orca.exe'), appPath: fixture.appPath, - userPathReader: async () => null, + userPathReader: async () => userPathRead(null), userPathWriter: async () => {} }) @@ -1197,6 +1302,33 @@ describe('CliInstaller', () => { expect(status.commandPath).toBe(join(resourcesPath, 'bin', 'orca.exe')) }) + it('keeps a bundled Windows launcher installed when the user PATH read is unknown', async () => { + const fixture = await makeFixture() + const resourcesPath = join(fixture.root, 'resources') + const bundledLauncher = join(resourcesPath, 'bin', 'orca.exe') + await mkdir(dirname(bundledLauncher), { recursive: true }) + await writeFile(bundledLauncher, 'native launcher', 'utf8') + + const installer = new CliInstaller({ + platform: 'win32', + isPackaged: true, + resourcesPath, + userDataPath: fixture.userDataPath, + execPath: join(fixture.root, 'Orca.exe'), + appPath: fixture.appPath, + userPathReader: async () => ({ + state: 'unknown', + detail: 'Orca could not read the Windows user PATH registry value.' + }) + }) + + await expect(installer.getStatus()).resolves.toMatchObject({ + state: 'installed', + pathConfigured: null, + detail: expect.stringContaining('could not read') + }) + }) + it('does not overwrite the packaged Windows launcher while registering PATH', async () => { const fixture = await makeFixture() const localAppDataPath = join(fixture.root, 'AppData', 'Local') @@ -1215,7 +1347,7 @@ describe('CliInstaller', () => { userDataPath: fixture.userDataPath, execPath: join(fixture.root, 'D Custom Orca', 'Orca.exe'), appPath: fixture.appPath, - userPathReader: async () => userPath, + userPathReader: async () => userPathRead(userPath), userPathWriter: async (value) => { userPath = value } diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index c5f1286126b..431c04e917b 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -18,6 +18,12 @@ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:pat import { promisify } from 'node:util' import type { CliInstallMethod, CliInstallStatus } from '../../shared/cli-install-types' import { buildAppImageCliWrapper } from './appimage-cli-wrapper' +import { + invalidateWindowsUserPathRegistryCache, + readFreshWindowsUserPathRegistry, + readWindowsUserPathRegistry, + type WindowsUserPathReadResult +} from './windows-user-path-registry' const execFileAsync = promisify(execFile) const DEFAULT_MAC_COMMAND_PATH = '/usr/local/bin/orca' @@ -25,7 +31,7 @@ const DEV_COMMAND_NAME = 'orca-dev' const LINUX_COMMAND_NAME = 'orca-ide' const LEGACY_LINUX_COMMAND_NAME = 'orca' const DEV_LAUNCHER_DIR = ['cli', 'bin'] -const WINDOWS_PATH_COMMAND_TIMEOUT_MS = 5_000 +const WINDOWS_PATH_WRITE_TIMEOUT_MS = 5_000 type CliInstallerOptions = { platform?: NodeJS.Platform @@ -41,8 +47,11 @@ type CliInstallerOptions = { /** Feeds into the /usr/local/bin existence check at construction time; used in tests to simulate absent /usr/local/bin on arm64 without relying on real filesystem state. */ defaultMacCommandPath?: string privilegedRunner?: (command: string) => Promise - userPathReader?: () => Promise + userPathReader?: () => Promise + userPathMutationReader?: () => Promise userPathWriter?: (value: string) => Promise + userPathCacheInvalidator?: () => void + windowsEnvironment?: NodeJS.ProcessEnv /** Why: AppImage reports a stable outer file path via $APPIMAGE while bundled resources live in an ephemeral FUSE mount. */ appImagePath?: string | null } @@ -65,8 +74,11 @@ export class CliInstaller { private readonly commandPathOverride: string | null private readonly macCommandPath: string private readonly privilegedRunner: (command: string) => Promise - private readonly userPathReader: () => Promise + private readonly userPathReader: () => Promise + private readonly userPathMutationReader: () => Promise private readonly userPathWriter: (value: string) => Promise + private readonly userPathCacheInvalidator: () => void + private readonly windowsEnvironment: NodeJS.ProcessEnv private readonly appImagePath: string | null private get commandName(): string { @@ -105,8 +117,13 @@ export class CliInstaller { ? candidateMacPath : join(this.homePath, '.local', 'bin', 'orca') this.privilegedRunner = options.privilegedRunner ?? runMacPrivilegedCommand - this.userPathReader = options.userPathReader ?? (() => readWindowsUserPath()) + this.userPathReader = options.userPathReader ?? readWindowsUserPathRegistry + this.userPathMutationReader = + options.userPathMutationReader ?? options.userPathReader ?? readFreshWindowsUserPathRegistry this.userPathWriter = options.userPathWriter ?? ((value) => writeWindowsUserPath(value)) + this.userPathCacheInvalidator = + options.userPathCacheInvalidator ?? invalidateWindowsUserPathRegistryCache + this.windowsEnvironment = options.windowsEnvironment ?? process.env this.appImagePath = this.platform === 'linux' && this.isPackaged ? (options.appImagePath ?? process.env.APPIMAGE ?? null) @@ -164,8 +181,8 @@ export class CliInstaller { ? await this.inspectAppImageWrapper(spec.commandPath, launcherPath) : await this.inspectWindowsWrapper(spec.commandPath, launcherPath) const pathDirectory = dirname(spec.commandPath) - const pathConfigured = await this.isPathConfigured(pathDirectory) - return this.withPathInfo(baseStatus, pathDirectory, pathConfigured) + const pathProbe = await this.probePathConfiguration(pathDirectory) + return this.withPathInfo(baseStatus, pathDirectory, pathProbe) } async install(): Promise { @@ -763,23 +780,40 @@ export class CliInstaller { } } - private async isPathConfigured(pathDirectory: string): Promise { - const pathValue = - this.platform === 'win32' ? await this.userPathReader() : (this.processPathEnv ?? '') - return splitPathEntries(this.platform, pathValue).some((entry) => - samePathEntry(this.platform, entry, pathDirectory) - ) + private async probePathConfiguration( + pathDirectory: string + ): Promise<{ configured: boolean | null; detail: string | null }> { + if (this.platform !== 'win32') { + return { + configured: splitPathEntries(this.platform, this.processPathEnv ?? '').some((entry) => + samePathEntry(this.platform, entry, pathDirectory) + ), + detail: null + } + } + + const result = await this.userPathReader() + if (result.state === 'unknown') { + return { configured: null, detail: result.detail } + } + return { + configured: splitPathEntries('win32', result.value).some((entry) => + samePathEntry('win32', entry, pathDirectory, this.windowsEnvironment, result.expandable) + ), + detail: null + } } private withPathInfo( status: CliInstallStatus, pathDirectory: string, - pathConfigured: boolean + pathProbe: { configured: boolean | null; detail: string | null } ): CliInstallStatus { + const { configured: pathConfigured } = pathProbe if ( this.isWindowsPackagedBundledCommand(status.commandPath, status.launcherPath) && status.state === 'installed' && - !pathConfigured + pathConfigured === false ) { return { ...status, @@ -791,6 +825,17 @@ export class CliInstaller { } } + if (pathConfigured === null) { + return { + ...status, + pathDirectory, + pathConfigured, + detail: + pathProbe.detail ?? + 'The Orca launcher exists, but Orca could not check your Windows user PATH.' + } + } + if (status.state !== 'installed') { return { ...status, @@ -819,9 +864,13 @@ export class CliInstaller { } private async ensureWindowsPathEntry(pathDirectory: string): Promise { - const current = await this.userPathReader() - const entries = splitPathEntries('win32', current) - if (entries.some((entry) => samePathEntry('win32', entry, pathDirectory))) { + const current = await this.readWindowsUserPathForMutation() + const entries = splitPathEntries('win32', current.value) + if ( + entries.some((entry) => + samePathEntry('win32', entry, pathDirectory, this.windowsEnvironment, current.expandable) + ) + ) { return } entries.push(pathDirectory) @@ -832,15 +881,31 @@ export class CliInstaller { if (this.platform !== 'win32') { return } - const current = await this.userPathReader() - const entries = splitPathEntries('win32', current) - const nextEntries = entries.filter((entry) => !samePathEntry('win32', entry, pathDirectory)) + const current = await this.readWindowsUserPathForMutation() + const entries = splitPathEntries('win32', current.value) + const nextEntries = entries.filter( + (entry) => + !samePathEntry('win32', entry, pathDirectory, this.windowsEnvironment, current.expandable) + ) if (nextEntries.length === entries.length) { return } await this.writeWindowsUserPathEntry(nextEntries.join(';'), pathDirectory, 'remove') } + private async readWindowsUserPathForMutation(): Promise<{ + value: string | null + expandable: boolean + }> { + const result = await this.userPathMutationReader() + if (result.state === 'success') { + return { value: result.value, expandable: result.expandable } + } + // Why: PATH updates are read-modify-write; continuing after an unknown read + // could replace the user's existing PATH with an incomplete value. + throw new Error(`${result.detail} No PATH changes were made.`) + } + // Why: raw PowerShell errors reach the UI, so translate denied PATH writes // while preserving the original diagnostic as the error cause. private async writeWindowsUserPathEntry( @@ -850,6 +915,7 @@ export class CliInstaller { ): Promise { try { await this.userPathWriter(value) + this.userPathCacheInvalidator() } catch (error) { if (!isWindowsUserPathPermissionError(error)) { throw error @@ -1019,9 +1085,16 @@ function uniquePathEntries(platform: NodeJS.Platform, entries: string[]): string return result } -function samePathEntry(platform: NodeJS.Platform, left: string, right: string): boolean { +function samePathEntry( + platform: NodeJS.Platform, + left: string, + right: string, + windowsEnvironment: NodeJS.ProcessEnv = process.env, + expandWindowsVariables = true +): boolean { return platform === 'win32' - ? normalizeWindowsPath(left) === normalizeWindowsPath(right) + ? normalizeWindowsPath(left, windowsEnvironment, expandWindowsVariables) === + normalizeWindowsPath(right, windowsEnvironment, expandWindowsVariables) : left === right } @@ -1043,8 +1116,22 @@ async function isExecutableFile(commandPath: string): Promise { } } -function normalizeWindowsPath(value: string): string { - return value.replaceAll('/', '\\').replace(/\\+$/, '').toLowerCase() +function normalizeWindowsPath( + value: string, + env: NodeJS.ProcessEnv = process.env, + expandEnvironmentVariables = true +): string { + return (expandEnvironmentVariables ? expandWindowsEnvironmentVariables(value, env) : value) + .replaceAll('/', '\\') + .replace(/\\+$/, '') + .toLowerCase() +} + +function expandWindowsEnvironmentVariables(value: string, env: NodeJS.ProcessEnv): string { + return value.replace(/%([^%]+)%/g, (match, rawName: string) => { + const envKey = Object.keys(env).find((key) => key.toLowerCase() === rawName.toLowerCase()) + return envKey && env[envKey] ? env[envKey] : match + }) } function escapeWindowsBatchValue(value: string): string { @@ -1108,15 +1195,6 @@ function isAbsoluteForPlatform(platform: NodeJS.Platform, value: string): boolea return isAbsolute(value) } -async function readWindowsUserPath(): Promise { - const stdout = await runWindowsPathCommand([ - '-NoProfile', - '-Command', - "[Environment]::GetEnvironmentVariable('Path','User')" - ]) - return stdout.trim() || null -} - async function writeWindowsUserPath(value: string): Promise { await runWindowsPathCommand([ '-NoProfile', @@ -1150,16 +1228,14 @@ function runWindowsPathCommand(args: string[]): Promise { // not keep command registration status or install/remove pending forever. const timeout = setTimeout(() => { child?.kill() - finish( - new Error(`Windows PATH command timed out after ${WINDOWS_PATH_COMMAND_TIMEOUT_MS}ms.`) - ) - }, WINDOWS_PATH_COMMAND_TIMEOUT_MS) + finish(new Error(`Windows PATH command timed out after ${WINDOWS_PATH_WRITE_TIMEOUT_MS}ms.`)) + }, WINDOWS_PATH_WRITE_TIMEOUT_MS) try { child = execFile( 'powershell', args, - { encoding: 'utf8', timeout: WINDOWS_PATH_COMMAND_TIMEOUT_MS }, + { encoding: 'utf8', timeout: WINDOWS_PATH_WRITE_TIMEOUT_MS }, (error, stdout) => { finish(error ?? null, stdout) } diff --git a/src/main/cli/windows-user-path-registry.test.ts b/src/main/cli/windows-user-path-registry.test.ts new file mode 100644 index 00000000000..12beca13852 --- /dev/null +++ b/src/main/cli/windows-user-path-registry.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest' +import { + invalidateWindowsUserPathRegistryCache, + readWindowsUserPathRegistry, + WindowsUserPathRegistryReader +} from './windows-user-path-registry' + +function registryModule(pathValue?: string, type = 2) { + return { + HK: { CU: 0x80000001 }, + REG: { SZ: 1, EXPAND_SZ: 2 }, + getRegistryKey: vi.fn(() => + pathValue === undefined ? {} : { Path: { name: 'Path', type, value: pathValue } } + ) + } +} + +describe('WindowsUserPathRegistryReader', () => { + it('reads Unicode REG_EXPAND_SZ values without expanding the stored text', async () => { + const registry = registryModule('%LOCALAPPDATA%\\Orca;C:\\工具\\bin;C:\\Développement') + const reader = new WindowsUserPathRegistryReader({ + platform: 'win32', + registryLoader: async () => registry + }) + + await expect(reader.read()).resolves.toEqual({ + state: 'success', + value: '%LOCALAPPDATA%\\Orca;C:\\工具\\bin;C:\\Développement', + expandable: true + }) + expect(registry.getRegistryKey).toHaveBeenCalledWith(0x80000001, 'Environment') + }) + + it('distinguishes a missing Path value from a registry read failure', async () => { + const missingReader = new WindowsUserPathRegistryReader({ + platform: 'win32', + registryLoader: async () => registryModule() + }) + const failedReader = new WindowsUserPathRegistryReader({ + platform: 'win32', + registryLoader: async () => { + throw new Error('native addon unavailable') + } + }) + + await expect(missingReader.read()).resolves.toEqual({ + state: 'success', + value: null, + expandable: false + }) + await expect(failedReader.read()).resolves.toMatchObject({ state: 'unknown' }) + }) + + it('coalesces concurrent reads, caches status reads, and bypasses the cache when fresh', async () => { + let now = 100 + let resolveRegistry!: (value: ReturnType) => void + const registryLoader = vi.fn( + () => + new Promise>((resolve) => { + resolveRegistry = resolve + }) + ) + const reader = new WindowsUserPathRegistryReader({ + platform: 'win32', + registryLoader, + now: () => now, + cacheTtlMs: 1_000 + }) + + const first = reader.read() + const concurrent = reader.read() + expect(registryLoader).toHaveBeenCalledOnce() + resolveRegistry(registryModule('C:\\Tools')) + await expect(Promise.all([first, concurrent])).resolves.toEqual([ + { state: 'success', value: 'C:\\Tools', expandable: true }, + { state: 'success', value: 'C:\\Tools', expandable: true } + ]) + + await reader.read() + expect(registryLoader).toHaveBeenCalledOnce() + + now += 1 + const refreshed = reader.readFresh() + expect(registryLoader).toHaveBeenCalledTimes(2) + resolveRegistry(registryModule('C:\\Tools;C:\\Orca')) + await expect(refreshed).resolves.toEqual({ + state: 'success', + value: 'C:\\Tools;C:\\Orca', + expandable: true + }) + }) + + it.each([ + { type: 1, expandable: false }, + { type: 2, expandable: true } + ])('preserves whether registry type $type expands variables', async ({ type, expandable }) => { + const reader = new WindowsUserPathRegistryReader({ + platform: 'win32', + registryLoader: async () => registryModule('%LOCALAPPDATA%\\Orca', type) + }) + + await expect(reader.read()).resolves.toEqual({ + state: 'success', + value: '%LOCALAPPDATA%\\Orca', + expandable + }) + }) + + it('does not load the Windows-only dependency on other platforms', async () => { + const registryLoader = vi.fn(async () => registryModule('C:\\Tools')) + const reader = new WindowsUserPathRegistryReader({ + platform: 'linux', + registryLoader + }) + + await expect(reader.read()).resolves.toMatchObject({ state: 'unknown' }) + expect(registryLoader).not.toHaveBeenCalled() + }) + + it.runIf(process.platform === 'win32')( + 'loads the real native addon and reads the current Windows user PATH', + async () => { + invalidateWindowsUserPathRegistryCache() + await expect(readWindowsUserPathRegistry()).resolves.toMatchObject({ state: 'success' }) + } + ) +}) diff --git a/src/main/cli/windows-user-path-registry.ts b/src/main/cli/windows-user-path-registry.ts new file mode 100644 index 00000000000..d3e493fd87d --- /dev/null +++ b/src/main/cli/windows-user-path-registry.ts @@ -0,0 +1,160 @@ +import { createRequire } from 'node:module' + +export type WindowsUserPathReadResult = + | { state: 'success'; value: string | null; expandable: boolean } + | { state: 'unknown'; detail: string } + +type RegistryValue = { + name?: unknown + type?: unknown + value?: unknown +} + +type WindowsRegistryModule = { + HK: { CU: number } + getRegistryKey: ( + root: number, + path: string + ) => Record | null | undefined +} + +type WindowsUserPathRegistryReaderOptions = { + platform?: NodeJS.Platform + registryLoader?: () => Promise + now?: () => number + cacheTtlMs?: number +} + +const DEFAULT_CACHE_TTL_MS = 1_000 +const USER_ENVIRONMENT_KEY = 'Environment' +const USER_PATH_VALUE = 'Path' +const REG_SZ = 1 +const REG_EXPAND_SZ = 2 +const requireFromMain = createRequire(__filename) + +async function loadWindowsRegistryModule(): Promise { + // Why: the optional dependency is not present in non-Windows installs, so + // TypeScript and the main bundle must not resolve it eagerly. + return requireFromMain('windows-native-registry') as WindowsRegistryModule +} + +export class WindowsUserPathRegistryReader { + private readonly platform: NodeJS.Platform + private readonly registryLoader: () => Promise + private readonly now: () => number + private readonly cacheTtlMs: number + private cached: { readAt: number; result: WindowsUserPathReadResult } | null = null + private inFlight: Promise | null = null + private generation = 0 + + constructor(options: WindowsUserPathRegistryReaderOptions = {}) { + this.platform = options.platform ?? process.platform + this.registryLoader = options.registryLoader ?? loadWindowsRegistryModule + this.now = options.now ?? Date.now + this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS + } + + async read(): Promise { + if (this.platform !== 'win32') { + return { + state: 'unknown', + detail: 'The Windows user PATH registry is only available on Windows.' + } + } + + const now = this.now() + const cacheAge = this.cached ? now - this.cached.readAt : null + if (this.cached && cacheAge !== null && cacheAge >= 0 && cacheAge < this.cacheTtlMs) { + return this.cached.result + } + if (this.inFlight) { + return this.inFlight + } + + const generation = this.generation + const inFlight = this.readUncached().then((result) => { + if (result.state === 'success' && generation === this.generation) { + this.cached = { readAt: this.now(), result } + } + return result + }) + this.inFlight = inFlight + try { + return await inFlight + } finally { + if (this.inFlight === inFlight) { + this.inFlight = null + } + } + } + + async readFresh(): Promise { + this.invalidate() + const generation = this.generation + const result = await this.readUncached() + if (result.state === 'success' && generation === this.generation) { + this.cached = { readAt: this.now(), result } + } + return result + } + + invalidate(): void { + this.generation += 1 + this.cached = null + // Why: a mutation must not join a registry read that may predate an external PATH update. + this.inFlight = null + } + + private async readUncached(): Promise { + try { + const registry = await this.registryLoader() + const key = registry.getRegistryKey(registry.HK.CU, USER_ENVIRONMENT_KEY) + if (!key || typeof key !== 'object') { + return { + state: 'unknown', + detail: 'Orca could not read the Windows user PATH registry key.' + } + } + + const pathEntry = Object.entries(key).find( + ([name]) => name.toLowerCase() === USER_PATH_VALUE.toLowerCase() + )?.[1] + if (!pathEntry) { + return { state: 'success', value: null, expandable: false } + } + if ( + (pathEntry.type !== REG_SZ && pathEntry.type !== REG_EXPAND_SZ) || + typeof pathEntry.value !== 'string' + ) { + return { + state: 'unknown', + detail: 'The Windows user PATH registry value has an unsupported format.' + } + } + return { + state: 'success', + value: pathEntry.value || null, + expandable: pathEntry.type === REG_EXPAND_SZ + } + } catch { + return { + state: 'unknown', + detail: 'Orca could not read the Windows user PATH registry value.' + } + } + } +} + +const defaultWindowsUserPathRegistryReader = new WindowsUserPathRegistryReader() + +export function readWindowsUserPathRegistry(): Promise { + return defaultWindowsUserPathRegistryReader.read() +} + +export function readFreshWindowsUserPathRegistry(): Promise { + return defaultWindowsUserPathRegistryReader.readFresh() +} + +export function invalidateWindowsUserPathRegistryCache(): void { + defaultWindowsUserPathRegistryReader.invalidate() +} diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index 547679df46f..13bd8088de3 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -206,6 +206,27 @@ describe('WslCliInstaller', () => { expect(installCommand).toContain('[ ! -L "$legacy_command_path" ]') }) + it('continues checking WSL when the host launcher exists but host PATH is unknown', async () => { + const wsl = createWslRunner() + const hostStatus = { + ...makeHostStatus(), + pathConfigured: null, + detail: 'Orca could not read the Windows user PATH registry value.' + } satisfies CliInstallStatus + const installer = new WslCliInstaller({ + platform: 'win32', + distro: 'Ubuntu', + hostInstaller: { getStatus: async () => hostStatus }, + wslRunner: wsl.runner + }) + + await expect(installer.getStatus()).resolves.toMatchObject({ + supported: true, + state: 'not_installed', + commandPath: '/home/alice/.local/bin/orca-ide' + }) + }) + it('derives the shared WSL bridge path for current and legacy command names', () => { expect(_internals.getBridgePathFromCommandPath('/home/alice/.local/bin/orca-ide')).toBe( '/home/alice/.local/share/orca/orca-wsl-bridge.ps1' diff --git a/src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.ts b/src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.ts index d464a64f894..b20565d823f 100644 --- a/src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.ts +++ b/src/renderer/src/components/emulator-pane/mobile-emulator-agent-setup-cli-state.ts @@ -2,7 +2,7 @@ import type { CliInstallStatus } from '../../../../shared/cli-install-types' import type { StepState } from '../settings/BrowserUseStepBadge' export function getMobileEmulatorCliPathNeedsAttention(status: CliInstallStatus | null): boolean { - return status?.state === 'installed' && !status.pathConfigured + return status?.state === 'installed' && status.pathConfigured === false } export function getMobileEmulatorCliStepBadgeState(input: { diff --git a/src/renderer/src/components/feature-tips/feature-tip-cli-install-action.ts b/src/renderer/src/components/feature-tips/feature-tip-cli-install-action.ts index e78e5695cdc..dbf4364808a 100644 --- a/src/renderer/src/components/feature-tips/feature-tip-cli-install-action.ts +++ b/src/renderer/src/components/feature-tips/feature-tip-cli-install-action.ts @@ -8,7 +8,7 @@ export async function installCliFromFeatureTip( installCli: () => Promise ): Promise { const status = await installCli() - if (status.state === 'installed' && status.pathConfigured) { + if (status.state === 'installed' && status.pathConfigured === true) { return { kind: 'installed', status } } return { kind: 'needs-attention', status } diff --git a/src/renderer/src/components/feature-tips/feature-tip-startup-gate.ts b/src/renderer/src/components/feature-tips/feature-tip-startup-gate.ts index ea426225130..a7eef0b8ead 100644 --- a/src/renderer/src/components/feature-tips/feature-tip-startup-gate.ts +++ b/src/renderer/src/components/feature-tips/feature-tip-startup-gate.ts @@ -16,7 +16,7 @@ export type FeatureTipsAppOpenDecision = export function isCliFeatureTipCompleted(status: CliInstallStatus): boolean { // Why: unsupported launch modes cannot complete setup, but an installed // launcher still needs attention until it is reachable on PATH. - return !status.supported || (status.state === 'installed' && status.pathConfigured) + return !status.supported || (status.state === 'installed' && status.pathConfigured === true) } export function getFeatureTipsAppOpenDecision(args: { diff --git a/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts b/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts index ab1d1a43f91..e0916c5fad0 100644 --- a/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts +++ b/src/renderer/src/components/onboarding/onboarding-feature-setup.test.ts @@ -301,4 +301,24 @@ describe('onboarding feature setup runner', () => { installCli.mock.invocationCallOrder[0] ) }) + + it('warns without changing PATH when the Windows registry read is unknown', async () => { + const unknownStatus: CliInstallStatus = { + ...INSTALLED_CLI_STATUS, + platform: 'win32', + pathConfigured: null, + detail: 'Orca could not read the Windows user PATH registry value.' + } + const deps = createDeps({ getCliStatus: vi.fn(async () => unknownStatus) }) + + const result = await runOnboardingFeatureSetup( + { browserUse: true, computerUse: false, orchestration: false, linearTickets: false }, + deps + ) + + expect(result.cliTouched).toBe(false) + expect(result.warnings).toContainEqual({ featureId: 'cli', message: unknownStatus.detail }) + expect(deps.showCliRegistrationPrompt).not.toHaveBeenCalled() + expect(deps.installCli).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/onboarding/onboarding-feature-setup.ts b/src/renderer/src/components/onboarding/onboarding-feature-setup.ts index c16632d874b..f92a5e85f63 100644 --- a/src/renderer/src/components/onboarding/onboarding-feature-setup.ts +++ b/src/renderer/src/components/onboarding/onboarding-feature-setup.ts @@ -225,7 +225,13 @@ export async function runOnboardingFeatureSetup( featureId: 'cli', message: status.detail ?? 'Orca CLI registration is not available on this platform.' }) - } else if (status.state !== 'installed' || !status.pathConfigured) { + } else if (status.pathConfigured === null) { + // Why: an unknown registry read cannot safely drive a PATH read-modify-write. + warnings.push({ + featureId: 'cli', + message: status.detail ?? 'Orca could not check your Windows user PATH.' + }) + } else if (status.state !== 'installed' || status.pathConfigured === false) { await deps.showCliRegistrationPrompt?.() const next = await deps.installCli() cliTouched = true @@ -234,7 +240,7 @@ export async function runOnboardingFeatureSetup( featureId: 'cli', message: next.detail ?? 'Orca CLI registration needs attention.' }) - } else if (!next.pathConfigured && next.detail) { + } else if (next.pathConfigured !== true && next.detail) { warnings.push({ featureId: 'cli', message: next.detail }) } } diff --git a/src/renderer/src/components/settings/BrowserUsePane.tsx b/src/renderer/src/components/settings/BrowserUsePane.tsx index 8f2dea14ee4..9871615acdf 100644 --- a/src/renderer/src/components/settings/BrowserUsePane.tsx +++ b/src/renderer/src/components/settings/BrowserUsePane.tsx @@ -128,7 +128,8 @@ export function BrowserUseSetup({ const cookiesImported = !!defaultProfile?.source const cliEnabled = isOrcaCliAvailableOnPath(cliStatus) - const cliPathNeedsAttention = cliStatus?.state === 'installed' && !cliStatus.pathConfigured + const cliPathNeedsAttention = + cliStatus?.state === 'installed' && cliStatus.pathConfigured === false const cliSupported = cliStatus?.supported ?? false const { diff --git a/src/renderer/src/components/settings/CliSection.test.tsx b/src/renderer/src/components/settings/CliSection.test.tsx index 2c34b299d8b..7db93106fe4 100644 --- a/src/renderer/src/components/settings/CliSection.test.tsx +++ b/src/renderer/src/components/settings/CliSection.test.tsx @@ -1,5 +1,8 @@ +// @vitest-environment happy-dom + import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' import { getDefaultSettings } from '../../../../shared/constants' import { CliSection } from './CliSection' @@ -12,6 +15,9 @@ const capturedPanel = vi.hoisted(() => ({ }, useInstalledAgentSkill: vi.fn() })) +const toastError = vi.hoisted(() => vi.fn()) + +vi.mock('sonner', () => ({ toast: { error: toastError, success: vi.fn() } })) vi.mock('@/hooks/useInstalledAgentSkills', () => ({ GLOBAL_AGENT_SKILL_SOURCE_KINDS: ['global'], @@ -25,6 +31,12 @@ capturedPanel.useInstalledAgentSkill.mockReturnValue({ refresh: vi.fn() }) +afterEach(() => { + cleanup() + toastError.mockReset() + vi.unstubAllGlobals() +}) + vi.mock('./AgentSkillSetupPanel', () => ({ AgentSkillSetupPanel: function AgentSkillSetupPanel(props: { command: string @@ -98,4 +110,40 @@ describe('CliSection project runtime defaults', () => { expect(getWslInstallStatus).toHaveBeenCalledWith({ distro: 'Ubuntu' }) expect(getWslInstallStatus).toHaveBeenCalledTimes(2) }) + + it('renders an inline unknown PATH state without offering a mutation', async () => { + const getInstallStatus = vi.fn().mockResolvedValue({ + platform: 'win32', + commandName: 'orca', + commandPath: 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe', + pathDirectory: 'C:\\Program Files\\Orca\\resources\\bin', + pathConfigured: null, + launcherPath: 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe', + installMethod: 'wrapper', + supported: true, + state: 'installed', + currentTarget: 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe', + unsupportedReason: null, + detail: 'Orca could not read the Windows user PATH registry value.' + }) + Object.assign(window, { + api: { + cli: { + getInstallStatus, + getWslInstallStatus: vi.fn(), + install: vi.fn(), + remove: vi.fn() + }, + shell: { openPath: vi.fn() } + } + }) + + render() + + expect(await screen.findByText(/could not read the Windows user PATH/i)).toBeDefined() + const registrationSwitch = screen.getByRole('switch') as HTMLButtonElement + expect(registrationSwitch.disabled).toBe(true) + expect(registrationSwitch.getAttribute('aria-checked')).toBe('false') + expect(toastError).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index eae2ce2a76b..5f61d1e8ef3 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -155,7 +155,8 @@ export function CliSection({ void refreshStatus() }, [refreshStatus]) - const isEnabled = status?.state === 'installed' + const pathStatusUnknown = currentPlatform === 'win32' && status?.pathConfigured === null + const isEnabled = status?.state === 'installed' && !pathStatusUnknown const isSupported = status?.supported ?? false const isBrowserManaged = status?.unsupportedReason === 'launch_mode_unavailable' const revealLabel = getRevealLabel(currentPlatform) @@ -251,7 +252,9 @@ export function CliSection({ -

+

{loading ? translate( 'auto.components.settings.CliSection.d363e5929b', @@ -286,11 +289,11 @@ export function CliSection({ + ) +})) + +// Why: fall back to English defaults so this test doesn't depend on locale files +// being loaded; the bug is missing JSX whitespace around those fragments. +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +function decodeHtml(html: string): string { + return html + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') +} + +describe('OrcaYamlTrustDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.state.activeModal = 'confirm-orca-yaml-hooks' + mocks.state.modalData = { + repoId: 'repo-1', + repoName: 'orca', + scriptKind: 'setup', + scriptContent: 'node config/scripts/run-internal-dev-setup.mjs\npnpm install', + contentHash: 'hash-1', + previouslyApproved: false + } + }) + + it('keeps spaces around orca.yaml and the repo name in the first-run copy', async () => { + const { default: OrcaYamlTrustDialog } = await import('./OrcaYamlTrustDialog') + const text = decodeHtml(renderToStaticMarkup()).replace(/<[^>]+>/g, '') + + expect(text).toContain("This repository's orca.yaml runs on your machine") + expect(text).toContain('Only run if you trust orca.') + expect(text).toContain('Always trust orca.yaml in orca') + expect(text).not.toContain("repository'sorca.yaml") + expect(text).not.toContain('trustorca') + expect(text).not.toContain('trustorca.yaml') + expect(text).not.toContain('inorca') + }) + + it('keeps spaces around orca.yaml when the script changed since last approval', async () => { + mocks.state.modalData = { + ...mocks.state.modalData, + previouslyApproved: true + } + const { default: OrcaYamlTrustDialog } = await import('./OrcaYamlTrustDialog') + const text = decodeHtml(renderToStaticMarkup()).replace(/<[^>]+>/g, '') + + expect(text).toContain('orca.yaml changed since you last approved') + expect(text).toContain('Always trust orca.yaml in orca') + expect(text).not.toContain('Always trustorca.yaml') + expect(text).not.toContain('inorca') + }) +}) diff --git a/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx b/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx index 63489c8b282..60ea9bcf90f 100644 --- a/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx +++ b/src/renderer/src/components/sidebar/OrcaYamlTrustDialog.tsx @@ -136,7 +136,7 @@ const OrcaYamlTrustDialog = React.memo(function OrcaYamlTrustDialog() { {translate( 'auto.components.sidebar.OrcaYamlTrustDialog.aa3ffb33fb', "This repository's" - )} + )}{' '} {translate('auto.components.sidebar.OrcaYamlTrustDialog.79afc6772b', 'orca.yaml')} {' '} @@ -148,7 +148,7 @@ const OrcaYamlTrustDialog = React.memo(function OrcaYamlTrustDialog() { {translate( 'auto.components.sidebar.OrcaYamlTrustDialog.bf800b7e04', '. Only run if you trust' - )} + )}{' '} {repoName}. )} @@ -190,12 +190,11 @@ const OrcaYamlTrustDialog = React.memo(function OrcaYamlTrustDialog() { onChange={(event) => setAlwaysTrust(event.target.checked)} /> - {translate('auto.components.sidebar.OrcaYamlTrustDialog.531689199b', 'Always trust')} + {translate('auto.components.sidebar.OrcaYamlTrustDialog.531689199b', 'Always trust')}{' '} {translate('auto.components.sidebar.OrcaYamlTrustDialog.79afc6772b', 'orca.yaml')} {' '} - {translate('auto.components.sidebar.OrcaYamlTrustDialog.c494b3ccb1', 'in')} - {repoName} + {translate('auto.components.sidebar.OrcaYamlTrustDialog.c494b3ccb1', 'in')} {repoName} diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 1565076a578..64f47a9c258 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3903,9 +3903,9 @@ "c494b3ccb1": "en", "79afc6772b": "orca.yaml", "531689199b": "Confiar siempre", - "bf800b7e04": ". Ejecútalo solo si confías en ", + "bf800b7e04": ". Ejecútalo solo si confías en", "831f2cd9f0": "se ejecuta en tu máquina", - "aa3ffb33fb": "El archivo ", + "aa3ffb33fb": "El archivo", "c55beddbf8": "cambió desde la última vez que lo aprobaste. Revísalo de nuevo antes de que se ejecute", "95bf974a1a": "Script {{value0}}", "9e52effffd": "Nuevo script {{value0}}", From 0d97653e3928f511cc28cb7a446c1985126e11fd Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:24:24 -0700 Subject: [PATCH 16/45] fix(ui): restore missing spaces in i18n JSX fragment copy (#9513) --- .../src/components/GitHubItemDialog.tsx | 5 +- .../src/components/GitLabItemDialog.tsx | 4 +- .../src/components/PullRequestPage.tsx | 5 +- src/renderer/src/components/TaskPage.tsx | 2 +- src/renderer/src/components/UpdateCard.tsx | 3 +- .../automations/AutomationsPage.tsx | 2 +- .../ExternalAutomationRunTable.tsx | 2 +- .../ContextualTourProgressDots.tsx | 2 +- .../components/editor/CombinedDiffViewer.tsx | 2 +- .../components/editor/ConflictComponents.tsx | 6 +- .../src/components/editor/IpynbViewer.tsx | 2 +- .../src/components/editor/MermaidBlock.tsx | 3 +- .../editor/RichMarkdownDocLinkMenu.tsx | 4 +- .../ReviewNotesAnimatedVisual.tsx | 2 +- .../ReviewPRViewAnimatedVisual.tsx | 2 +- .../feature-wall/ReviewShipAnimatedVisual.tsx | 2 +- .../feature-wall/review-notes-diff-rows.tsx | 2 +- .../components/github/PRFilterSections.tsx | 3 +- .../github/github-rate-limit-display.tsx | 4 +- .../gitlab/gitlab-rate-limit-display.tsx | 2 +- .../src/components/linear-priority-icon.tsx | 9 +- .../right-sidebar/FileExplorerTreeStatus.tsx | 2 +- .../SourceControlAgentActionDialogForm.tsx | 2 +- .../right-sidebar/checks-panel-content.tsx | 4 +- .../src/components/settings/AccountsPane.tsx | 4 +- .../AutoRenameBranchFromWorkSetting.tsx | 8 +- .../src/components/settings/BaseRefPicker.tsx | 4 +- .../settings/BrowserSessionCookiesSection.tsx | 2 +- .../src/components/settings/CliSection.tsx | 2 +- .../settings/CommitMessageAiPane.tsx | 2 +- .../settings/GeneralEditorSettingsSection.tsx | 2 +- .../settings/GeneralUpdateSettingsSection.tsx | 4 +- .../src/components/settings/GitPane.tsx | 9 +- .../settings/ManageSessionKillDialog.tsx | 2 +- .../settings/NotificationSoundSection.tsx | 2 +- .../settings/RepositoryHooksSection.tsx | 6 +- .../components/settings/RepositoryPane.tsx | 2 +- ...orySourceControlAiHostedReviewDefaults.tsx | 2 +- .../settings/RuntimeEnvironmentsPane.tsx | 2 +- .../settings/SshPassphraseDialog.tsx | 4 +- .../settings/WslCliRegistration.tsx | 2 +- .../sidebar/DeleteWorktreeWarningPanels.tsx | 4 +- .../sidebar/SetupScriptPromptCardViews.tsx | 6 +- .../src/components/stats/ShareUsageCard.tsx | 2 +- .../components/stats/UsageOverviewPane.tsx | 2 +- .../src/lib/i18n-jsx-spacing-guard.test.ts | 140 ++++++++++++++++++ 46 files changed, 212 insertions(+), 77 deletions(-) create mode 100644 src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 6f6b7fb2424..5d98a84f0f1 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -4935,13 +4935,12 @@ function ChecksTab({ {startedAt && ( - {translate('auto.components.GitHubItemDialog.4812814bc8', 'Started')} - {startedAt} + {translate('auto.components.GitHubItemDialog.4812814bc8', 'Started')} {startedAt} )} {completedAt && ( - {translate('auto.components.GitHubItemDialog.0f478f5efa', 'Completed')} + {translate('auto.components.GitHubItemDialog.0f478f5efa', 'Completed')}{' '} {completedAt} )} diff --git a/src/renderer/src/components/GitLabItemDialog.tsx b/src/renderer/src/components/GitLabItemDialog.tsx index b71386b4c34..827d86ad2d8 100644 --- a/src/renderer/src/components/GitLabItemDialog.tsx +++ b/src/renderer/src/components/GitLabItemDialog.tsx @@ -1034,7 +1034,7 @@ export default function GitLabItemDialog({ {item.author ? ( - {translate('auto.components.GitLabItemDialog.9bfb4a24d7', 'by')} + {translate('auto.components.GitLabItemDialog.9bfb4a24d7', 'by')}{' '} {item.author} ) : null} @@ -1521,7 +1521,7 @@ export default function GitLabItemDialog({ {translate( 'auto.components.GitLabItemDialog.a7eb4f4916', 'from' - )} + )}{' '} {file.oldPath}

) : null} diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index b53ba354618..8cd3f872974 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -5030,13 +5030,12 @@ function ChecksTab({ {startedAt && ( - {translate('auto.components.PullRequestPage.76551b1161', 'Started')} - {startedAt} + {translate('auto.components.PullRequestPage.76551b1161', 'Started')} {startedAt} )} {completedAt && ( - {translate('auto.components.PullRequestPage.000f90afcf', 'Completed')} + {translate('auto.components.PullRequestPage.000f90afcf', 'Completed')}{' '} {completedAt} )} diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 51bbc201879..727c0a44970 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -9323,7 +9323,7 @@ export default function TaskPage(): React.JSX.Element { // Why: per-repo partial-failure signal — distinct from a hard // IPC reject (tasksError). The two are mutually exclusive.
- {failedCount} {translate('auto.components.TaskPage.7762f4b03a', 'of')} + {failedCount} {translate('auto.components.TaskPage.7762f4b03a', 'of')}{' '} {selectedRepos.length}{' '} {translate('auto.components.TaskPage.d1766fd62d', 'projects failed to load')}
diff --git a/src/renderer/src/components/UpdateCard.tsx b/src/renderer/src/components/UpdateCard.tsx index 9010a740e10..c080b518f64 100644 --- a/src/renderer/src/components/UpdateCard.tsx +++ b/src/renderer/src/components/UpdateCard.tsx @@ -887,8 +887,7 @@ function DownloadingContent({
{release ? (

- {translate('auto.components.UpdateCard.f58b5c57a6', 'New:')} - {release.title} + {translate('auto.components.UpdateCard.f58b5c57a6', 'New:')} {release.title}

) : (

diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 98a78ef9845..538cec979e3 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -2877,7 +2877,7 @@ export default function AutomationsPage(): React.JSX.Element { )} - {translate('auto.components.automations.AutomationsPage.0e110a3469', 'Runs')} + {translate('auto.components.automations.AutomationsPage.0e110a3469', 'Runs')}{' '} {selectedRuns.length} diff --git a/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx b/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx index aa8ff9666c7..f6e5f693371 100644 --- a/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx @@ -288,7 +288,7 @@ export function ExternalAutomationRunTable({ {pageStart}-{pageEnd}{' '} - {translate('auto.components.automations.ExternalAutomationRunTable.7475c0ce96', 'of')} + {translate('auto.components.automations.ExternalAutomationRunTable.7475c0ce96', 'of')}{' '} {totalCount}

diff --git a/src/renderer/src/components/contextual-tours/ContextualTourProgressDots.tsx b/src/renderer/src/components/contextual-tours/ContextualTourProgressDots.tsx index b52cbb6fdf4..bb7fde46fe1 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourProgressDots.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourProgressDots.tsx @@ -46,7 +46,7 @@ export function ContextualTourProgressDots({ {current}{' '} - {translate('auto.components.contextual.tours.ContextualTourProgressDots.7734cb8ad3', 'of')} + {translate('auto.components.contextual.tours.ContextualTourProgressDots.7734cb8ad3', 'of')}{' '} {total}
diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index 9ae116de477..7622980a231 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -2022,7 +2022,7 @@ export default function CombinedDiffViewer({ {translate('auto.components.editor.CombinedDiffViewer.948a5fd6c8', 'Clear Notes')} - {translate('auto.components.editor.CombinedDiffViewer.84898c548d', 'Clear')} + {translate('auto.components.editor.CombinedDiffViewer.84898c548d', 'Clear')}{' '} {diffCommentCount}{' '} {diffCommentCount === 1 ? translate('auto.components.editor.CombinedDiffViewer.8ab3248fd8', 'note') diff --git a/src/renderer/src/components/editor/ConflictComponents.tsx b/src/renderer/src/components/editor/ConflictComponents.tsx index 47c74ae88db..32159e23f00 100644 --- a/src/renderer/src/components/editor/ConflictComponents.tsx +++ b/src/renderer/src/components/editor/ConflictComponents.tsx @@ -101,7 +101,7 @@ export function ConflictBanner({ )} {label}{' '} - {translate('auto.components.editor.ConflictComponents.55d61a0ccd', 'conflict ·')} + {translate('auto.components.editor.ConflictComponents.55d61a0ccd', 'conflict ·')}{' '} {CONFLICT_KIND_LABELS[conflict.conflictKind]} {conflictNavigation && conflictNavigation.total > 0 && ( @@ -171,7 +171,7 @@ export function ConflictBanner({ )} {entry?.oldPath && (
- {translate('auto.components.editor.ConflictComponents.d5edd81755', 'Renamed from')} + {translate('auto.components.editor.ConflictComponents.d5edd81755', 'Renamed from')}{' '} {entry.oldPath}
)} @@ -334,7 +334,7 @@ export function ConflictReviewPanel({ {translate( 'auto.components.editor.ConflictComponents.a1ce36f77d', 'Snapshot captured at' - )} + )}{' '} {snapshotTime}.
diff --git a/src/renderer/src/components/editor/IpynbViewer.tsx b/src/renderer/src/components/editor/IpynbViewer.tsx index fc19ca04175..ecb20f2ff63 100644 --- a/src/renderer/src/components/editor/IpynbViewer.tsx +++ b/src/renderer/src/components/editor/IpynbViewer.tsx @@ -899,7 +899,7 @@ export default function IpynbViewer({ {translate('auto.components.editor.IpynbViewer.329764e9fc', 'BETA')} - {translate('auto.components.editor.IpynbViewer.8c3b21369a', 'nbformat')} + {translate('auto.components.editor.IpynbViewer.8c3b21369a', 'nbformat')}{' '} {notebook.nbformat}
diff --git a/src/renderer/src/components/editor/MermaidBlock.tsx b/src/renderer/src/components/editor/MermaidBlock.tsx index 250a0c32161..f25de3983ff 100644 --- a/src/renderer/src/components/editor/MermaidBlock.tsx +++ b/src/renderer/src/components/editor/MermaidBlock.tsx @@ -103,8 +103,7 @@ export default function MermaidBlock({ return (
- {translate('auto.components.editor.MermaidBlock.dcc132e691', 'Diagram error:')} - {error} + {translate('auto.components.editor.MermaidBlock.dcc132e691', 'Diagram error:')} {error}
           {content}
diff --git a/src/renderer/src/components/editor/RichMarkdownDocLinkMenu.tsx b/src/renderer/src/components/editor/RichMarkdownDocLinkMenu.tsx
index d339f1e3da6..dafc70c658b 100644
--- a/src/renderer/src/components/editor/RichMarkdownDocLinkMenu.tsx
+++ b/src/renderer/src/components/editor/RichMarkdownDocLinkMenu.tsx
@@ -68,9 +68,9 @@ export function RichMarkdownDocLinkMenu({
       )}
       {overflow ? (
         
- {translate('auto.components.editor.RichMarkdownDocLinkMenu.2aaf7d9678', 'Showing')} + {translate('auto.components.editor.RichMarkdownDocLinkMenu.2aaf7d9678', 'Showing')}{' '} {rows.length}{' '} - {translate('auto.components.editor.RichMarkdownDocLinkMenu.90c5f0e1e4', 'of')} + {translate('auto.components.editor.RichMarkdownDocLinkMenu.90c5f0e1e4', 'of')}{' '} {totalMatches}
) : null} diff --git a/src/renderer/src/components/feature-wall/ReviewNotesAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/ReviewNotesAnimatedVisual.tsx index 24c8399761e..ba9684ba708 100644 --- a/src/renderer/src/components/feature-wall/ReviewNotesAnimatedVisual.tsx +++ b/src/renderer/src/components/feature-wall/ReviewNotesAnimatedVisual.tsx @@ -381,7 +381,7 @@ export function ReviewNotesAnimatedVisual(props: { reducedMotion: boolean }): JS
- {translate('auto.components.feature.wall.ReviewNotesAnimatedVisual.a7a89d8f94', 'Line')} + {translate('auto.components.feature.wall.ReviewNotesAnimatedVisual.a7a89d8f94', 'Line')}{' '} ?
diff --git a/src/renderer/src/components/feature-wall/ReviewPRViewAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/ReviewPRViewAnimatedVisual.tsx index be3148a0e7c..461da2f21c6 100644 --- a/src/renderer/src/components/feature-wall/ReviewPRViewAnimatedVisual.tsx +++ b/src/renderer/src/components/feature-wall/ReviewPRViewAnimatedVisual.tsx @@ -272,7 +272,7 @@ export function ReviewPRViewAnimatedVisual(props: { reducedMotion: boolean }): J {translate( 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.6f4c2d7cb7', 'Add a coverage case for' - )} + )}{' '} {translate( 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.c2062da7ec', diff --git a/src/renderer/src/components/feature-wall/ReviewShipAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/ReviewShipAnimatedVisual.tsx index 0b2ef880ffa..548ac8334f2 100644 --- a/src/renderer/src/components/feature-wall/ReviewShipAnimatedVisual.tsx +++ b/src/renderer/src/components/feature-wall/ReviewShipAnimatedVisual.tsx @@ -303,7 +303,7 @@ export function ReviewShipAnimatedVisual(props: { reducedMotion: boolean }): JSX {translate( 'auto.components.feature.wall.ReviewShipAnimatedVisual.e725000cd7', 'Changes' - )} + )}{' '} {SHIP_FILES.length} diff --git a/src/renderer/src/components/feature-wall/review-notes-diff-rows.tsx b/src/renderer/src/components/feature-wall/review-notes-diff-rows.tsx index c5c476a39d2..cbba8da521c 100644 --- a/src/renderer/src/components/feature-wall/review-notes-diff-rows.tsx +++ b/src/renderer/src/components/feature-wall/review-notes-diff-rows.tsx @@ -85,7 +85,7 @@ export function ReviewDiffRows(): JSX.Element { {translate( 'auto.components.feature.wall.review.notes.diff.rows.f621c734f8', 'Note · line' - )} + )}{' '} ?
diff --git a/src/renderer/src/components/github/PRFilterSections.tsx b/src/renderer/src/components/github/PRFilterSections.tsx index 59fc7634120..6f65eee8e55 100644 --- a/src/renderer/src/components/github/PRFilterSections.tsx +++ b/src/renderer/src/components/github/PRFilterSections.tsx @@ -208,8 +208,7 @@ export function SectionMenu({ return (
- {translate('auto.components.github.PRFilterSections.8177eda37e', 'Filter')} - {subject} + {translate('auto.components.github.PRFilterSections.8177eda37e', 'Filter')} {subject}
{rows.map((row) => (
diff --git a/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx b/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx index 6bc87d967e8..a85c566b0d6 100644 --- a/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx +++ b/src/renderer/src/components/gitlab/gitlab-rate-limit-display.tsx @@ -133,7 +133,7 @@ function GitLabRateLimitRows({ )} > {rest.remaining}{' '} - {translate('auto.components.gitlab.gitlab.rate.limit.display.ea8ad0bae8', 'of')} + {translate('auto.components.gitlab.gitlab.rate.limit.display.ea8ad0bae8', 'of')}{' '} {rest.limit}{' '} {translate( 'auto.components.gitlab.gitlab.rate.limit.display.3e2c982cfa', diff --git a/src/renderer/src/components/linear-priority-icon.tsx b/src/renderer/src/components/linear-priority-icon.tsx index ae9ebbb16a8..92f8bfaa210 100644 --- a/src/renderer/src/components/linear-priority-icon.tsx +++ b/src/renderer/src/components/linear-priority-icon.tsx @@ -54,8 +54,7 @@ export function LinearPriorityIcon({ > - {translate('auto.components.linear.priority.icon.c43d3e065b', 'Priority:')} - {label} + {translate('auto.components.linear.priority.icon.c43d3e065b', 'Priority:')} {label} ) @@ -72,8 +71,7 @@ export function LinearPriorityIcon({ className="size-3 rounded-full border border-muted-foreground/55" /> - {translate('auto.components.linear.priority.icon.c43d3e065b', 'Priority:')} - {label} + {translate('auto.components.linear.priority.icon.c43d3e065b', 'Priority:')} {label} ) @@ -110,8 +108,7 @@ export function LinearPriorityIcon({ })} - {translate('auto.components.linear.priority.icon.c43d3e065b', 'Priority:')} - {label} + {translate('auto.components.linear.priority.icon.c43d3e065b', 'Priority:')} {label} ) diff --git a/src/renderer/src/components/right-sidebar/FileExplorerTreeStatus.tsx b/src/renderer/src/components/right-sidebar/FileExplorerTreeStatus.tsx index 7f8d3824151..58fa294e5da 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerTreeStatus.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerTreeStatus.tsx @@ -29,7 +29,7 @@ export function FileExplorerTreeStatus({ {translate( 'auto.components.right.sidebar.FileExplorerTreeStatus.c76693e456', 'Could not load files for this workspace:' - )} + )}{' '} {error}
) diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx index 4f2caf0133b..8f9fff3e657 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialogForm.tsx @@ -378,7 +378,7 @@ export function SourceControlAgentActionDialogForm({ {translate( 'auto.components.right.sidebar.SourceControlAgentActionDialogForm.1bc0bdbb5e', 'Launch:' - )} + )}{' '} {deliveryPlan.commandLabel}
{deliveryPlan.caveat}
diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx index 84798c944f8..0dd54d7b7dd 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -735,7 +735,7 @@ function CheckRunDetails({ {translate( 'auto.components.right.sidebar.checks.panel.content.fd46a70f1a', 'Started' - )} + )}{' '} {startedAt} )} @@ -744,7 +744,7 @@ function CheckRunDetails({ {translate( 'auto.components.right.sidebar.checks.panel.content.00e1c1658a', 'Completed' - )} + )}{' '} {completedAt} )} diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 5268a890e36..4735f0b06c5 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -1501,14 +1501,14 @@ export function AccountsPane({ {translate( 'auto.components.settings.AccountsPane.0023cc336e', 'Paste either the raw token value (e.g.' - )} + )}{' '} {translate('auto.components.settings.AccountsPane.922b51e02d', 'Fe26.2**…')} {translate( 'auto.components.settings.AccountsPane.338820326a', ') or the full cookie header (e.g.' - )} + )}{' '} {translate('auto.components.settings.AccountsPane.8951c5309f', 'auth=Fe26.2**…')} diff --git a/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx b/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx index b8540cdefa9..c4d520cbdb7 100644 --- a/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx +++ b/src/renderer/src/components/settings/AutoRenameBranchFromWorkSetting.tsx @@ -159,7 +159,7 @@ export function AutoRenameBranchFromWorkSetting({ {translate( 'auto.components.settings.AutoRenameBranchFromWorkSetting.12ea4a408d', 'When an agent starts working in a new workspace, Orca renames its auto-generated branch (e.g.' - )} + )}{' '} {translate( 'auto.components.settings.AutoRenameBranchFromWorkSetting.1626524572', @@ -223,7 +223,7 @@ export function AutoRenameBranchFromWorkSetting({ {translate( 'auto.components.settings.AutoRenameBranchFromWorkSetting.9241b59bf5', 'Use' - )} + )}{' '} {translate( 'auto.components.settings.AutoRenameBranchFromWorkSetting.c71770c455', @@ -261,7 +261,7 @@ export function AutoRenameBranchFromWorkSetting({ {translate( 'auto.components.settings.AutoRenameBranchFromWorkSetting.56580dcf60', '. You can also reference' - )} + )}{' '} {translate( 'auto.components.settings.AutoRenameBranchFromWorkSetting.2ee2779c05', @@ -281,7 +281,7 @@ export function AutoRenameBranchFromWorkSetting({ {translate( 'auto.components.settings.AutoRenameBranchFromWorkSetting.5d569f5199', '. Orca generates only the final segment, like' - )} + )}{' '} {translate( 'auto.components.settings.AutoRenameBranchFromWorkSetting.800edb1e54', diff --git a/src/renderer/src/components/settings/BaseRefPicker.tsx b/src/renderer/src/components/settings/BaseRefPicker.tsx index b0c0b6e72ef..8bb96a508cc 100644 --- a/src/renderer/src/components/settings/BaseRefPicker.tsx +++ b/src/renderer/src/components/settings/BaseRefPicker.tsx @@ -195,14 +195,14 @@ export function BaseRefPicker({ {translate( 'auto.components.settings.BaseRefPicker.a5c16712c1', 'Multiple remotes detected. Type a remote name (e.g.' - )} + )}{' '} {translate('auto.components.settings.BaseRefPicker.915ad97875', 'upstream')} {translate( 'auto.components.settings.BaseRefPicker.80f7c82303', ') or a full ref (e.g.' - )} + )}{' '} {translate('auto.components.settings.BaseRefPicker.b468f46726', 'upstream/main')} diff --git a/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx b/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx index f1c39ca8fec..8fa1e6c9a60 100644 --- a/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx +++ b/src/renderer/src/components/settings/BrowserSessionCookiesSection.tsx @@ -72,7 +72,7 @@ export function BrowserSessionCookiesSection({ {translate( 'auto.components.settings.BrowserPane.cd47bc9622', 'Select a default profile for new browser tabs. Import cookies and switch profiles per-tab via the' - )} + )}{' '} ···{' '} {translate('auto.components.settings.BrowserPane.e4aaf8051b', 'toolbar menu.')}

diff --git a/src/renderer/src/components/settings/CliSection.tsx b/src/renderer/src/components/settings/CliSection.tsx index 5f61d1e8ef3..6f6eab2ee94 100644 --- a/src/renderer/src/components/settings/CliSection.tsx +++ b/src/renderer/src/components/settings/CliSection.tsx @@ -317,7 +317,7 @@ export function CliSection({ {translate( 'auto.components.settings.CliSection.b0c310ab46', 'Existing launcher target:' - )} + )}{' '} {status.currentTarget}

) : null} diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.tsx index 9b8ebc6d9ff..e24fc97ee40 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.tsx @@ -273,7 +273,7 @@ export function CommitMessageAiPane({ {translate( 'auto.components.settings.CommitMessageAiPane.4f722a5f53', 'Used by commit-message, pull-request, and branch-name recipes that select Custom command. Use' - )} + )}{' '} {translate('auto.components.settings.CommitMessageAiPane.b8b6fd55b4', '{prompt}')} {' '} diff --git a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx index 9cccdf166ee..2a524600b07 100644 --- a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx @@ -170,7 +170,7 @@ export function GeneralEditorSettingsSection({ {translate( 'auto.components.settings.GeneralEditorSettingsSection.8112cd6dcf', 'How long Orca waits after your last edit before saving automatically. First launch defaults to' - )} + )}{' '} {DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS}{' '} {translate('auto.components.settings.GeneralEditorSettingsSection.fc5c5306ff', 'ms.')}

diff --git a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx index 51668b1ac12..da1984cca0e 100644 --- a/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralUpdateSettingsSection.tsx @@ -161,7 +161,7 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { {translate( 'auto.components.settings.GeneralUpdateSettingsSection.a6b37929dc', 'Version' - )} + )}{' '} {updateStatus.version}{' '} {translate( 'auto.components.settings.GeneralUpdateSettingsSection.8311da27ba', @@ -199,7 +199,7 @@ export function GeneralUpdateSettingsSection(): React.JSX.Element { {translate( 'auto.components.settings.GeneralUpdateSettingsSection.a6b37929dc', 'Version' - )} + )}{' '} {updateStatus.version}{' '} {translate( 'auto.components.settings.GeneralUpdateSettingsSection.d89806cc89', diff --git a/src/renderer/src/components/settings/GitPane.tsx b/src/renderer/src/components/settings/GitPane.tsx index 6d346fb1de6..bdfff4a6967 100644 --- a/src/renderer/src/components/settings/GitPane.tsx +++ b/src/renderer/src/components/settings/GitPane.tsx @@ -236,11 +236,14 @@ export function GitPane({ {translate( 'auto.components.settings.GitPane.976afc6b3e', 'When you create a workspace, Orca refreshes the remote base and safely fast-forwards your matching local branch, such as' - )} + )}{' '} {translate('auto.components.settings.GitPane.ffba483bae', 'main')}{' '} - {translate('auto.components.settings.GitPane.5bf885be48', 'or')} + {translate('auto.components.settings.GitPane.5bf885be48', 'or')}{' '} {translate('auto.components.settings.GitPane.3ae3de8898', 'master')} - {translate('auto.components.settings.GitPane.db3a127eb1', '. This keeps commands like')} + {translate( + 'auto.components.settings.GitPane.db3a127eb1', + '. This keeps commands like' + )}{' '} {translate('auto.components.settings.GitPane.d072a12995', 'git diff main...HEAD')} {' '} diff --git a/src/renderer/src/components/settings/ManageSessionKillDialog.tsx b/src/renderer/src/components/settings/ManageSessionKillDialog.tsx index 56ddb9845ee..04c795f3748 100644 --- a/src/renderer/src/components/settings/ManageSessionKillDialog.tsx +++ b/src/renderer/src/components/settings/ManageSessionKillDialog.tsx @@ -66,7 +66,7 @@ export function ManageSessionKillDialog({ {translate( 'auto.components.settings.ManageSessionKillDialog.8401328fed', 'Force-quits' - )} + )}{' '} {session.sessionId} {translate( 'auto.components.settings.ManageSessionKillDialog.ad9832aa26', diff --git a/src/renderer/src/components/settings/NotificationSoundSection.tsx b/src/renderer/src/components/settings/NotificationSoundSection.tsx index 0d0b92279e7..da1fe4e11cc 100644 --- a/src/renderer/src/components/settings/NotificationSoundSection.tsx +++ b/src/renderer/src/components/settings/NotificationSoundSection.tsx @@ -159,7 +159,7 @@ export function NotificationSoundSection({ className="truncate font-mono text-[11px] text-muted-foreground" title={notificationSettings.customSoundPath} > - {translate('auto.components.settings.NotificationsPane.4aa5085cd7', 'Custom:')} + {translate('auto.components.settings.NotificationsPane.4aa5085cd7', 'Custom:')}{' '} {notificationSettings.customSoundPath}

) : null} diff --git a/src/renderer/src/components/settings/RepositoryHooksSection.tsx b/src/renderer/src/components/settings/RepositoryHooksSection.tsx index 1ee7cfe19f8..bec7ba52a28 100644 --- a/src/renderer/src/components/settings/RepositoryHooksSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHooksSection.tsx @@ -441,7 +441,7 @@ function ExampleTemplateCard({ return (

- {translate('auto.components.settings.RepositoryHooksSection.175daba180', 'Example')} + {translate('auto.components.settings.RepositoryHooksSection.175daba180', 'Example')}{' '} {translate('auto.components.settings.RepositoryHooksSection.39da2ae12f', 'orca.yaml')} {' '} @@ -665,7 +665,7 @@ function ScriptEditor({ - {translate('auto.components.settings.RepositoryHooksSection.b113344b6a', 'Edit')} + {translate('auto.components.settings.RepositoryHooksSection.b113344b6a', 'Edit')}{' '} {translate( 'auto.components.settings.RepositoryHooksSection.39da2ae12f', @@ -1308,7 +1308,7 @@ export function RepositoryHooksSection({ {translate( 'auto.components.settings.RepositoryHooksSection.ac9038d2cc', 'When both' - )} + )}{' '} {translate( 'auto.components.settings.RepositoryHooksSection.39da2ae12f', diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index bb178d3c17f..a90c94e5ea8 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -226,7 +226,7 @@ export function RepositoryPane({ )}

- {translate('auto.components.settings.RepositoryPane.323debba71', 'Type:')} + {translate('auto.components.settings.RepositoryPane.323debba71', 'Type:')}{' '} {getRepoKindLabel(repo)}

{isFolder ? ( diff --git a/src/renderer/src/components/settings/RepositorySourceControlAiHostedReviewDefaults.tsx b/src/renderer/src/components/settings/RepositorySourceControlAiHostedReviewDefaults.tsx index 7d620ee7ace..07156b38388 100644 --- a/src/renderer/src/components/settings/RepositorySourceControlAiHostedReviewDefaults.tsx +++ b/src/renderer/src/components/settings/RepositorySourceControlAiHostedReviewDefaults.tsx @@ -82,7 +82,7 @@ export function RepositorySourceControlAiHostedReviewDefaults({ {translate( 'auto.components.settings.RepositorySourceControlAiHostedReviewDefaults.a68849a859', 'Global default is' - )} + )}{' '} {inherited}. diff --git a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx index 116dc02c5be..af3e2be3ee0 100644 --- a/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx +++ b/src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx @@ -813,7 +813,7 @@ export function RuntimeEnvironmentsPane({ className="h-8 min-w-0 font-mono text-xs" />

- {translate('auto.components.settings.RuntimeEnvironmentsPane.163671f7b5', 'Run')} + {translate('auto.components.settings.RuntimeEnvironmentsPane.163671f7b5', 'Run')}{' '} {translate( 'auto.components.settings.RuntimeEnvironmentsPane.960e901ae4', diff --git a/src/renderer/src/components/settings/SshPassphraseDialog.tsx b/src/renderer/src/components/settings/SshPassphraseDialog.tsx index 7cfb4dbd0b8..56fd3c3d374 100644 --- a/src/renderer/src/components/settings/SshPassphraseDialog.tsx +++ b/src/renderer/src/components/settings/SshPassphraseDialog.tsx @@ -133,7 +133,7 @@ export function SshPassphraseDialog(): React.JSX.Element | null { {translate( 'auto.components.settings.SshPassphraseDialog.dbf9b6f2d0', 'Enter the password for' - )} + )}{' '} {label} ) : ( @@ -141,7 +141,7 @@ export function SshPassphraseDialog(): React.JSX.Element | null { {translate( 'auto.components.settings.SshPassphraseDialog.ce4fdf7914', 'Enter the passphrase for' - )} + )}{' '} {label} )} diff --git a/src/renderer/src/components/settings/WslCliRegistration.tsx b/src/renderer/src/components/settings/WslCliRegistration.tsx index 9d23dbeca93..7d01d96d58b 100644 --- a/src/renderer/src/components/settings/WslCliRegistration.tsx +++ b/src/renderer/src/components/settings/WslCliRegistration.tsx @@ -217,7 +217,7 @@ export function WslCliRegistration({ {translate( 'auto.components.settings.WslCliRegistration.1dbb0377d9', 'Existing launcher target:' - )} + )}{' '} {status.currentTarget}

) : null} diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeWarningPanels.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeWarningPanels.tsx index 89b22f6c841..b143148249c 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeWarningPanels.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeWarningPanels.tsx @@ -21,7 +21,7 @@ export function DeleteWorktreeWarningPanels({ {translate( 'auto.components.sidebar.DeleteWorktreeWarningPanels.e3be9eba15', 'This is the' - )} + )}{' '} {translate( 'auto.components.sidebar.DeleteWorktreeWarningPanels.c4f96a6e18', @@ -32,7 +32,7 @@ export function DeleteWorktreeWarningPanels({ 'auto.components.sidebar.DeleteWorktreeWarningPanels.026738155a', '(the original clone directory).' )} - {mainWorktreeBlocker} + {mainWorktreeBlocker ? <> {mainWorktreeBlocker} : null}
diff --git a/src/renderer/src/components/sidebar/SetupScriptPromptCardViews.tsx b/src/renderer/src/components/sidebar/SetupScriptPromptCardViews.tsx index 45f2947d3ed..25a960bff8c 100644 --- a/src/renderer/src/components/sidebar/SetupScriptPromptCardViews.tsx +++ b/src/renderer/src/components/sidebar/SetupScriptPromptCardViews.tsx @@ -71,7 +71,7 @@ export function DetectedSetupPreview({ {translate( 'auto.components.sidebar.SetupScriptPromptCardViews.d02e6a42b1', 'Detected from' - )} + )}{' '} {provenance}

) : null} @@ -157,7 +157,7 @@ export function SetupScriptPromptBody({ {translate( 'auto.components.sidebar.SetupScriptPromptCardViews.bb879db364', 'This repo ignores shared' - )} + )}{' '} {translate('auto.components.sidebar.SetupScriptPromptCardViews.8f6be51aa1', 'orca.yaml')} {' '} @@ -184,7 +184,7 @@ export function SetupScriptPromptBody({ {translate( 'auto.components.sidebar.SetupScriptPromptCardViews.b56d1322f7', 'Found a setup command in' - )} + )}{' '} {candidateSource} {translate( 'auto.components.sidebar.SetupScriptPromptCardViews.8349e3fa4c', diff --git a/src/renderer/src/components/stats/ShareUsageCard.tsx b/src/renderer/src/components/stats/ShareUsageCard.tsx index cb6eb5c92bf..dcd2f673cb2 100644 --- a/src/renderer/src/components/stats/ShareUsageCard.tsx +++ b/src/renderer/src/components/stats/ShareUsageCard.tsx @@ -233,7 +233,7 @@ function ChartHeader(props: {
{props.sessions}{' '} - {translate('auto.components.stats.ShareUsageCard.4a4c6c79a3', 'sessions ·')} + {translate('auto.components.stats.ShareUsageCard.4a4c6c79a3', 'sessions ·')}{' '} {props.turnsOrEvents.count} {props.turnsOrEvents.label}
diff --git a/src/renderer/src/components/stats/UsageOverviewPane.tsx b/src/renderer/src/components/stats/UsageOverviewPane.tsx index 1f2080c995d..03001c2bec9 100644 --- a/src/renderer/src/components/stats/UsageOverviewPane.tsx +++ b/src/renderer/src/components/stats/UsageOverviewPane.tsx @@ -253,7 +253,7 @@ export function UsageOverviewPane(): React.JSX.Element {

{overview.enabledProviderCount}{' '} - {translate('auto.components.stats.UsageOverviewPane.ecb0cd8a4c', 'enabled -')} + {translate('auto.components.stats.UsageOverviewPane.ecb0cd8a4c', 'enabled -')}{' '} {overview.dataProviderCount}{' '} {translate('auto.components.stats.UsageOverviewPane.444585cb41', 'with data')}

diff --git a/src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts b/src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts new file mode 100644 index 00000000000..f2dde24571e --- /dev/null +++ b/src/renderer/src/lib/i18n-jsx-spacing-guard.test.ts @@ -0,0 +1,140 @@ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Guard against the i18n fragment glue-word class of bugs: + * `translate('…', 'word')}` immediately followed by `` / `{value}` without + * an explicit `{' '}` space. Newline indentation alone is not reliable. + */ +const ROOT = path.resolve(__dirname, '..') + +type GuardCase = { + file: string + /** English fallback that must be followed by `{' '}` before the next token. */ + afterFallback: string + /** Human-readable description for assertion messages. */ + label: string +} + +const CASES: GuardCase[] = [ + { + file: 'components/sidebar/SetupScriptPromptCardViews.tsx', + afterFallback: 'This repo ignores shared', + label: 'shared orca.yaml' + }, + { + file: 'components/sidebar/SetupScriptPromptCardViews.tsx', + afterFallback: 'Detected from', + label: 'Detected from provenance' + }, + { + file: 'components/sidebar/SetupScriptPromptCardViews.tsx', + afterFallback: 'Found a setup command in', + label: 'Found a setup command in source' + }, + { + file: 'components/settings/RepositoryHooksSection.tsx', + afterFallback: 'Example', + label: 'Example orca.yaml template' + }, + { + file: 'components/settings/RepositoryHooksSection.tsx', + afterFallback: 'Edit', + label: 'Edit orca.yaml' + }, + { + file: 'components/settings/RepositoryHooksSection.tsx', + afterFallback: 'When both', + label: 'When both orca.yaml' + }, + { + file: 'components/settings/GitPane.tsx', + afterFallback: 'or', + label: 'main or master' + }, + { + file: 'components/settings/GitPane.tsx', + afterFallback: 'such as', + label: 'such as main' + }, + { + file: 'components/settings/RuntimeEnvironmentsPane.tsx', + afterFallback: 'Run', + label: 'Run orca serve' + }, + { + file: 'components/settings/AutoRenameBranchFromWorkSetting.tsx', + afterFallback: 'Use', + label: 'Use {basePrompt}' + }, + { + file: 'components/settings/CommitMessageAiPane.tsx', + afterFallback: 'Use', + label: 'Use {prompt}' + }, + { + file: 'components/editor/RichMarkdownDocLinkMenu.tsx', + afterFallback: 'Showing', + label: 'Showing N of M' + }, + { + file: 'components/editor/RichMarkdownDocLinkMenu.tsx', + afterFallback: 'of', + label: 'of totalMatches' + }, + { + file: 'components/contextual-tours/ContextualTourProgressDots.tsx', + afterFallback: 'of', + label: 'tour of total' + }, + { + file: 'components/github/PRFilterSections.tsx', + afterFallback: 'Filter', + label: 'Filter pull requests' + }, + { + file: 'components/editor/ConflictComponents.tsx', + afterFallback: 'Renamed from', + label: 'Renamed from path' + }, + { + file: 'components/settings/SshPassphraseDialog.tsx', + afterFallback: 'Enter the password for', + label: 'password for host' + }, + { + file: 'components/settings/ManageSessionKillDialog.tsx', + afterFallback: 'Force-quits', + label: 'Force-quits session id' + }, + { + file: 'components/sidebar/DeleteWorktreeWarningPanels.tsx', + afterFallback: 'This is the', + label: 'This is the main worktree' + } +] + +function hasExplicitSpaceAfterFallback(source: string, fallback: string): boolean { + // Match translate fallbacks that end with (or equal) the given phrase, then + // require an explicit {' '} after the closing )} so JSX whitespace is not + // left to indentation alone. + const escaped = fallback.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const pattern = new RegExp( + String.raw`translate\(\s*'[^']+',\s*(?:'[^']*${escaped}'|"[^"]*${escaped}")\s*(?:,\s*\{[^}]*\})?\s*\)\s*\}\s*\{\s*['"]\s['"]\s*\}`, + 'm' + ) + return pattern.test(source) +} + +describe('i18n JSX fragment spacing guard', () => { + for (const testCase of CASES) { + it(`keeps an explicit space after "${testCase.label}" in ${testCase.file}`, () => { + const source = readFileSync(path.join(ROOT, testCase.file), 'utf8') + expect( + hasExplicitSpaceAfterFallback(source, testCase.afterFallback), + `Expected {' '} immediately after translate fallback "${testCase.afterFallback}" in ${testCase.file}` + ).toBe(true) + }) + } +}) From 74e2cb702ca34f171cdef140358dbf14a49d57f6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:30:01 -0700 Subject: [PATCH 17/45] perf(window): re-enable macOS main-window background throttling (#9395) Co-authored-by: Orca --- src/main/startup/configure-process.test.ts | 14 ++++ src/main/startup/configure-process.ts | 12 ++++ src/main/window/createMainWindow.test.ts | 74 ++++++++++++++++++++++ src/main/window/createMainWindow.ts | 65 +++++++++++++++---- 4 files changed, 151 insertions(+), 14 deletions(-) diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 72a545a3988..08cd8139e93 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -297,6 +297,20 @@ describe('enableMainProcessGpuFeatures', () => { expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('enable-unsafe-webgpu') }) + it('opts hidden pages out of intensive wake-up throttling', async () => { + const { app } = await import('electron') + const { enableMainProcessGpuFeatures } = await import('./configure-process') + + delete process.env.ORCA_E2E_USER_DATA_DIR + vi.mocked(app.commandLine.appendSwitch).mockClear() + enableMainProcessGpuFeatures() + + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith( + 'disable-features', + 'IntensiveWakeUpThrottling' + ) + }) + it('raises the WebGL context budget above the 16-context Blink default', async () => { const { app } = await import('electron') const { enableMainProcessGpuFeatures } = await import('./configure-process') diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index e92c53e4b0d..e033e4e94de 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -344,4 +344,16 @@ export function enableMainProcessGpuFeatures(): void { if (features) { app.commandLine.appendSwitch('enable-features', features) } + + const existingDisabledFeatures = app.commandLine.getSwitchValue('disable-features') + // Why: with main-window background throttling on, Chromium's intensive mode + // clamps hidden-page timers to 1/min after 5 minutes, delaying agent-done and + // bell notifications by up to 60s. Keep the normal 1s hidden clamp (rAF and + // rendering still stop) but opt out of the 1/min tier. Callers skip this + // function under GPU fallback (win32-only today); if throttling ever extends + // to Windows, this opt-out must move out of the GPU-gated path. + const disabledFeatures = ['IntensiveWakeUpThrottling', existingDisabledFeatures] + .filter(Boolean) + .join(',') + app.commandLine.appendSwitch('disable-features', disabledFeatures) } diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 8899ba81bbf..3b69b9c1253 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -299,6 +299,80 @@ describe('createMainWindow', () => { } }) + it('keeps main-window background throttling enabled while repainting macOS visibility transitions', () => { + vi.useFakeTimers() + const windowHandlers = new Map void)[]>() + let windowSize: [number, number] = [1200, 800] + const webContents = { + on: vi.fn(), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + isDestroyed: vi.fn(() => false), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + const handlers = windowHandlers.get(event) ?? [] + handlers.push(handler) + windowHandlers.set(event, handlers) + }), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => false), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => windowSize), + setSize: vi.fn((width: number, height: number) => { + windowSize = [width, height] + }), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + withPlatform('darwin', () => createMainWindow(null)) + + // Why: throttling-off pins visibilityState 'visible' and renders occluded + // windows at full rate; this guards against reintroducing it. + expect(webContents.setBackgroundThrottling).not.toHaveBeenCalledWith(false) + + expect(webContents.setBackgroundThrottling).toHaveBeenCalledWith(true) + expect(windowHandlers.get('restore')).toHaveLength(1) + expect(windowHandlers.get('show')).toHaveLength(1) + expect(windowHandlers.get('focus')).toHaveLength(1) + + windowHandlers.get('show')?.[0]?.() + windowHandlers.get('restore')?.[0]?.() + + expect(webContents.invalidate).toHaveBeenCalledTimes(2) + expect(browserWindowInstance.setSize).toHaveBeenNthCalledWith(1, 1201, 800) + expect(browserWindowInstance.setSize).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(32) + expect(browserWindowInstance.setSize).toHaveBeenNthCalledWith(2, 1200, 800) + + vi.advanceTimersByTime(217) + expect(webContents.invalidate).toHaveBeenCalledTimes(2) + + vi.advanceTimersByTime(1) + expect(webContents.invalidate).toHaveBeenCalledTimes(3) + + // Why: focus covers occlusion-uncover with invalidate only — no setSize + // jiggle that would resize terminals on every window focus. + const setSizeCalls = browserWindowInstance.setSize.mock.calls.length + windowHandlers.get('focus')?.[0]?.() + expect(webContents.invalidate).toHaveBeenCalledTimes(4) + expect(browserWindowInstance.setSize).toHaveBeenCalledTimes(setSizeCalls) + }) + it('supports all minus key variants for terminal zoom out', () => { const windowHandlers: Record void> = {} const webContents = { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 52c84ec6048..b12583377b1 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -50,23 +50,65 @@ import { buildEditableContextMenuTemplate } from './editable-context-menu' import { clearTrustedUIRendererWebContentsId, setTrustedUIRendererWebContentsId } from '../ipc/ui' import { resolveWindowCloseAction } from './window-close-decision' +// Why: show/restore/resume can overlap before the size nudge resets; never +// capture the temporary width as the next repaint's baseline. +const activeRepaintJiggles = new WeakSet() + function forceRepaint(window: BrowserWindow): void { - if (window.isDestroyed()) { + // Why: webContents can be destroyed a beat before the BrowserWindow during + // close, and this runs from timers/focus events that can land in that gap. + if (window.isDestroyed() || window.webContents.isDestroyed()) { return } window.webContents.invalidate() - if (window.isMaximized() || window.isFullScreen()) { + if (window.isMaximized() || window.isFullScreen() || activeRepaintJiggles.has(window)) { return } + activeRepaintJiggles.add(window) const [width, height] = window.getSize() window.setSize(width + 1, height) setTimeout(() => { if (!window.isDestroyed()) { window.setSize(width, height) } + activeRepaintJiggles.delete(window) }, 32) } +function installMacosVisibilityRepaint(window: BrowserWindow): void { + let delayedRepaintTimer: ReturnType | null = null + const repaintAfterVisibilityTransition = (): void => { + forceRepaint(window) + if (delayedRepaintTimer) { + clearTimeout(delayedRepaintTimer) + } + // Why: macOS can finish restoring webview compositor layers after Electron's + // show/restore event, so a second paint catches late black-surface recovery. + delayedRepaintTimer = setTimeout(() => { + delayedRepaintTimer = null + forceRepaint(window) + }, 250) + } + const clearDelayedRepaint = (): void => { + if (delayedRepaintTimer) { + clearTimeout(delayedRepaintTimer) + delayedRepaintTimer = null + } + } + + window.on('restore', repaintAfterVisibilityTransition) + window.on('show', repaintAfterVisibilityTransition) + // Why: occlusion-uncover fires neither restore nor show; focus is the only + // signal. Invalidate only — the setSize jiggle would SIGWINCH every terminal + // on each Cmd+Tab. + window.on('focus', () => { + if (!window.isDestroyed() && !window.webContents.isDestroyed()) { + window.webContents.invalidate() + } + }) + window.on('closed', clearDelayedRepaint) +} + function isMacAppPasteInput(input: Electron.Input): boolean { return ( process.platform === 'darwin' && @@ -293,18 +335,13 @@ export function createMainWindow( setTrustedUIRendererWebContentsId(rendererWebContentsId) if (process.platform === 'darwin') { - // Why: persistent browser webviews use separate compositor layers, and on - // recent macOS releases those layers can fail to repaint after occlusion or - // restore. Disabling main-window throttling and forcing a repaint on - // visibility transitions hardens Orca against black-surface failures during - // browser-tab restore and tab switching. - mainWindow.webContents.setBackgroundThrottling(false) - mainWindow.on('restore', () => { - forceRepaint(mainWindow) - }) - mainWindow.on('show', () => { - forceRepaint(mainWindow) - }) + // Why: browser-guest surfaces are kept alive by their own per-guest + // unthrottle (browser-manager attach), so the main window can throttle + // normally while hidden/occluded instead of rendering at full rate. + // Toggling must happen only while visible: flipping it on a hidden window + // desyncs Chromium's frame evictor and blanks the surface (electron#42378). + mainWindow.webContents.setBackgroundThrottling(true) + installMacosVisibilityRepaint(mainWindow) } // Why: a focus-preserving system/display wake fires no window focus or From e90dc38f01670ff2da0d1eb36fc051961efca09f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:31:36 -0700 Subject: [PATCH 18/45] fix(dev): stabilize macOS safeStorage Keychain key name across dev branches (#9400) Co-authored-by: Orca --- src/main/index.ts | 7 ++++++- src/main/menu/register-app-menu.ts | 5 ++++- src/main/startup/dev-instance-identity.test.ts | 13 +++++++++++++ src/main/startup/dev-instance-identity.ts | 10 ++++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 9278eb043ec..312b8bfd9cf 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1766,7 +1766,11 @@ app.whenReady().then(async () => { } ) electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId) - app.setName(devInstanceIdentity.name) + // Why: setName drives the macOS safeStorage Keychain item name. Use the stable + // appName (not the per-branch `name`) so dev branches share one key and don't + // re-prompt per branch; the per-branch label still shows via window title, + // renderer identity, and the app-menu label passed to registerAppMenu below. + app.setName(devInstanceIdentity.appName) // Why: managed WSL launchers live outside the Windows app bundle, so keep // their launcher and bridge contract synchronized across app updates. @@ -2138,6 +2142,7 @@ app.whenReady().then(async () => { logStartupMilestone('i18n-ready') registerAppMenu({ + appMenuLabel: devInstanceIdentity.name, onCheckForUpdates: (options) => runUserInitiatedUpdateCheck(options), onBeforeReload: ({ ignoreCache, webContentsId }) => { if (mainWindow?.webContents.id === webContentsId) { diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index c36aca6973a..2f3a3d70a97 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -37,6 +37,9 @@ type RegisterAppMenuOptions = { onToggleAppearance: (key: AppearanceMenuKey) => void getAppearanceState: () => AppearanceMenuState getKeybindings?: () => KeybindingOverrides | undefined + // Why: the macOS app-menu title. Passed the per-branch dev label since + // app.name is now pinned to a stable value for Keychain-key stability. + appMenuLabel?: string } function buildAndApplyMenu(options: RegisterAppMenuOptions): void { @@ -130,7 +133,7 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { // redundant "Orca" entry with roles that don't apply, so we omit it there // and distribute its items across File / Help instead. const macAppMenu: Electron.MenuItemConstructorOptions = { - label: app.name, + label: options.appMenuLabel ?? app.name, submenu: [ { role: 'about' }, checkForUpdatesItem, diff --git a/src/main/startup/dev-instance-identity.test.ts b/src/main/startup/dev-instance-identity.test.ts index 2021dfed224..2b06e85dda4 100644 --- a/src/main/startup/dev-instance-identity.test.ts +++ b/src/main/startup/dev-instance-identity.test.ts @@ -5,6 +5,7 @@ describe('dev-instance-identity', () => { it('keeps packaged identity stable', () => { expect(getDevInstanceIdentity(false, {})).toMatchObject({ name: 'Orca', + appName: 'Orca', isDev: false, devLabel: null, dockBadgeLabel: null, @@ -12,6 +13,18 @@ describe('dev-instance-identity', () => { }) }) + it('pins a stable dev appName across branches so the safeStorage key does not churn', () => { + const a = getDevInstanceIdentity(true, { ORCA_DEV_BRANCH: 'feature/a' }) + const b = getDevInstanceIdentity(true, { ORCA_DEV_BRANCH: 'feature/b' }) + + // Per-branch label differs (window title / app menu)... + expect(a.name).not.toBe(b.name) + // ...but the Keychain-driving appName is identical and distinct from prod. + expect(a.appName).toBe('Orca Dev') + expect(b.appName).toBe('Orca Dev') + expect(a.appName).not.toBe('Orca') + }) + it('derives a readable dev label from worktree and branch env', () => { const identity = getDevInstanceIdentity(true, { ORCA_DEV_REPO_ROOT: '/repo/worktrees/dev-indicator', diff --git a/src/main/startup/dev-instance-identity.ts b/src/main/startup/dev-instance-identity.ts index b375e88bba7..6d13541ea1f 100644 --- a/src/main/startup/dev-instance-identity.ts +++ b/src/main/startup/dev-instance-identity.ts @@ -8,6 +8,11 @@ const MAX_LABEL_LENGTH = 80 export type DevInstanceIdentity = AppIdentity & { appUserModelId: string + // Why: drives app.setName → the macOS safeStorage Keychain item name + // (" Safe Storage"). Kept stable across dev branches (unlike the + // per-branch `name`) so every dev instance shares one Keychain key instead of + // creating a new one per branch and re-prompting. Distinct from prod's 'Orca'. + appName: string } function cleanEnvValue(value: string | undefined): string | null { @@ -50,6 +55,7 @@ export function getDevInstanceIdentity( if (!isDev) { return { name: BASE_APP_NAME, + appName: BASE_APP_NAME, isDev: false, devLabel: null, devBranch: null, @@ -71,6 +77,10 @@ export function getDevInstanceIdentity( return { name: dockTitle, + // Why: one stable Keychain key ('Orca Dev Safe Storage') for all dev + // branches; the per-branch identity still shows via `name` (window title, + // app menu, renderer label). + appName: `${BASE_APP_NAME} Dev`, isDev: true, devLabel, devBranch: branch, From a02389cea809239c7d38409e62b957b2dcaf66e1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:40:49 -0700 Subject: [PATCH 19/45] Update code comments section in AGENTS.md Clarified guidelines for code comments, emphasizing the importance of explaining the 'why' behind non-obvious code. --- AGENTS.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2c117478e84..1fd213f742e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,12 +5,7 @@ All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section. ## Code Comments: Document the "Why", Briefly - -When writing or modifying code driven by a design doc or non-obvious constraint, add a comment explaining **why** the code behaves the way it does. - -Keep comments short — one or two lines. Capture only the non-obvious reason (safety constraint, compatibility shim, design-doc rule). Don't restate what the code does, narrate the mechanism, cite design-doc sections verbatim, or explain adjacent API choices unless they're the point. - -The comment's audience is the next person reading that line, not a PR description. If a comment lists call sites, recounts the crash/history, or re-describes the code below it, cut it to the one non-obvious fact: +When code is non-obvious, Add code comments explaining **why** (not HOW). BE CONCISE — ideally 1 line. ```ts // 🚫 Over-explained — narrates the crash, the mechanism, where the helper lives, and every caller: From 64dae13bc9b5bfb7d27ae42d8f593913344c064e Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 20 Jul 2026 01:18:27 -0400 Subject: [PATCH 20/45] fix(sidebar): stop agent 'working' spinner freezing as a broken ring under reduced motion (#9515) Under prefers-reduced-motion (Windows 'Animation effects' off), the agent 'working' spinner froze mid-rotation as a partial (3/4) ring, reading as a broken spinner in the terminal tab and worktree card. Fill the top border so it becomes a complete static ring, and apply the same treatment to StatusIndicator so the sidebar dot honors reduced motion consistently instead of stepping. Mirrors the existing feature-tour-preview-glyphs pattern. Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- src/renderer/src/components/AgentStateDot.test.ts | 3 +++ src/renderer/src/components/AgentStateDot.tsx | 5 ++++- .../src/components/sidebar/StatusIndicator.test.ts | 4 ++++ src/renderer/src/components/sidebar/StatusIndicator.tsx | 8 ++++++-- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index 2e17d0423df..11297224433 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -24,6 +24,9 @@ describe('AgentStateDot', () => { expect(markup).toContain('border-t-transparent') expect(markup).toContain('[animation:spin_1s_steps(12,end)_infinite]') expect(markup).toContain('motion-reduce:animate-none') + // Why: under reduced motion the top border is filled so the frozen ring + // reads as a complete static marker, not a broken partial spinner. + expect(markup).toContain('motion-reduce:border-t-yellow-500') expect(markup).not.toContain('animate-spin') }) diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index 9a4fe792e13..7543de46fac 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -78,7 +78,10 @@ export const AgentStateDot = React.memo(function AgentStateDot({ className={cn( // Why: match the sidebar worktree spinner's stepped cadence so // long-running visible agents do not keep a full-frame-rate loop. - 'block rounded-full border-2 border-yellow-500 border-t-transparent [animation:spin_1s_steps(12,end)_infinite] motion-reduce:animate-none', + // Why: under reduced motion the spin is disabled, so fill the top + // border too — a frozen transparent-top ring reads as a broken + // spinner; a complete ring reads as an intentional static marker. + 'block rounded-full border-2 border-yellow-500 border-t-transparent [animation:spin_1s_steps(12,end)_infinite] motion-reduce:animate-none motion-reduce:border-t-yellow-500', inner )} /> diff --git a/src/renderer/src/components/sidebar/StatusIndicator.test.ts b/src/renderer/src/components/sidebar/StatusIndicator.test.ts index 5f9061eb937..0179a0bac02 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.test.ts +++ b/src/renderer/src/components/sidebar/StatusIndicator.test.ts @@ -23,6 +23,10 @@ describe('StatusIndicator', () => { expect(classNames).toContain('border-yellow-500') expect(classNames).toContain('border-t-transparent') expect(classNames).toContain('[animation:spin_1s_steps(12,end)_infinite]') + // Why: under reduced motion the spin is disabled and the top border filled, + // so the dot stays a complete static ring rather than freezing mid-rotation. + expect(classNames).toContain('motion-reduce:animate-none') + expect(classNames).toContain('motion-reduce:border-t-yellow-500') expect(classNames).not.toContain('animate-spin') }) diff --git a/src/renderer/src/components/sidebar/StatusIndicator.tsx b/src/renderer/src/components/sidebar/StatusIndicator.tsx index 34c1491095f..c181e0c297d 100644 --- a/src/renderer/src/components/sidebar/StatusIndicator.tsx +++ b/src/renderer/src/components/sidebar/StatusIndicator.tsx @@ -34,8 +34,12 @@ const StatusIndicator = React.memo(function StatusIndicator({ {...rest} > {/* Why: a stepped spin preserves the worker-is-running affordance while - avoiding a full-refresh-rate compositor loop for long agent runs. */} - + avoiding a full-refresh-rate compositor loop for long agent runs. + Why: honor reduced motion (Windows "Animation effects" off → + prefers-reduced-motion) by stopping the spin and filling the top + border, so the dot stays a complete static ring instead of a + partial ring frozen mid-rotation that reads as a broken spinner. */} + ) } From 4d25de383e0c294c539b6101281e30a44c772120 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:53:32 -0700 Subject: [PATCH 21/45] perf(renderer): stop agent spinners from keeping the frame pipeline awake (one shared clock, hidden-window stop) (#9380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(renderer): drive agent working-spinners from one shared clock Per-element infinite CSS spin animations kept Chromium's frame pipeline awake for the whole agent run — measured live (interleaved A/B/A/B, renderer+GPU): 86.0/76.0 ms CPU/s with the CSS animation vs 61.0/61.0 with the clock, one working agent, and the CSS cost scales per element while the clock is one flat timer for N spinners. The clock ticks 12 steps/s at 30° — frame-for-frame identical to the retired animation — writes style.transform on registered elements, and stops on document hidden (native visibilitychange post-#9395), under prefers-reduced-motion (static ring keeps the filled top border from #9515), and when the last spinner unmounts. The stale-visibility latch keeps it spinning when proven user input contradicts the occlusion tracker. Co-authored-by: Orca * fix(github): restore explicit space after Filter fallback in PRFilterSections Pre-existing failure on main: i18n-jsx-spacing-guard requires {' '} after the 'Filter' translate fallback, but the file had a bare JSX space (which oxfmt collapses). Wrap the subject in a span so the explicit {' '} survives formatting, matching the pattern in the other guarded files. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../src/components/AgentStateDot.test.ts | 11 +- src/renderer/src/components/AgentStateDot.tsx | 13 +- .../src/components/AgentWorkingSpinner.tsx | 23 +++ .../components/github/PRFilterSections.tsx | 3 +- .../sidebar/StatusIndicator.test.ts | 22 +-- .../components/sidebar/StatusIndicator.tsx | 9 +- .../tab-bar/TerminalTabLeadingIcon.test.tsx | 2 +- .../src/lib/agent-spinner-clock.test.ts | 141 ++++++++++++++++++ src/renderer/src/lib/agent-spinner-clock.ts | 128 ++++++++++++++++ 9 files changed, 318 insertions(+), 34 deletions(-) create mode 100644 src/renderer/src/components/AgentWorkingSpinner.tsx create mode 100644 src/renderer/src/lib/agent-spinner-clock.test.ts create mode 100644 src/renderer/src/lib/agent-spinner-clock.ts diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index 11297224433..40c2bea60f3 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -22,12 +22,15 @@ describe('AgentStateDot', () => { expect(markup).toContain('border-yellow-500') expect(markup).toContain('border-t-transparent') - expect(markup).toContain('[animation:spin_1s_steps(12,end)_infinite]') - expect(markup).toContain('motion-reduce:animate-none') - // Why: under reduced motion the top border is filled so the frozen ring - // reads as a complete static marker, not a broken partial spinner. + // Why: rotation comes from the shared agent-spinner clock (which also + // honors prefers-reduced-motion), not a per-element CSS animation that + // would keep the compositor awake. + expect(markup).toContain('data-agent-spinner') + // Why: under reduced motion the top border is filled so the static ring + // reads as a complete marker, not a broken partial spinner (#9515). expect(markup).toContain('motion-reduce:border-t-yellow-500') expect(markup).not.toContain('animate-spin') + expect(markup).not.toContain('animation:spin') }) it('renders done as an emerald check icon', () => { diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index 7543de46fac..a5c50dad1fe 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -1,6 +1,7 @@ import React from 'react' import { CircleCheck } from 'lucide-react' import { cn } from '@/lib/utils' +import { AgentWorkingSpinner } from '@/components/AgentWorkingSpinner' // Why: shared state-indicator primitive so the dashboard and the sidebar's // agent hover share a single state vocabulary. Most states render as a dot; @@ -74,17 +75,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({ className={cn('inline-flex shrink-0 items-center justify-center', box, className)} aria-label={agentStateLabel(state)} > - + ) } diff --git a/src/renderer/src/components/AgentWorkingSpinner.tsx b/src/renderer/src/components/AgentWorkingSpinner.tsx new file mode 100644 index 00000000000..7e3d3302a7e --- /dev/null +++ b/src/renderer/src/components/AgentWorkingSpinner.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import { agentSpinnerRef } from '@/lib/agent-spinner-clock' + +// Why: the working-state ring must not carry its own infinite CSS animation — +// that keeps the compositor awake per element for the whole agent run. The +// shared agent-spinner clock rotates every mounted ring in phase and stops +// cleanly when nothing can be seen. Callers size it via className (size-2 etc.). +export function AgentWorkingSpinner({ className }: { className?: string }): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/github/PRFilterSections.tsx b/src/renderer/src/components/github/PRFilterSections.tsx index 6f65eee8e55..d704ea12754 100644 --- a/src/renderer/src/components/github/PRFilterSections.tsx +++ b/src/renderer/src/components/github/PRFilterSections.tsx @@ -208,7 +208,8 @@ export function SectionMenu({ return (
- {translate('auto.components.github.PRFilterSections.8177eda37e', 'Filter')} {subject} + {translate('auto.components.github.PRFilterSections.8177eda37e', 'Filter')}{' '} + {subject}
{rows.map((row) => (