diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index f7d8172cb5f..333eb7e8a14 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2114,6 +2114,171 @@ ], "demotionRule": "Keep non-blocking or demote to protection none if the Windows byte test silently skips, flakes without an actionable artifact, routes bytes using tab/title/terminal-output heuristics, or adds any ordinary-key lookup, IPC, polling, scan, or lifecycle leak." }, + { + "id": "terminal-input.guarded-agent-send-authorization", + "title": "Guarded agent sends authorize only the confirmed exact PTY", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-input", + "layer": "runtime-provider-contract", + "surfaces": [ + "terminal input", + "guarded note sends", + "agent status", + "PTY identity", + "foreground process confirmation" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "wsl", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [ + "local", + "daemon" + ], + "coverageNotes": "Runtime and provider-contract tests on macOS cover exact-PTY authorization, local/daemon fresh confirmation, exact ConPTY membership, and unsupported-provider fail-closed behavior. Physical Windows, live Linux, SSH, WSL, and remote-runtime validation remain explicit gaps; providers without confirmation preserve conservative refusal on a shell conflict.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/8303" + ], + "invariant": "A guarded note send writes only to the exact PTY binding checked by the guard and only while permission/wait evidence allows input; fresh hook state conflicting with an ordinary shell foreground requires fresh provider confirmation of a recognized agent in that PTY.", + "oracle": "Fresh explicit state plus ordinary PowerShell plus confirmed recognized agent is sendable on the same PTY. Confirmed shell/non-agent, unavailable confirmation, PTY exit, handle rebind, or a callback PTY mismatch returns a refusal or not-writable result and writes zero bytes.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx" + ], + "testFiles": [ + "src/main/runtime/orca-runtime.test.ts", + "src/main/runtime/rpc/terminal-send.test.ts", + "src/main/ipc/pty.test.ts", + "src/main/providers/agent-foreground-process.test.ts", + "src/main/providers/local-pty-provider.test.ts", + "src/main/providers/windows-conpty-process-membership.test.ts", + "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts", + "src/main/daemon/pty-subprocess.test.ts", + "src/renderer/src/lib/active-agent-note-send.test.ts", + "src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx" + ], + "assertionRefs": [ + { + "file": "src/main/runtime/orca-runtime.test.ts", + "assertions": [ + "a recognized fresh provider confirmation authorizes explicit hook state over an ordinary shell result", + "shell, non-agent, null, thrown, and missing confirmation remain fail-closed", + "title blockers and ordinary recognized agent evidence add no confirmation call", + "PTY exit or handle rebind during either foreground read rejects evidence bound to the original PTY", + "controller-less yields preserve stale-handle errors and confirmation methods retain their controller receiver" + ] + }, + { + "file": "src/main/runtime/rpc/terminal-send.test.ts", + "assertions": [ + "a handle rebind during guarded status evaluation returns not writable and writes zero bytes", + "the callback's actual PTY must equal the handle binding before status can authorize a write" + ] + }, + { + "file": "src/main/ipc/pty.test.ts", + "assertions": [ + "runtime confirmation reaches the provider owning the captured PTY", + "unsupported or missing providers return unavailable confirmation" + ] + }, + { + "file": "src/main/providers/agent-foreground-process.test.ts", + "assertions": [ + "recognized Windows candidates require exact ConPTY membership and detached descendants are rejected", + "unavailable process inspection is distinct from authoritative shell evidence" + ] + }, + { + "file": "src/main/providers/local-pty-provider.test.ts", + "assertions": [ + "fresh confirmation is discarded when its owning local PTY exits" + ] + }, + { + "file": "src/main/providers/windows-conpty-process-membership.test.ts", + "assertions": [ + "exact ConPTY console membership comes from the fixed node-pty helper", + "malformed, incomplete, timed-out, and spawn-error membership reads fail closed" + ] + }, + { + "file": "src/main/daemon/daemon-foreground-confirmation-protocol.test.ts", + "assertions": [ + "daemons from before the fresh-confirmation RPC are rejected by protocol version" + ] + }, + { + "file": "src/main/daemon/pty-subprocess.test.ts", + "assertions": [ + "fresh confirmation bypasses cached PowerShell and waits for a post-request process scan" + ] + }, + { + "file": "src/renderer/src/lib/active-agent-note-send.test.ts", + "assertions": [ + "selected active-agent note sends retain guarded paste and submit routing" + ] + }, + { + "file": "src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx", + "assertions": [ + "browser annotation send content routes through review-notes send so existing agent sessions remain selectable", + "both browser annotation send surfaces wire the shared menu content" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-11", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-send.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/pty-subprocess.test.ts src/renderer/src/lib/active-agent-note-send.test.ts src/renderer/src/components/browser-pane/BrowserAnnotationSendMenuContent.test.tsx", + "result": "passed", + "durationSeconds": 9.58, + "summary": "Ten focused test files passed (1183 tests), covering runtime confirmation and PTY revalidation, guarded RPC zero-write behavior, PTY controller routing, local/daemon fresh scans, exact ConPTY membership, and unchanged renderer note routing." + } + ], + "runtimeBudget": { + "p95Seconds": 45, + "scope": "focused runtime, RPC, PTY provider, and renderer routing units" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Deterministic units have local coverage only; promotion requires repeated CI and platform history." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Removing strong confirmation fails the shell-conflict success oracle, while removing either exact-binding comparison fails zero-write rebind coverage; saved intentional-break and physical Windows evidence remain missing." + }, + "performanceBudget": { + "required": true, + "evidence": "Confirmation is invoked at most once per status evaluation and only for fresh explicit hook state whose ordinary foreground result is a shell. Count tests prove permission/title blockers and ordinary recognized-agent evidence add zero confirmations; no polling, retry, session listing, provider fanout, or runtime-global cache is added, and existing provider snapshot dedup remains authoritative." + }, + "promotionCriteria": [ + "Run the browser annotation existing-agent and repeat-send path in Electron on Windows ConPTY without a recognition refusal.", + "Collect stable CI and flake history across local and daemon providers on Windows plus representative macOS/Linux coverage.", + "Attach saved intentional-break evidence for confirmation removal and exact-PTY revalidation removal." + ], + "knownGaps": [ + "Physical Windows validation is unavailable on the current macOS host; exact ConPTY membership is covered deterministically.", + "Electron annotation golden-path, repeat-send, adjacent-menu evidence, and screenshots are left to coordinator validation.", + "SSH, WSL, legacy daemon, and remote-runtime providers without confirmation remain intentionally fail-closed on an ordinary-shell conflict; no live artifacts cover those degraded paths.", + "No live Linux PTY, paired-web, mobile/relay, restore/replay, or multi-window artifact is attached; those surfaces receive no renderer, persistence, or protocol change." + ], + "demotionRule": "Keep non-blocking or demote to protection none if provider confirmation becomes unconditional, exact-PTY mismatch can write bytes, unsupported providers fail open, or the focused gate flakes without an actionable product or harness defect." + }, { "id": "terminal-input.agent-prompt-injection", "title": "Orchestration agent prompts arrive as bracketed paste before submit", diff --git a/docs/claude-scoped-oauth-usage-limits.md b/docs/claude-scoped-oauth-usage-limits.md new file mode 100644 index 00000000000..e5eb3c9e2d3 --- /dev/null +++ b/docs/claude-scoped-oauth-usage-limits.md @@ -0,0 +1,84 @@ +# Claude Scoped OAuth Usage Limits + +## Problem + +Anthropic's current OAuth usage response reports Fable in `limits` as a model-scoped weekly limit instead of one of the legacy top-level Fable fields. Orca ignores `limits`, maps `fableWeekly` to `null`, and then depends on a hidden Claude `/usage` PTY read that is disabled on Windows and can fail silently elsewhere. + +- `src/main/rate-limits/claude-fetcher.ts:300` models only top-level OAuth windows. +- `src/main/rate-limits/claude-fetcher.ts:393` maps only legacy Fable field names. +- `src/main/rate-limits/service.ts:1203` disables the PTY supplement on Windows. +- `src/renderer/src/components/status-bar/tooltip.tsx:172` renders Fable whenever `fableWeekly` is populated. + +## Root Cause + +The OAuth response contract evolved from dedicated model fields to generic entries shaped like `kind: "weekly_scoped"`, `percent`, `resets_at`, and `scope.model.display_name`. Orca's response type and mapper were not updated for that shape. + +## Non-goals + +- Do not change polling, credentials, token refresh, account switching, renderer layout, or usage percentage semantics. +- Do not remove the existing PTY supplement or legacy field compatibility. +- Do not generalize shared renderer state to arbitrary model windows in this targeted bug fix. + +## Design + +1. Extend the private OAuth response type with an optional `limits` array containing only the fields needed for safe parsing. +2. Select a Fable entry only when `kind` is `weekly_scoped`, the model display name is Fable (case-insensitive), and `percent` is finite. +3. Map the scoped entry to the existing seven-day `fableWeekly` window, including its reset timestamp. +4. Prefer the current scoped entry, then retain the three legacy top-level fields as fallbacks. +5. Keep malformed, unrelated, inactive, or absent entries non-fatal. Treat `is_active: false` as unavailable so stale promotional limits disappear; accept a missing activity flag for compatibility. + +## Data Flow + +- OAuth response + - `limits[].weekly_scoped` Fable -> `fableWeekly` + - otherwise legacy explicit Fable field -> `fableWeekly` + - otherwise existing optional PTY supplement +- Existing provider state -> existing status-bar and details rendering + +## Edge Cases + +- `limits` is missing, null, malformed, or contains null entries. +- A scoped entry names another model. +- Fable percent is missing, non-numeric, or non-finite. +- Fable is inactive and should not be rendered. +- `is_active` is omitted by an older server response but the remaining scoped entry is valid. +- Both current and legacy fields exist; the current scoped entry wins. +- Reset timestamps may be ISO strings, epoch seconds, epoch milliseconds, or absent. +- Windows, WSL, SSH, and remote runtimes use the same OAuth mapper and require no platform-specific execution. + +## Test Plan + +- Unit: reproduce a current real-response shape and assert Fable maps without a PTY attempt. +- Unit: assert scoped data wins over a legacy field. +- Unit: assert inactive, malformed, and unrelated scoped entries are ignored while legacy fallback remains available. +- Regression: retain existing legacy-field and bare-`fable` behavior tests. +- Verification: focused Claude fetcher tests, typecheck, lint, and max-lines ratchet. +- Electron: refresh Claude usage and confirm Session, Weekly, and Fable remain visible in the existing status-bar details surface. + +## UI Quality Bar + +No UI implementation changes. The existing Fable row must reappear with the same typography, spacing, progress bar, percentage semantics, and reset copy as adjacent Session and Weekly rows. + +## Review Screenshots + +1. Claude usage details showing Session, Weekly, and Fable from a live OAuth refresh. +2. Adjacent status-bar context showing the Claude provider remains visually unchanged outside the restored row. + +## Rollout + +1. Add the scoped OAuth response types and mapper. +2. Add focused current-schema and compatibility regression tests. +3. Run focused and repository checks. +4. Validate the restored row in Electron and capture review screenshots. +5. Commit, push, and open an unmerged PR. + +## Lightweight Eng Review + +- Scope: Kept to the private OAuth mapper and tests; no shared-state or renderer generalization is required to restore Fable. +- Architecture/data flow: OAuth remains authoritative, with structured scoped data preferred over legacy fields and PTY used only as the existing final supplement. +- Failure modes covered: malformed optional data, unrelated models, inactive limits, missing activity flags, duplicate old/new representations, missing reset metadata, and platform-neutral execution. +- Test coverage required: current-schema success without PTY, precedence, inactivity, malformed/unrelated entries, and legacy fallback. +- Performance/blast radius: One bounded linear scan of the small response `limits` array per existing OAuth refresh; no new requests, polling, subprocesses, IPC, storage, or renderer work. +- UI quality bar: Existing status-bar visuals must remain unchanged except for the restored Fable row. +- Required review screenshots: Live Claude details with all three rows; surrounding status-bar context. +- Residual risks: Anthropic may rename the scoped model display label; legacy and PTY fallbacks remain available. diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 1b63bf59b7a..5737683d481 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -2528,6 +2528,7 @@ export default function SessionScreen() { } }, []) + const pendingForegroundRecoveryRef = useRef(false) useEffect(() => { let previousAppState: AppStateStatus | null = AppState.currentState const sub = AppState.addEventListener('change', (nextAppState: AppStateStatus) => { @@ -2546,7 +2547,7 @@ export default function SessionScreen() { // Why: iOS can resume a live WKWebView with a blank xterm backing store // without firing web-ready/reconnect; invalidate the native readiness // latch before replay so init waits for the document's pong. - recoverActiveTerminalAfterForeground({ + const outcome = recoverActiveTerminalAfterForeground({ activeHandleRef, terminalRefs, initializedHandlesRef, @@ -2555,12 +2556,36 @@ export default function SessionScreen() { subscribeToTerminal, schedule: scheduleDelayedAction }) + pendingForegroundRecoveryRef.current = outcome === 'deferred' }) return () => { sub.remove() } }, [scheduleDelayedAction, subscribeToTerminal, unsubscribeTerminal]) + // Why: resume usually lands mid-reconnect (the socket dies after ~60-80s of + // background), so the recovery above defers. Re-run it once the connection + // is back; otherwise a blanked WKWebView whose socket was merely probed (no + // stream replay) stays stale until a manual tab switch. + useEffect(() => { + if (connState !== 'connected' || !pendingForegroundRecoveryRef.current) { + return + } + pendingForegroundRecoveryRef.current = false + if (AppState.currentState !== 'active') { + return + } + recoverActiveTerminalAfterForeground({ + activeHandleRef, + terminalRefs, + initializedHandlesRef, + connStateRef, + unsubscribeTerminal, + subscribeToTerminal, + schedule: scheduleDelayedAction + }) + }, [connState, scheduleDelayedAction, subscribeToTerminal, unsubscribeTerminal]) + // Why: viewport refits for layout changes outside the subscribe path // (tab strip toggling, fold/unfold, rotation) live in a dedicated hook — // see terminal-viewport-refit.ts for the full rationale. @@ -3046,10 +3071,15 @@ export default function SessionScreen() { // this, subscribe(viewport=null) lands on the server first and the // post-scrollback measure path's resubscribe sees alreadyMeasured=true // (because measureViewportOnce won the race) and silently skips. - if (handle === activeHandleRef.current && !terminalUnsubsRef.current.has(handle)) { + // Why: a just-created tab can briefly lose activeHandleRef to a lagging + // session-tab snapshot; honor the pending marker so its one web-ready + // subscribe still fires (see handleCreateTerminal). + const isIntendedActive = () => + handle === activeHandleRef.current || handle === pendingActiveTerminalHandleRef.current + if (isIntendedActive() && !terminalUnsubsRef.current.has(handle)) { void (async () => { await measureViewportOnce(handle) - if (handle === activeHandleRef.current && !terminalUnsubsRef.current.has(handle)) { + if (isIntendedActive() && !terminalUnsubsRef.current.has(handle)) { subscribeToTerminal(handle) } })() @@ -3937,6 +3967,11 @@ export default function SessionScreen() { if (typeof created.terminal === 'string') { const createdHandle = created.terminal defaultTerminalHandlesToLiveInput([createdHandle]) + // Why: session-tab snapshots can lag the create RPC. Without the + // handle marker, applySessionTabs snaps activeHandleRef back to the + // previous terminal, and the new pane's web-ready subscribe (gated + // on the active handle) is skipped — blank tab until a manual switch. + pendingActiveTerminalHandleRef.current = createdHandle activeHandleRef.current = createdHandle setActiveHandle(createdHandle) setTerminals((prev) => { @@ -3991,6 +4026,9 @@ export default function SessionScreen() { }) } } else { + // Why: a prior pending handle must not outlive a create that returned + // no terminal; web-ready subscribe gates on this ref as active. + pendingActiveTerminalHandleRef.current = null activeHandleRef.current = null setActiveHandle(null) } diff --git a/mobile/src/terminal/terminal-foreground-recovery.test.ts b/mobile/src/terminal/terminal-foreground-recovery.test.ts index 6aeceaaa14a..74d567ff62a 100644 --- a/mobile/src/terminal/terminal-foreground-recovery.test.ts +++ b/mobile/src/terminal/terminal-foreground-recovery.test.ts @@ -69,7 +69,7 @@ describe('terminal foreground recovery', () => { const recovered = recoverActiveTerminalAfterForeground(harness) - expect(recovered).toBe(true) + expect(recovered).toBe('recovered') expect(harness.unsubscribeTerminal).toHaveBeenCalledWith('term-1') expect(harness.initializedHandlesRef.current.has('term-1')).toBe(false) expect(harness.schedule).toHaveBeenCalledWith( @@ -90,7 +90,7 @@ describe('terminal foreground recovery', () => { const recovered = recoverActiveTerminalAfterForeground(harness) - expect(recovered).toBe(true) + expect(recovered).toBe('recovered') expect(harness.initializedHandlesRef.current.has('term-1')).toBe(false) expect(harness.initializedHandlesRef.current.has('term-2')).toBe(false) expect(harness.unsubscribeTerminal).toHaveBeenCalledWith('term-1') @@ -102,11 +102,37 @@ describe('terminal foreground recovery', () => { const recovered = recoverActiveTerminalAfterForeground(harness) - expect(recovered).toBe(false) + expect(recovered).toBe('skipped') expect(harness.unsubscribeTerminal).not.toHaveBeenCalled() expect(harness.schedule).not.toHaveBeenCalled() }) + it('defers without touching state when the socket is not connected yet', () => { + const harness = createHarness() + harness.connStateRef.current = 'reconnecting' + + const recovered = recoverActiveTerminalAfterForeground(harness) + + expect(recovered).toBe('deferred') + // Why this matters: the deferred retry needs the initialized flags intact + // so the post-reconnect recovery can still force a scrollback replay. + expect(harness.initializedHandlesRef.current.has('term-1')).toBe(true) + expect(harness.unsubscribeTerminal).not.toHaveBeenCalled() + expect(harness.schedule).not.toHaveBeenCalled() + }) + + it('replays scrollback when re-run after the deferred reconnect completes', () => { + const harness = createHarness() + harness.connStateRef.current = 'reconnecting' + + expect(recoverActiveTerminalAfterForeground(harness)).toBe('deferred') + + harness.connStateRef.current = 'connected' + expect(recoverActiveTerminalAfterForeground(harness)).toBe('recovered') + harness.runScheduled() + expect(harness.subscribeToTerminal).toHaveBeenCalledWith('term-1') + }) + it('does not replay a stale terminal if focus changes before the delayed subscribe', () => { const harness = createHarness() @@ -134,4 +160,17 @@ describe('terminal foreground recovery', () => { expect(readinessInvalidation).toBeGreaterThanOrEqual(0) expect(replay).toBeGreaterThan(readinessInvalidation) }) + + it('re-runs a deferred recovery once the session screen reconnects', () => { + // Why: resume usually lands mid-reconnect; the session screen must retry + // recovery on the connState→connected transition or blanked panes stay + // stale until a manual tab switch. + expect(sessionSource).toContain("pendingForegroundRecoveryRef.current = outcome === 'deferred'") + const reconnectRetry = sliceSessionSource( + "if (connState !== 'connected' || !pendingForegroundRecoveryRef.current)", + 'recoverActiveTerminalAfterForeground({' + ) + expect(reconnectRetry).toContain('pendingForegroundRecoveryRef.current = false') + expect(reconnectRetry).toContain("AppState.currentState !== 'active'") + }) }) diff --git a/mobile/src/terminal/terminal-foreground-recovery.ts b/mobile/src/terminal/terminal-foreground-recovery.ts index d3d5132c8c7..7abfe48b8c9 100644 --- a/mobile/src/terminal/terminal-foreground-recovery.ts +++ b/mobile/src/terminal/terminal-foreground-recovery.ts @@ -4,6 +4,11 @@ import type { TerminalWebViewHandle } from './TerminalWebView' export const TERMINAL_FOREGROUND_RECOVERY_DELAY_MS = 120 +// 'deferred' = the socket wasn't connected at the foreground edge (it usually +// dies after ~60-80s of background); the caller must re-run recovery once the +// connection is back or a blanked WKWebView stays stale until a tab switch. +export type TerminalForegroundRecoveryOutcome = 'recovered' | 'deferred' | 'skipped' + type TerminalForegroundRecoveryOptions = { activeHandleRef: RefObject terminalRefs: RefObject> @@ -36,15 +41,15 @@ export function recoverActiveTerminalAfterForeground({ subscribeToTerminal, schedule, delayMs = TERMINAL_FOREGROUND_RECOVERY_DELAY_MS -}: TerminalForegroundRecoveryOptions): boolean { +}: TerminalForegroundRecoveryOptions): TerminalForegroundRecoveryOutcome { if (connStateRef.current !== 'connected') { - return false + return 'deferred' } const initializedMountedHandles = Array.from(initializedHandlesRef.current).filter((handle) => terminalRefs.current.has(handle) ) if (initializedMountedHandles.length === 0) { - return false + return 'skipped' } const handle = activeHandleRef.current const shouldRecoverActive = @@ -57,7 +62,7 @@ export function recoverActiveTerminalAfterForeground({ } if (!shouldRecoverActive || !handle) { - return true + return 'recovered' } unsubscribeTerminal(handle) @@ -70,5 +75,5 @@ export function recoverActiveTerminalAfterForeground({ } subscribeToTerminal(handle) }, delayMs) - return true + return 'recovered' } diff --git a/package.json b/package.json index 6f5400d484e..cc90546cd0b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.136-rc.0", + "version": "1.4.136", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", @@ -177,6 +177,7 @@ "lint-staged": "^16.4.0", "lowlight": "^3.3.0", "lucide-react": "^0.577.0", + "marked": "^17.0.1", "mermaid": "^11.15.0", "monaco-editor": "^0.55.1", "oxfmt": "^0.52.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b48ad9b4f29..ebb3aa40135 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -263,6 +263,9 @@ importers: lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.5) + marked: + specifier: ^17.0.1 + version: 17.0.6 mermaid: specifier: ^11.15.0 version: 11.15.0 @@ -399,10 +402,6 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -415,10 +414,6 @@ packages: resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - '@babel/generator@7.29.7': resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} @@ -437,10 +432,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - '@babel/helper-globals@7.29.7': resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} @@ -449,20 +440,10 @@ packages: resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.29.7': resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-module-transforms@7.29.7': resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} @@ -487,26 +468,14 @@ packages: resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.29.7': resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} @@ -515,11 +484,6 @@ packages: resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.3': - resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.29.7': resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} @@ -577,26 +541,14 @@ packages: resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.7': resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -3457,6 +3409,7 @@ packages: boolean@3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. brace-expansion@1.1.14: resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} @@ -4354,14 +4307,6 @@ packages: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} - fs-extra@11.3.4: - resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} - engines: {node: '>=14.14'} - - fs-extra@11.3.5: - resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} - engines: {node: '>=14.14'} - fs-extra@11.3.6: resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} engines: {node: '>=14.14'} @@ -4446,6 +4391,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -4642,6 +4588,7 @@ packages: inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -4967,6 +4914,7 @@ packages: lodash.isequal@4.5.0: resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -5330,6 +5278,7 @@ packages: node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead node-fetch@3.3.2: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} @@ -6740,12 +6689,6 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.1.2 - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -6774,14 +6717,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - '@babel/generator@7.29.7': dependencies: '@babel/parser': 7.29.7 @@ -6792,7 +6727,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/helper-compilation-targets@7.29.7': dependencies: @@ -6810,26 +6745,17 @@ snapshots: '@babel/helper-optimise-call-expression': 7.27.1 '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-globals@7.28.0': {} - '@babel/helper-globals@7.29.7': {} '@babel/helper-member-expression-to-functions@7.28.5': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -6840,15 +6766,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -6860,7 +6777,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/helper-plugin-utils@7.28.6': {} @@ -6869,27 +6786,21 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.0 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-string-parser@7.29.7': {} - '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-option@7.27.1': {} - '@babel/helper-validator-option@7.29.7': {} '@babel/helpers@7.29.7': @@ -6897,10 +6808,6 @@ snapshots: '@babel/template': 7.29.7 '@babel/types': 7.29.7 - '@babel/parser@7.29.3': - dependencies: - '@babel/types': 7.29.0 - '@babel/parser@7.29.7': dependencies: '@babel/types': 7.29.7 @@ -6923,7 +6830,7 @@ snapshots: '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -6953,7 +6860,7 @@ snapshots: dependencies: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-validator-option': 7.27.1 + '@babel/helper-validator-option': 7.29.7 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7) '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7) @@ -6962,30 +6869,12 @@ snapshots: '@babel/runtime@7.29.7': {} - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 - '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.3 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -6998,11 +6887,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -7127,7 +7011,7 @@ snapshots: fs-extra: 10.1.0 isbinaryfile: 4.0.10 minimist: 1.2.8 - plist: 3.1.0 + plist: 3.1.1 transitivePeerDependencies: - supports-color @@ -7148,9 +7032,9 @@ snapshots: '@malept/cross-spawn-promise': 2.0.0 debug: 4.4.3 dir-compare: 4.2.0 - fs-extra: 11.3.4 + fs-extra: 11.3.6 minimatch: 9.0.9 - plist: 3.1.0 + plist: 3.1.1 transitivePeerDependencies: - supports-color @@ -8992,24 +8876,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/cacheable-request@6.0.3': dependencies: @@ -9369,8 +9253,7 @@ snapshots: '@xmldom/xmldom@0.8.13': {} - '@xmldom/xmldom@0.9.10': - optional: true + '@xmldom/xmldom@0.9.10': {} '@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287)': dependencies: @@ -10588,24 +10471,11 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 - fs-extra@11.3.4: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.1 - universalify: 2.0.1 - - fs-extra@11.3.5: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.1 - universalify: 2.0.1 - fs-extra@11.3.6: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.1 universalify: 2.0.1 - optional: true fs-extra@7.0.1: dependencies: @@ -12019,7 +11889,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -12089,7 +11959,6 @@ snapshots: '@xmldom/xmldom': 0.9.10 base64-js: 1.5.1 xmlbuilder: 15.1.1 - optional: true pngjs@5.0.0: {} @@ -12689,7 +12558,7 @@ snapshots: shadcn@4.7.0(@types/node@25.6.0)(typescript@7.0.2): dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.29.3 + '@babel/parser': 7.29.7 '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.7) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) '@dotenvx/dotenvx': 1.66.0 @@ -12703,7 +12572,7 @@ snapshots: diff: 8.0.4 execa: 9.6.1 fast-glob: 3.3.3 - fs-extra: 11.3.5 + fs-extra: 11.3.6 fuzzysort: 3.1.0 https-proxy-agent: 7.0.6 kleur: 4.1.5 @@ -12788,7 +12657,7 @@ snapshots: simple-update-notifier@2.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.1 sisteransi@1.0.5: {} diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index fabf508b141..2b734fd41ca 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -8,10 +8,10 @@ import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree- import { parseWorkspaceKey } from '../../shared/workspace-scope' import { parsePaneKey } from '../../shared/stable-pane-id' import { - humanizeBranchSlug, isAutoGeneratedCreatureBranchName, stripConfiguredBranchPrefix } from '../../shared/branch-name-from-work' +import { deriveWorkspaceDisplayName } from '../../shared/display-name-from-work' import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key' import { computeBranchName, getConfiguredBranchPrefix } from '../ipc/worktree-logic' import { gitExecFileAsync } from '../git/runner' @@ -307,7 +307,7 @@ async function runAutoRename( // Keep the sidebar name in sync with the branch — but only when it is still // the auto-generated creature name, so a name the user typed is left alone. const currentDisplayName = deps.getCurrentDisplayName(worktreeId) - const newDisplayName = humanizeBranchSlug(newBranchLeaf) + const newDisplayName = deriveWorkspaceDisplayName({ prompt, slug, resolvedLeaf: newBranchLeaf }) const updateDisplay = !currentDisplayName || isAutoGeneratedCreatureBranchName(currentDisplayName) if (updateDisplay) { deps.setDisplayName(worktreeId, newDisplayName) @@ -390,7 +390,7 @@ async function runFolderWorkspaceTitleAutoRename( return retry(`generation failed: ${generated.error}`) } - const newDisplayName = humanizeBranchSlug(generated.slug) + const newDisplayName = deriveWorkspaceDisplayName({ prompt, slug: generated.slug }) deps.setDisplayName(worktreeId, newDisplayName) deps.setRenameError(worktreeId, null) deps.onRenamed(worktreeId) diff --git a/src/main/agent-hooks/server.claude-interactive-question.test.ts b/src/main/agent-hooks/server.claude-interactive-question.test.ts new file mode 100644 index 00000000000..a2c31a8f18b --- /dev/null +++ b/src/main/agent-hooks/server.claude-interactive-question.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import { makePaneKey } from '../../shared/stable-pane-id' +import { AgentHookServer } from './server' + +const PANE_KEY = makePaneKey('tab-question', '11111111-1111-4111-8111-111111111111') + +function ingestClaudeStatus( + server: AgentHookServer, + event: { + state: 'working' | 'waiting' + hookEventName: 'PermissionRequest' | 'PreToolUse' + toolName: string + toolUseId: string + } +): void { + server.ingestRemote( + { + paneKey: PANE_KEY, + tabId: 'tab-question', + worktreeId: 'worktree-question', + hookEventName: event.hookEventName, + toolUseId: event.toolUseId, + payload: { + state: event.state, + agentType: 'claude', + toolName: event.toolName + } + }, + 'connection-1' + ) +} + +describe('Claude interactive-question status transitions', () => { + it('clears an AskUserQuestion wait when later tool work starts', () => { + const server = new AgentHookServer() + + ingestClaudeStatus(server, { + state: 'waiting', + hookEventName: 'PreToolUse', + toolName: 'AskUserQuestion', + toolUseId: 'tool-question' + }) + ingestClaudeStatus(server, { + state: 'working', + hookEventName: 'PreToolUse', + toolName: 'Read', + toolUseId: 'tool-after-answer' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE_KEY, + state: 'working', + agentType: 'claude', + toolName: 'Read' + }) + ]) + }) + + it('keeps an actual permission request sticky during unrelated tool work', () => { + const server = new AgentHookServer() + + ingestClaudeStatus(server, { + state: 'waiting', + hookEventName: 'PermissionRequest', + toolName: 'Bash', + toolUseId: 'tool-needs-permission' + }) + ingestClaudeStatus(server, { + state: 'working', + hookEventName: 'PreToolUse', + toolName: 'Read', + toolUseId: 'tool-unrelated' + }) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + paneKey: PANE_KEY, + state: 'waiting', + agentType: 'claude', + toolName: 'Bash' + }) + ]) + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 45fdf0362a6..f859277303d 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -314,6 +314,7 @@ function shouldKeepClaudePermissionVisible( if ( previous?.payload.agentType !== 'claude' || previous.payload.state !== 'waiting' || + previous.hookEventName !== 'PermissionRequest' || next.payload.agentType !== 'claude' || next.payload.state !== 'working' ) { @@ -325,9 +326,8 @@ function shouldKeepClaudePermissionVisible( if (isClaudePermissionResumingApprovedTool(previous, next)) { return false } - // Why: Claude can run subagents concurrently in one pane. Keep permission - // sticky unless the next hook has a source-level execution id that the - // PermissionRequest event itself does not expose. + // Why: only real permission requests stay sticky across concurrent subagent + // activity; interactive questions clear on the next working hook. return true } diff --git a/src/main/ai-vault/session-scanner-kimi-index-cache.test.ts b/src/main/ai-vault/session-scanner-kimi-index-cache.test.ts new file mode 100644 index 00000000000..549c1a3c170 --- /dev/null +++ b/src/main/ai-vault/session-scanner-kimi-index-cache.test.ts @@ -0,0 +1,164 @@ +import { mkdtemp, rm, stat, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + KimiSessionIndexCache, + KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, + KIMI_WORK_DIR_CACHE_TTL_MS, + type KimiSessionIndexIdentity +} from './session-scanner-kimi-index-cache' +import { + clearKimiSessionIndexCache, + hasKimiSessionIndexCacheEntryForTests, + readKimiWorkDirBySessionId +} from './session-scanner-kimi-paths' + +const IDENTITY: KimiSessionIndexIdentity = { + changeTimeMs: 1, + mtimeMs: 1, + sizeBytes: 1 +} +let tempDirs: string[] = [] + +afterEach(async () => { + vi.useRealTimers() + clearKimiSessionIndexCache() + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) + tempDirs = [] +}) + +describe('KimiSessionIndexCache', () => { + it('bounds prolonged path churn and retains a reused index', async () => { + const cache = new KimiSessionIndexCache() + + for (let index = 0; index < KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS; index += 1) { + const path = `home-${index}/session_index.jsonl` + await cache.get(path, IDENTITY, cache.beginRead(), async () => new Map([[path, path]])) + } + const reusedPath = 'home-0/session_index.jsonl' + await cache.get(reusedPath, IDENTITY, cache.beginRead(), async () => new Map()) + + const firstOverflowPath = `home-${KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS}/session_index.jsonl` + await cache.get( + firstOverflowPath, + IDENTITY, + cache.beginRead(), + async () => new Map([[firstOverflowPath, firstOverflowPath]]) + ) + expect(cache.has(reusedPath)).toBe(true) + expect(cache.has('home-1/session_index.jsonl')).toBe(false) + + for (let index = KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS + 1; index < 640; index += 1) { + const path = `home-${index}/session_index.jsonl` + await cache.get(path, IDENTITY, cache.beginRead(), async () => new Map([[path, path]])) + } + + expect(cache.has(reusedPath)).toBe(false) + expect(cache.size).toBe(KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS) + expect(cache.has('home-576/session_index.jsonl')).toBe(true) + expect(cache.has('home-575/session_index.jsonl')).toBe(false) + cache.clear() + }) + + it('refreshes active entries and expires them after an idle TTL', async () => { + vi.useFakeTimers() + const cache = new KimiSessionIndexCache() + const path = 'active/session_index.jsonl' + const value = new Map([['session', '/repo']]) + + await cache.get(path, IDENTITY, cache.beginRead(), async () => value) + await vi.advanceTimersByTimeAsync(KIMI_WORK_DIR_CACHE_TTL_MS - 1) + expect(await cache.get(path, IDENTITY, cache.beginRead(), async () => new Map())).toBe(value) + + await vi.advanceTimersByTimeAsync(KIMI_WORK_DIR_CACHE_TTL_MS - 1) + expect(cache.has(path)).toBe(true) + await vi.advanceTimersByTimeAsync(1) + expect(cache.has(path)).toBe(false) + expect(vi.getTimerCount()).toBe(0) + }) + + it('deduplicates concurrent reads of the same file identity', async () => { + const cache = new KimiSessionIndexCache() + const load = vi.fn(async () => new Map([['session', '/repo']])) + const first = cache.get('index', IDENTITY, cache.beginRead(), load) + const second = cache.get('index', IDENTITY, cache.beginRead(), load) + + expect(second).toBe(first) + await expect(second).resolves.toEqual(new Map([['session', '/repo']])) + expect(load).toHaveBeenCalledOnce() + cache.clear() + }) + + it('does not let an older mutation race replace a newer identity', async () => { + const cache = new KimiSessionIndexCache() + const oldGeneration = cache.beginRead() + const newGeneration = cache.beginRead() + const newerIdentity = { ...IDENTITY, changeTimeMs: 2, mtimeMs: 2, sizeBytes: 2 } + const newer = new Map([['session', '/new']]) + + await cache.get('index', newerIdentity, newGeneration, async () => newer) + await cache.get('index', IDENTITY, oldGeneration, async () => new Map([['session', '/old']])) + const reload = vi.fn(async () => new Map()) + + await expect(cache.get('index', newerIdentity, cache.beginRead(), reload)).resolves.toBe(newer) + expect(reload).not.toHaveBeenCalled() + cache.clear() + }) + + it('does not repopulate after an owner clears an in-flight read', async () => { + const cache = new KimiSessionIndexCache() + const staleGeneration = cache.beginRead() + cache.clear() + + await cache.get('index', IDENTITY, staleGeneration, async () => new Map([['session', '/old']])) + + expect(cache.has('index')).toBe(false) + const current = new Map([['session', '/current']]) + await expect( + cache.get('index', IDENTITY, cache.beginRead(), async () => current) + ).resolves.toBe(current) + expect(cache.has('index')).toBe(true) + cache.clear() + }) +}) + +describe('Kimi session index reader cache', () => { + it('releases a retained map when its index file disappears', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-kimi-index-delete-')) + tempDirs.push(root) + const indexPath = join(root, 'session_index.jsonl') + await writeFile(indexPath, `${JSON.stringify({ sessionId: 'session', workDir: '/repo' })}\n`) + + await readKimiWorkDirBySessionId(indexPath) + expect(hasKimiSessionIndexCacheEntryForTests(indexPath)).toBe(true) + await rm(indexPath) + + await expect(readKimiWorkDirBySessionId(indexPath)).resolves.toEqual(new Map()) + expect(hasKimiSessionIndexCacheEntryForTests(indexPath)).toBe(false) + }) + + it('invalidates when size changes even if the mtime is restored', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-kimi-index-mutation-')) + tempDirs.push(root) + const indexPath = join(root, 'session_index.jsonl') + await writeFile(indexPath, `${JSON.stringify({ sessionId: 'session-old', workDir: '/old' })}\n`) + const originalStat = await stat(indexPath) + + await expect(readKimiWorkDirBySessionId(indexPath)).resolves.toEqual( + new Map([['session-old', '/old']]) + ) + await writeFile( + indexPath, + `${JSON.stringify({ sessionId: 'session-old', workDir: '/old' })}\n${JSON.stringify({ sessionId: 'session-new', workDir: '/new' })}\n` + ) + await utimes(indexPath, originalStat.atime, originalStat.mtime) + + await expect(readKimiWorkDirBySessionId(indexPath)).resolves.toEqual( + new Map([ + ['session-old', '/old'], + ['session-new', '/new'] + ]) + ) + }) +}) diff --git a/src/main/ai-vault/session-scanner-kimi-index-cache.ts b/src/main/ai-vault/session-scanner-kimi-index-cache.ts new file mode 100644 index 00000000000..13ebe3c1426 --- /dev/null +++ b/src/main/ai-vault/session-scanner-kimi-index-cache.ts @@ -0,0 +1,132 @@ +export type KimiSessionIndexIdentity = { + changeTimeMs: number + mtimeMs: number + sizeBytes: number +} + +type KimiSessionIndexCacheEntry = { + expiresAt: number + generation: number + identity: KimiSessionIndexIdentity + timer: NodeJS.Timeout | null + value: Promise> +} + +export const KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS = 64 +// Active Vault scans refresh this window; closing the surface releases parsed +// index maps soon without making a live Kimi session reread on every scan. +export const KIMI_WORK_DIR_CACHE_TTL_MS = 5 * 60_000 + +export class KimiSessionIndexCache { + private readonly entries = new Map() + private minimumCacheGeneration = 0 + private nextGeneration = 0 + + beginRead(): number { + this.nextGeneration += 1 + return this.nextGeneration + } + + clear(): void { + for (const entry of this.entries.values()) { + if (entry.timer) { + clearTimeout(entry.timer) + } + } + this.entries.clear() + // Why: a read already awaiting stat/load when its owner clears the cache + // may finish later, but must not silently recreate the released entry. + this.minimumCacheGeneration = this.nextGeneration + 1 + } + + delete(indexPath: string, generation = Number.POSITIVE_INFINITY): void { + const entry = this.entries.get(indexPath) + if (entry && entry.generation <= generation) { + this.forget(indexPath, entry) + } + } + + get( + indexPath: string, + identity: KimiSessionIndexIdentity, + generation: number, + load: () => Promise> + ): Promise> { + if (generation < this.minimumCacheGeneration) { + return load() + } + const cached = this.entries.get(indexPath) + const now = Date.now() + if (cached && cached.expiresAt > now && identitiesMatch(cached.identity, identity)) { + this.remember(indexPath, cached, now) + return cached.value + } + if (cached && cached.generation > generation) { + // Why: a slower, older stat must not replace a newer file generation + // that another concurrent scan already cached for the same path. + return load() + } + + const entry: KimiSessionIndexCacheEntry = { + expiresAt: now + KIMI_WORK_DIR_CACHE_TTL_MS, + generation, + identity, + timer: null, + value: load() + } + this.remember(indexPath, entry, now) + return entry.value + } + + has(indexPath: string): boolean { + return this.entries.has(indexPath) + } + + get size(): number { + return this.entries.size + } + + private forget(indexPath: string, entry: KimiSessionIndexCacheEntry): void { + if (this.entries.get(indexPath) !== entry) { + return + } + if (entry.timer) { + clearTimeout(entry.timer) + } + this.entries.delete(indexPath) + } + + private remember(indexPath: string, entry: KimiSessionIndexCacheEntry, now: number): void { + const replaced = this.entries.get(indexPath) + if (replaced?.timer && replaced !== entry) { + clearTimeout(replaced.timer) + } + if (entry.timer) { + clearTimeout(entry.timer) + } + entry.expiresAt = now + KIMI_WORK_DIR_CACHE_TTL_MS + entry.timer = setTimeout(() => this.forget(indexPath, entry), KIMI_WORK_DIR_CACHE_TTL_MS) + entry.timer.unref() + this.entries.delete(indexPath) + this.entries.set(indexPath, entry) + + while (this.entries.size > KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS) { + const oldest = this.entries.entries().next().value + if (!oldest) { + return + } + this.forget(oldest[0], oldest[1]) + } + } +} + +function identitiesMatch( + left: KimiSessionIndexIdentity, + right: KimiSessionIndexIdentity +): boolean { + return ( + left.changeTimeMs === right.changeTimeMs && + left.mtimeMs === right.mtimeMs && + left.sizeBytes === right.sizeBytes + ) +} diff --git a/src/main/ai-vault/session-scanner-kimi-parser.test.ts b/src/main/ai-vault/session-scanner-kimi-parser.test.ts index bb268fbdcb1..67748ae727f 100644 --- a/src/main/ai-vault/session-scanner-kimi-parser.test.ts +++ b/src/main/ai-vault/session-scanner-kimi-parser.test.ts @@ -3,7 +3,12 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { parseKimiSessionFile } from './session-scanner-kimi-parser' -import { clearKimiSessionIndexCache } from './session-scanner-kimi-paths' +import { + clearKimiSessionIndexCache, + hasKimiSessionIndexCacheEntryForTests, + KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, + readKimiWorkDirBySessionId +} from './session-scanner-kimi-paths' import type { FileWithMtime } from './session-scanner-types' let tempDirs: string[] = [] @@ -218,4 +223,34 @@ describe('parseKimiSessionFile', () => { const session = await parseKimiSessionFile(file, 'darwin') expect(session?.title).toBe('do the thing') }) + + it('caps cached session index maps by recent index path', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-kimi-cache-')) + tempDirs.push(root) + const indexPaths: string[] = [] + + for (let index = 0; index <= KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS; index += 1) { + const home = join(root, `home-${index}`) + await mkdir(home, { recursive: true }) + const indexPath = join(home, 'session_index.jsonl') + await writeFile( + indexPath, + `${JSON.stringify({ sessionId: `session_${index}`, workDir: `/tmp/kimi-${index}` })}\n` + ) + indexPaths.push(indexPath) + } + + for (const indexPath of indexPaths.slice(0, KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS)) { + await readKimiWorkDirBySessionId(indexPath) + } + const refreshedFirst = await readKimiWorkDirBySessionId(indexPaths[0]) + expect(refreshedFirst.get('session_0')).toBe('/tmp/kimi-0') + await readKimiWorkDirBySessionId(indexPaths[KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS]) + + expect(hasKimiSessionIndexCacheEntryForTests(indexPaths[0])).toBe(true) + expect(hasKimiSessionIndexCacheEntryForTests(indexPaths[1])).toBe(false) + expect( + hasKimiSessionIndexCacheEntryForTests(indexPaths[KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS]) + ).toBe(true) + }) }) diff --git a/src/main/ai-vault/session-scanner-kimi-paths.ts b/src/main/ai-vault/session-scanner-kimi-paths.ts index db307cb22d0..7ed457f6c07 100644 --- a/src/main/ai-vault/session-scanner-kimi-paths.ts +++ b/src/main/ai-vault/session-scanner-kimi-paths.ts @@ -4,6 +4,13 @@ import { homedir } from 'node:os' import { basename, dirname, join } from 'node:path' import { createInterface } from 'node:readline' import { asRecord, extractString } from './session-scanner-values' +import { + KimiSessionIndexCache, + KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, + KIMI_WORK_DIR_CACHE_TTL_MS +} from './session-scanner-kimi-index-cache' + +export { KIMI_WORK_DIR_CACHE_MAX_INDEX_PATHS, KIMI_WORK_DIR_CACHE_TTL_MS } // Why: Kimi Code stores sessions under /sessions/, mirroring the // CLI's own `KIMI_CODE_HOME ?? ~/.kimi-code` resolution (see kimi-fetcher.ts). @@ -55,38 +62,41 @@ export function kimiPrimaryAgentWirePath( return join(dirname(statePath), 'agents', primaryId, 'wire.jsonl') } -type WorkDirCacheEntry = { - mtimeMs: number - map: Promise> -} - // Why: every session under one Kimi home shares a single session_index.jsonl. -// Re-reading it once per session would be O(n^2); memoize by path + mtime so a -// scan reads the index at most once and the cache self-invalidates when Kimi -// appends a new session (mtime bump). -const workDirCacheByIndexPath = new Map() +// Re-reading it once per session would be O(n^2); memoize by path + file +// identity so a scan reads the index at most once. Bound and expire entries +// because host/WSL/runtime roots can change during one main-process lifetime. +const workDirCacheByIndexPath = new KimiSessionIndexCache() export function clearKimiSessionIndexCache(): void { workDirCacheByIndexPath.clear() } +export function hasKimiSessionIndexCacheEntryForTests(indexPath: string): boolean { + return workDirCacheByIndexPath.has(indexPath) +} + export async function readKimiWorkDirBySessionId(indexPath: string): Promise> { - let mtimeMs: number + const generation = workDirCacheByIndexPath.beginRead() + let identity: Awaited> try { - mtimeMs = (await stat(indexPath)).mtimeMs + identity = await stat(indexPath) } catch { // Missing index (e.g. user deleted it): sessions still list, just without cwd. + workDirCacheByIndexPath.delete(indexPath, generation) return new Map() } - const cached = workDirCacheByIndexPath.get(indexPath) - if (cached && cached.mtimeMs === mtimeMs) { - return cached.map - } - - const map = parseKimiSessionIndex(indexPath) - workDirCacheByIndexPath.set(indexPath, { mtimeMs, map }) - return map + return workDirCacheByIndexPath.get( + indexPath, + { + changeTimeMs: identity.ctimeMs, + mtimeMs: identity.mtimeMs, + sizeBytes: identity.size + }, + generation, + () => parseKimiSessionIndex(indexPath) + ) } async function parseKimiSessionIndex(indexPath: string): Promise> { diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 4ce919cef6b..712659fa173 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -346,6 +346,88 @@ describe('CliInstaller', () => { expect(userPath).not.toContain(join(fixture.root, 'Programs', 'Orca', 'bin')) }) + it.each(['UnauthorizedAccessException', 'SecurityException'])( + 'rejects with a friendly message for Windows PATH denial: %s', + async (permissionMarker) => { + const fixture = await makeFixture() + const installPath = join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd') + 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: async () => 'C:\\Windows\\System32', + userPathWriter: async () => { + // The .NET error id survives localized or mojibake PowerShell output. + const error = new Error( + `Command failed: powershell -NoProfile -Command [Environment]::SetEnvironmentVariable('Path', '...', 'User')\nFullyQualifiedErrorId : ${permissionMarker},Microsoft.PowerShell.Commands` + ) + Object.assign(error, { code: 1 }) + throw error + } + }) + + const result = installer.install() + await expect(result).rejects.toThrow(/access denied|Group Policy|manually/i) + await expect(result).rejects.not.toThrow(/Command failed: powershell/) + await expect(result).rejects.toMatchObject({ + cause: expect.objectContaining({ + message: expect.stringContaining(permissionMarker) + }) + }) + } + ) + + it('skips the Windows PATH write when removing an absent entry', 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 () => 'C:\\Windows\\System32', + userPathWriter + }) + + await expect(installer.remove()).resolves.toMatchObject({ state: 'not_installed' }) + expect(userPathWriter).not.toHaveBeenCalled() + }) + + it.each([ + ['PowerShell timeout', 'Windows PATH command timed out after 5000ms.'], + [ + 'generic PowerShell method failure', + "Command failed: powershell -NoProfile -Command [Environment]::SetEnvironmentVariable('Path', '...', 'User')\nCategoryInfo : NotSpecified: (:) [], MethodInvocationException\nFullyQualifiedErrorId : MethodInvocationException" + ] + ])( + 'propagates a non-permission Windows PATH write error unchanged: %s', + async (_name, message) => { + const fixture = await makeFixture() + const installPath = join(fixture.root, 'Programs', 'Orca', 'bin', 'orca.cmd') + 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: async () => 'C:\\Windows\\System32', + userPathWriter: async () => { + throw new Error(message) + } + }) + + const result = installer.install() + await expect(result).rejects.toThrow(message) + await expect(result).rejects.not.toThrow(/Windows blocked updating your user PATH/) + } + ) + it('settles when the Windows PATH query hangs', async () => { vi.useFakeTimers() const fixture = await makeFixture() diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index 6765d8249b1..0705c4aaee5 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -823,7 +823,7 @@ export class CliInstaller { return } entries.push(pathDirectory) - await this.userPathWriter(entries.join(';')) + await this.writeWindowsUserPathEntry(entries.join(';'), pathDirectory, 'add') } private async removeWindowsPathEntry(pathDirectory: string): Promise { @@ -831,10 +831,36 @@ export class CliInstaller { return } const current = await this.userPathReader() - const nextEntries = splitPathEntries('win32', current).filter( - (entry) => !samePathEntry('win32', entry, pathDirectory) - ) - await this.userPathWriter(nextEntries.join(';')) + const entries = splitPathEntries('win32', current) + const nextEntries = entries.filter((entry) => !samePathEntry('win32', entry, pathDirectory)) + if (nextEntries.length === entries.length) { + return + } + await this.writeWindowsUserPathEntry(nextEntries.join(';'), pathDirectory, 'remove') + } + + // Why: raw PowerShell errors reach the UI, so translate denied PATH writes + // while preserving the original diagnostic as the error cause. + private async writeWindowsUserPathEntry( + value: string, + pathDirectory: string, + action: 'add' | 'remove' + ): Promise { + try { + await this.userPathWriter(value) + } catch (error) { + if (!isWindowsUserPathPermissionError(error)) { + throw error + } + const guidance = + action === 'add' + ? `Add this folder to your PATH manually: ${pathDirectory}. Or run Orca as an administrator and try again.` + : `Remove this folder from your PATH manually: ${pathDirectory}. Or run Orca as an administrator and try again.` + throw new Error( + `Windows blocked updating your user PATH (access denied). This usually means your PATH environment variable is managed by Group Policy or your organization's device management. ${guidance}`, + { cause: error } + ) + } } } @@ -1038,6 +1064,26 @@ function isMissingError(error: unknown): boolean { ) } +// Why: localized permission errors retain these .NET/ACL markers even when +// their human-readable PowerShell text is mojibake. +function isWindowsUserPathPermissionError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false + } + const stderr = + 'stderr' in error && typeof (error as { stderr?: unknown }).stderr === 'string' + ? (error as { stderr: string }).stderr + : '' + const haystack = `${error.message}\n${stderr}` + return ( + haystack.includes('UnauthorizedAccessException') || + haystack.includes('SecurityException') || + haystack.includes('Requested registry access is not allowed') || + haystack.includes('Access is denied') || + haystack.includes('Access to the registry key') + ) +} + function quoteShell(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'` } diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 9958e0fe867..d3c39b0b067 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -142,7 +142,6 @@ function createSettings(overrides: Partial = {}): GlobalSettings experimentalActivity: true, experimentalTerminalAttention: false, compactWorktreeCards: false, - experimentalWorktreeSymlinks: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index a7cf0d033e2..8c4779573fc 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -147,7 +147,6 @@ function createSettings(overrides: Partial = {}): GlobalSettings experimentalActivity: true, experimentalTerminalAttention: false, compactWorktreeCards: false, - experimentalWorktreeSymlinks: false, terminalWindowsShell: 'powershell.exe', terminalWindowsPowerShellImplementation: 'powershell.exe', enableGitHubAttribution: true, diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index ca21dd5e58b..99a35047743 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -9,10 +9,12 @@ const { spawnMock, isPwshAvailableMock, validateWorkingDirectoryMock, + resolveUnixShellPathMock, resolveAgentForegroundProcessMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), isPwshAvailableMock: vi.fn(), + resolveUnixShellPathMock: vi.fn((shellPath: string) => shellPath), resolveAgentForegroundProcessMock: vi.fn(), validateWorkingDirectoryMock: vi.fn((cwd: string) => { if (cwd.includes('definitely-missing')) { @@ -51,6 +53,7 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => { const actual = await importOriginal() return { ...actual, + resolveUnixShellPath: resolveUnixShellPathMock, validateWorkingDirectory: validateWorkingDirectoryMock } }) @@ -116,6 +119,8 @@ describe('createPtySubprocess', () => { async (_pid: number, fallbackProcess: string | null) => fallbackProcess ) validateWorkingDirectoryMock.mockClear() + resolveUnixShellPathMock.mockReset() + resolveUnixShellPathMock.mockImplementation((shellPath: string) => shellPath) isPwshAvailableMock.mockReturnValue(false) previousUserDataPath = process.env.ORCA_USER_DATA_PATH previousPowerlevelWizardDisable = process.env[POWERLEVEL10K_WIZARD_DISABLE_ENV] @@ -181,6 +186,117 @@ describe('createPtySubprocess', () => { ) }) + it('resolves a missing Unix default before spawning node-pty', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + resolveUnixShellPathMock.mockReturnValue('/bin/sh') + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const previousShell = process.env.SHELL + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + delete process.env.SHELL + + try { + createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24, env: {} }) + + expect(resolveUnixShellPathMock).toHaveBeenCalledWith('/bin/zsh') + expect(spawnMock).toHaveBeenCalledWith( + '/bin/sh', + ['-l'], + expect.objectContaining({ env: expect.objectContaining({ SHELL: '/bin/sh' }) }) + ) + expect(warn).toHaveBeenCalledWith( + '[daemon/pty] Preferred shell "/bin/zsh" is unavailable, fell back to "/bin/sh"' + ) + } finally { + warn.mockRestore() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + if (previousShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = previousShell + } + } + }) + + it('derives shell-ready launch config from the resolved fallback shell', () => { + const proc = mockPtyProcess() + spawnMock.mockReturnValue(proc) + resolveUnixShellPathMock.mockReturnValue('/bin/sh') + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + const previousShell = process.env.SHELL + const previousMarker = process.env.ORCA_SHELL_READY_MARKER + const previousZdotdir = process.env.ZDOTDIR + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + delete process.env.SHELL + // Why: the test runner itself can execute inside an Orca-wrapped shell + // whose exported wrapper vars would leak through the process.env spread. + delete process.env.ORCA_SHELL_READY_MARKER + delete process.env.ZDOTDIR + + try { + const handle = createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24, + env: {}, + command: 'echo hi' + }) + + expect(handle.shellPath).toBe('/bin/sh') + const [shellPath, shellArgs, spawnOptions] = spawnMock.mock.calls[0] + expect(shellPath).toBe('/bin/sh') + expect(shellArgs).toEqual(['-l']) + // A launch config derived from the missing preferred zsh would inject + // ZDOTDIR and ORCA_SHELL_READY_MARKER; /bin/sh must spawn without them. + expect(spawnOptions.env.ZDOTDIR).toBeUndefined() + expect(spawnOptions.env.ORCA_SHELL_READY_MARKER).toBeUndefined() + expect(spawnOptions.env.SHELL).toBe('/bin/sh') + } finally { + warn.mockRestore() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + if (previousShell === undefined) { + delete process.env.SHELL + } else { + process.env.SHELL = previousShell + } + if (previousMarker === undefined) { + delete process.env.ORCA_SHELL_READY_MARKER + } else { + process.env.ORCA_SHELL_READY_MARKER = previousMarker + } + if (previousZdotdir === undefined) { + delete process.env.ZDOTDIR + } else { + process.env.ZDOTDIR = previousZdotdir + } + } + }) + + it('surfaces the no-executable-shell error before node-pty forks', () => { + resolveUnixShellPathMock.mockImplementation(() => { + throw new Error('No executable Unix shell found (tried: /bin/zsh, /bin/bash, /bin/sh)') + }) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + + try { + expect(() => createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24, env: {} })).toThrow( + 'No executable Unix shell found' + ) + expect(spawnMock).not.toHaveBeenCalled() + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + it('uses bundled ConPTY for native Windows daemon terminals', () => { const proc = mockPtyProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 6a285301406..ce1b1faf35c 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -14,6 +14,7 @@ import { isValidPtySize, normalizePtySize } from './daemon-pty-size' import { ensureNodePtySpawnHelperExecutable, getNodePtySpawnHelperCandidates, + resolveUnixShellPath, validateWorkingDirectory } from '../providers/local-pty-utils' import { wrapShellSpawnForMacosTccAttribution } from '../providers/macos-tcc-login-shell' @@ -742,6 +743,17 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl if (opts.env?.TERM) { env.TERM = opts.env.TERM } + // Why after the scrub: SHELL must reflect the shell that actually spawns, + // matching LocalPtyProvider; and before the launch-config derivation below + // so shell-ready wrappers target the resolved shell, not a missing one. + const preferredShellPath = shellPath + shellPath = resolveUnixShellPath(shellPath) + if (shellPath !== preferredShellPath) { + env.SHELL = shellPath + console.warn( + `[daemon/pty] Preferred shell "${preferredShellPath}" is unavailable, fell back to "${shellPath}"` + ) + } // Why: OpenCode/Codex path restoration and OMP's typed-command status // wrapper need shell-ready code after user startup files run. let shellLaunch: ReturnType | null = null @@ -1007,6 +1019,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl return { pid: proc.pid, + shellPath, ...(startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}), getForegroundProcess: () => { // Why: node-pty's `.process` getter reports the PTY's live foreground diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 06404fd9246..b9c82a48174 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -48,6 +48,10 @@ export type SubprocessHandle = { /** True when shell launch args already delivered the startup command, so the * terminal host must skip its stdin fallback write. */ startupCommandDeliveredInShellArgs?: boolean + /** Shell the subprocess actually spawned, after Unix/Windows fallbacks. The + * host reconciles the caller's shell-ready assumption against it so a + * fallback shell without a ready marker never gates startup commands. */ + shellPath?: string write(data: string): void resize(cols: number, rows: number): void /** Stop reading the PTY fd (node-pty pause()) so the kernel/ConPTY buffer diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 909a75ddf1f..d602f5f7fa5 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -348,13 +348,16 @@ export function resolvePtyShellPath(env: Record): string { return env.SHELL || process.env.SHELL || '/bin/zsh' } +export function shellPathSupportsPtyStartupBarrier(shellPath: string): boolean { + const shellName = pathWin32.basename(basename(shellPath)).toLowerCase() + return shellName === 'zsh' || shellName === 'bash' +} + export function supportsPtyStartupBarrier(env: Record): boolean { if (process.platform === 'win32') { return false } - const resolvedShell = resolvePtyShellPath(env) - const shellName = pathWin32.basename(basename(resolvedShell)).toLowerCase() - return shellName === 'zsh' || shellName === 'bash' + return shellPathSupportsPtyStartupBarrier(resolvePtyShellPath(env)) } type ShellLaunchConfig = { diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index 4f92d4c1e1f..46e495e5f2e 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -3,7 +3,7 @@ import { Session, type SubprocessHandle } from './session' import { TerminalHost } from './terminal-host' function createMockSubprocess( - options: { startupCommandDeliveredInShellArgs?: boolean } = {} + options: { startupCommandDeliveredInShellArgs?: boolean; shellPath?: string } = {} ): SubprocessHandle { let onDataCb: ((data: string) => void) | null = null let onExitCb: ((code: number) => void) | null = null @@ -12,6 +12,7 @@ function createMockSubprocess( ...(options.startupCommandDeliveredInShellArgs ? { startupCommandDeliveredInShellArgs: true } : {}), + ...(options.shellPath ? { shellPath: options.shellPath } : {}), getForegroundProcess: vi.fn(() => null), write: vi.fn(), resize: vi.fn(), @@ -193,6 +194,88 @@ describe('TerminalHost', () => { } }) + it('delivers startup commands immediately when the spawned shell cannot emit the ready marker', async () => { + spawnFn = vi.fn(() => { + const sub = createMockSubprocess({ shellPath: '/bin/sh' }) as ReturnType< + typeof createMockSubprocess + > & { + _onDataCb: ((data: string) => void) | null + _onExitCb: ((code: number) => void) | null + } + lastSubprocess = sub + return sub + }) + host.dispose() + host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) + + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + command: 'echo hello', + shellReadySupported: true, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(lastSubprocess.write).toHaveBeenCalledWith( + process.platform === 'win32' ? 'echo hello\r' : 'echo hello\n' + ) + }) + + it('does not bracketed-paste-wrap multiline commands for a fallback shell without paste mode', async () => { + spawnFn = vi.fn(() => { + const sub = createMockSubprocess({ shellPath: '/bin/sh' }) as ReturnType< + typeof createMockSubprocess + > & { + _onDataCb: ((data: string) => void) | null + _onExitCb: ((code: number) => void) | null + } + lastSubprocess = sub + return sub + }) + host.dispose() + host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) + + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + command: 'claude "line one\nline two"', + shellReadySupported: true, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + const written = (lastSubprocess.write as ReturnType).mock.calls[0]?.[0] + expect(written).not.toContain('\x1b[200~') + expect(written).toContain('line one\nline two') + }) + + it('keeps the shell-ready barrier when the spawned shell supports the marker', async () => { + spawnFn = vi.fn(() => { + const sub = createMockSubprocess({ shellPath: '/bin/bash' }) as ReturnType< + typeof createMockSubprocess + > & { + _onDataCb: ((data: string) => void) | null + _onExitCb: ((code: number) => void) | null + } + lastSubprocess = sub + return sub + }) + host.dispose() + host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn }) + + await host.createOrAttach({ + sessionId: 'session-1', + cols: 80, + rows: 24, + command: 'echo hello', + shellReadySupported: true, + streamClient: { onData: vi.fn(), onExit: vi.fn() } + }) + + expect(lastSubprocess.write).not.toHaveBeenCalled() + }) + it('does not write startup commands already embedded in shell args', async () => { spawnFn = vi.fn(() => { const sub = createMockSubprocess({ diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 203719d354e..b15420fed14 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -1,5 +1,6 @@ import { Session, type SubprocessHandle } from './session' import { normalizePtySize } from './daemon-pty-size' +import { shellPathSupportsPtyStartupBarrier } from './shell-ready' import { resolveProcessCwd } from '../providers/process-cwd' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import { buildStartupCommandSubmission } from '../../shared/startup-command-submission' @@ -104,6 +105,16 @@ export class TerminalHost { terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation }) + // Why: the caller computed shellReadySupported from the preferred shell, + // before spawn. A Unix fallback (e.g. /bin/sh) never emits the ready + // marker, so keeping the stale flag would queue startup commands until the + // shell-ready timeout and bracketed-paste-wrap them for a line editor + // without paste mode. + const shellReadySupported = + (opts.shellReadySupported ?? false) && + (subprocess.shellPath === undefined || + shellPathSupportsPtyStartupBarrier(subprocess.shellPath)) + const session = new Session({ sessionId: opts.sessionId, cols: size.cols, @@ -111,7 +122,7 @@ export class TerminalHost { terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, launchAgent: opts.launchAgent, subprocess, - shellReadySupported: opts.shellReadySupported ?? false, + shellReadySupported, historySeed: opts.historySeed, // Why: reap the dead session (dispose emulator + drop from the map) the // moment its subprocess exits, instead of retaining it for the daemon's @@ -143,7 +154,7 @@ export class TerminalHost { session.write( buildStartupCommandSubmission(opts.command, { submit, - bracketedPasteSafe: opts.shellReadySupported ?? false + bracketedPasteSafe: shellReadySupported }) ) } diff --git a/src/main/git/worktree.test.ts b/src/main/git/worktree.test.ts index 54216ab9ad7..035a7f77405 100644 --- a/src/main/git/worktree.test.ts +++ b/src/main/git/worktree.test.ts @@ -23,6 +23,8 @@ import { clearGitCapabilityStateForTests } from './git-capability-state' import { addSparseWorktree, addWorktree, + _getWorktreeScanCacheSizesForTests, + _resetWorktreeScanCacheForTests, listWorktreeGraph, listWorktrees, moveWorktree, @@ -36,7 +38,13 @@ beforeEach(() => { }) describe('listWorktrees in-flight sharing', () => { - beforeEach(() => { + beforeEach(async () => { + gitExecFileAsyncMock.mockReset() + _resetWorktreeScanCacheForTests() + // Why: capability discovery serializes same-host callers; warming it lets + // these tests isolate the scan-generation overlap they are exercising. + gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + await listWorktrees('/capability-warmup') gitExecFileAsyncMock.mockReset() }) @@ -87,6 +95,126 @@ describe('listWorktrees in-flight sharing', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) }) + it('does not retain scan generations after mutations without active scans', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + + await Promise.all( + Array.from({ length: 128 }, (_, index) => + moveWorktree(`/repo-${index}`, `/repo-${index}-old`, `/repo-${index}-new`) + ) + ) + + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 }) + }) + + it('cleans mutation generations after stale scans settle and supports repo reuse', async () => { + const scanResolvers: ((stdout: string) => void)[] = [] + let listCalls = 0 + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args[0] === 'worktree' && args[1] === 'list') { + listCalls += 1 + return new Promise((resolve) => { + scanResolvers.push((stdout) => resolve({ stdout })) + }) + } + return Promise.resolve({ stdout: '' }) + }) + + const staleScan = listWorktrees('/repo') + expect(scanResolvers).toHaveLength(1) + + await moveWorktree('/repo', '/repo-old', '/repo-new') + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 1, generations: 1 }) + + const freshScan = listWorktrees('/repo') + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 }) + + scanResolvers[1]?.('worktree /repo-new\nHEAD fresh\nbranch refs/heads/main\n') + expect((await freshScan)[0]?.path).toBe('/repo-new') + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 1, generations: 1 }) + + scanResolvers[0]?.('worktree /repo\nHEAD stale\nbranch refs/heads/main\n') + expect((await staleScan)[0]?.path).toBe('/repo') + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 }) + + const firstReuse = listWorktrees('/repo') + const secondReuse = listWorktrees('/repo') + expect(listCalls).toBe(3) + + scanResolvers[2]?.('worktree /repo-reused\nHEAD reused\nbranch refs/heads/main\n') + const [firstResult, secondResult] = await Promise.all([firstReuse, secondReuse]) + expect(firstResult).toEqual(secondResult) + expect(firstResult[0]?.path).toBe('/repo-reused') + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 }) + }) + + it('retires every overlapping scan generation across repeated mutations', async () => { + const scanResolvers: (() => void)[] = [] + let listCalls = 0 + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args[0] === 'worktree' && args[1] === 'list') { + listCalls += 1 + return new Promise((resolve) => { + scanResolvers.push(() => resolve({ stdout: '' })) + }) + } + return Promise.resolve({ stdout: '' }) + }) + + const oldestScan = listWorktrees('/repo') + await moveWorktree('/repo', '/old-0', '/new-0') + const middleScan = listWorktrees('/repo') + await moveWorktree('/repo', '/old-1', '/new-1') + const newestScan = listWorktrees('/repo') + + expect(listCalls).toBe(3) + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 3, generations: 1 }) + + scanResolvers[0]?.() + scanResolvers[2]?.() + scanResolvers[1]?.() + await Promise.all([oldestScan, middleScan, newestScan]) + + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 }) + }) + + it('keeps WSL scans distinct while retiring both after a mutation', async () => { + const scanResolvers: (() => void)[] = [] + gitExecFileAsyncMock.mockResolvedValue({ stdout: '' }) + await Promise.all([ + listWorktrees('/capability-warmup', { wslDistro: 'Ubuntu' }), + listWorktrees('/capability-warmup', { wslDistro: 'Debian' }) + ]) + gitExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args[0] === 'worktree' && args[1] === 'list') { + return new Promise((resolve) => { + scanResolvers.push(() => resolve({ stdout: '' })) + }) + } + return Promise.resolve({ stdout: '' }) + }) + + const staleUbuntu = listWorktrees('/repo', { wslDistro: 'Ubuntu' }) + const staleDebian = listWorktrees('/repo', { wslDistro: 'Debian' }) + expect(scanResolvers).toHaveLength(2) + + await removeWorktree('/repo', '/worktree', false, { + knownRemovedWorktree: { branch: '', head: '' }, + wslDistro: 'Ubuntu' + }) + const freshUbuntu = listWorktrees('/repo', { wslDistro: 'Ubuntu' }) + const freshDebian = listWorktrees('/repo', { wslDistro: 'Debian' }) + expect(scanResolvers).toHaveLength(4) + + for (const resolve of scanResolvers) { + resolve() + } + await Promise.all([staleUbuntu, staleDebian, freshUbuntu, freshDebian]) + + expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 0, generations: 0 }) + }) + it('does not share scans for callers with an AbortSignal', async () => { const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' gitExecFileAsyncMock.mockResolvedValue({ stdout: scanOutput }) diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 37c017c04d5..c9175b3dcb4 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -650,10 +650,48 @@ const inFlightWorktreeScans = new Map>() // settle for their original callers). const worktreeScanGenerations = new Map() +function hasInFlightWorktreeScanForRepo(repoPath: string): boolean { + const keyPrefix = `${repoPath}\0` + for (const key of inFlightWorktreeScans.keys()) { + if (key.startsWith(keyPrefix)) { + return true + } + } + return false +} + function bumpWorktreeScanGeneration(repoPath: string): void { + // Why: generations only prevent joining a pre-mutation scan. Without an + // active scan, retaining the repo path just leaks completed mutation keys. + if (!hasInFlightWorktreeScanForRepo(repoPath)) { + return + } worktreeScanGenerations.set(repoPath, (worktreeScanGenerations.get(repoPath) ?? 0) + 1) } +function pruneWorktreeScanGeneration(repoPath: string): void { + // Why: ordinary scan settlement should stay O(1); only repos invalidated + // during an active scan need the cross-generation in-flight check. + if (!worktreeScanGenerations.has(repoPath)) { + return + } + if (!hasInFlightWorktreeScanForRepo(repoPath)) { + worktreeScanGenerations.delete(repoPath) + } +} + +export function _getWorktreeScanCacheSizesForTests(): { inFlight: number; generations: number } { + return { + inFlight: inFlightWorktreeScans.size, + generations: worktreeScanGenerations.size + } +} + +export function _resetWorktreeScanCacheForTests(): void { + inFlightWorktreeScans.clear() + worktreeScanGenerations.clear() +} + /** * List all worktrees for a git repo at the given path. Concurrent calls for * the same repo share one scan (unless the caller passes an AbortSignal, @@ -676,6 +714,7 @@ export function listWorktrees( if (inFlightWorktreeScans.get(key) === scan) { inFlightWorktreeScans.delete(key) } + pruneWorktreeScanGeneration(repoPath) }) inFlightWorktreeScans.set(key, scan) return scan diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 5363cb56c60..c0a3c58be37 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -118,6 +118,7 @@ import { updatePRState, updatePRTitle, _getMergeQueueCacheSizeForTests, + _getTrackedUpstreamBranchCacheSizesForTests, _resetOwnerRepoCache, _resetMergeQueueCacheForTests, __resetTrackedUpstreamBranchCacheForTests @@ -1786,6 +1787,119 @@ describe('getPRForBranch', () => { expect(trackedUpstreamCalls).toHaveLength(1) }) + it('releases tracked-upstream probe generations across runtime identities', async () => { + const sshGitProvider = { + exec: vi.fn().mockResolvedValue({ stdout: 'feature\0\n', stderr: '' }) + } + getSshGitProviderMock.mockReturnValue(sshGitProvider) + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'feature\0\n', stderr: '' }) + + await getPRForBranch('/repo-root', 'feature') + await getPRForBranch('/repo-root', 'feature', null, null, null, { + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + await getPRForBranch('/repo-root', 'feature', null, 'ssh-1') + + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 3, + inFlight: 0, + generations: 0 + }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(sshGitProvider.exec).toHaveBeenCalledTimes(1) + }) + + it('bounds unique tracked-upstream snapshots and sweeps expired identities', async () => { + vi.useFakeTimers() + try { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'feature\0\n', stderr: '' }) + + for (let index = 0; index < 513; index += 1) { + await getPRForBranch(`/repo-root-${index}`, 'feature') + } + await getPRForBranch('/repo-root-512', 'feature') + + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 512, + inFlight: 0, + generations: 0 + }) + expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(513) + + await vi.advanceTimersByTimeAsync(30_001) + await getPRForBranch('/repo-root-fresh', 'feature') + + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 1, + inFlight: 0, + generations: 0 + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps stale probe cleanup from releasing a replacement generation', async () => { + let resolveOldProbe: (value: { stdout: string; stderr: string }) => void + let resolveCurrentProbe: (value: { stdout: string; stderr: string }) => void + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + gitExecFileAsyncMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveOldProbe = resolve + }) + ) + .mockResolvedValueOnce({ stdout: 'replacement\0\n', stderr: '' }) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCurrentProbe = resolve + }) + ) + + const oldLookup = getPRForBranch('/repo-root', 'old') + await vi.waitFor(() => expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)) + __resetTrackedUpstreamBranchCacheForTests() + await getPRForBranch('/repo-root', 'replacement') + const currentLookup = getPRForBranch('/repo-root', 'current') + await vi.waitFor(() => expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(3)) + + resolveOldProbe!({ stdout: 'old\0\n', stderr: '' }) + await oldLookup + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 0, + inFlight: 1, + generations: 1 + }) + + resolveCurrentProbe!({ stdout: 'current\0\n', stderr: '' }) + await currentLookup + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 1, + inFlight: 0, + generations: 0 + }) + }) + + it('releases tracked-upstream probe state when setup rejects', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) + readLocalGitConfigSignatureMock.mockRejectedValue(new Error('git config unavailable')) + + await expect(getPRForBranch('/repo-root', 'feature')).resolves.toBeNull() + expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ + snapshots: 0, + inFlight: 0, + generations: 0 + }) + }) + it('does not fan out tracked-upstream probes after a transient for-each-ref failure', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [{ owner: 'acme', repo: 'widgets' }], diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 6e4005421a4..b4702f28128 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -2392,6 +2392,7 @@ type TrackedUpstreamBranch = { } const TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS = 30_000 +const TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES = 512 type TrackedUpstreamSnapshotCacheEntry = { expiresAt: number @@ -2411,12 +2412,49 @@ const trackedUpstreamSnapshotInFlight = new Map< string, Promise >() -const trackedUpstreamSnapshotGenerations = new Map() +const trackedUpstreamSnapshotGenerations = new Map() -function beginTrackedUpstreamSnapshotProbe(cacheKey: string): number { - const nextGeneration = (trackedUpstreamSnapshotGenerations.get(cacheKey) ?? 0) + 1 - trackedUpstreamSnapshotGenerations.set(cacheKey, nextGeneration) - return nextGeneration +function beginTrackedUpstreamSnapshotProbe(cacheKey: string): symbol { + const generation = Symbol() + trackedUpstreamSnapshotGenerations.set(cacheKey, generation) + return generation +} + +function finishTrackedUpstreamSnapshotProbe(cacheKey: string, generation: symbol): void { + // Why: generations only guard an active probe; retaining completed repo keys + // leaks worktree/runtime identities after the short-lived snapshot TTL expires. + if (trackedUpstreamSnapshotGenerations.get(cacheKey) === generation) { + trackedUpstreamSnapshotGenerations.delete(cacheKey) + } +} + +function pruneTrackedUpstreamSnapshotCache(now: number): void { + for (const [cacheKey, cached] of trackedUpstreamSnapshotCache) { + if (cached.expiresAt <= now) { + trackedUpstreamSnapshotCache.delete(cacheKey) + } + } + // Why: workspace/runtime churn can create unbounded unique keys within one + // TTL window, so expiry sweeping alone is not a memory bound. + while (trackedUpstreamSnapshotCache.size > TRACKED_UPSTREAM_SNAPSHOT_CACHE_MAX_ENTRIES) { + const oldestKey = trackedUpstreamSnapshotCache.keys().next().value + if (oldestKey === undefined) { + break + } + trackedUpstreamSnapshotCache.delete(oldestKey) + } +} + +export function _getTrackedUpstreamBranchCacheSizesForTests(): { + snapshots: number + inFlight: number + generations: number +} { + return { + snapshots: trackedUpstreamSnapshotCache.size, + inFlight: trackedUpstreamSnapshotInFlight.size, + generations: trackedUpstreamSnapshotGenerations.size + } } export function __resetTrackedUpstreamBranchCacheForTests(): void { @@ -2510,6 +2548,7 @@ async function getTrackedUpstreamBranch( upstreamsByBranchName: getCacheableTrackedUpstreamSnapshot(result.upstreamsByBranchName), expiresAt: Date.now() + TRACKED_UPSTREAM_SNAPSHOT_CACHE_TTL_MS }) + pruneTrackedUpstreamSnapshotCache(Date.now()) } if (trackedUpstreamSnapshotGenerations.get(cacheKey) !== probeGeneration) { const fresherCached = trackedUpstreamSnapshotCache.get(cacheKey) @@ -2522,6 +2561,7 @@ async function getTrackedUpstreamBranch( if (trackedUpstreamSnapshotInFlight.get(cacheKey) === probe) { trackedUpstreamSnapshotInFlight.delete(cacheKey) } + finishTrackedUpstreamSnapshotProbe(cacheKey, probeGeneration) } } diff --git a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts new file mode 100644 index 00000000000..53588814848 --- /dev/null +++ b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts @@ -0,0 +1,228 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handlers, listeners, subscribeTranscript } = vi.hoisted(() => ({ + handlers: new Map unknown>(), + listeners: new Map unknown>(), + subscribeTranscript: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + handlers.set(channel, handler) + }), + on: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => { + listeners.set(channel, handler) + }) + } +})) + +vi.mock('../native-chat/transcript-watch', () => ({ + subscribeNativeChatTranscript: subscribeTranscript +})) + +import { + _getNativeChatPendingSubscriptionCountForTest, + _getNativeChatSenderCleanupCountForTest, + clearNativeChatSubscriptions, + registerNativeChatHandlers +} from './native-chat' + +type TestSubscription = { + unsubscribe: ReturnType +} + +type DeferredSubscription = { + promise: Promise + reject: (error: Error) => void + resolve: () => void + unsubscribe: ReturnType +} + +type SenderHarness = { + destroy: () => void + registeredCleanupCount: () => number + sender: { + id: number + isDestroyed: () => boolean + once: (event: string, callback: () => void) => void + send: ReturnType + } +} + +beforeEach(() => { + clearNativeChatSubscriptions() + handlers.clear() + listeners.clear() + subscribeTranscript.mockReset() + registerNativeChatHandlers() +}) + +function deferredSubscription(): DeferredSubscription { + const unsubscribe = vi.fn() + let resolvePromise: (subscription: TestSubscription) => void = () => {} + let rejectPromise: (error: Error) => void = () => {} + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + }) + return { + promise, + reject: rejectPromise, + resolve: () => resolvePromise({ unsubscribe }), + unsubscribe + } +} + +function createSender(id: number): SenderHarness { + let destroyed = false + const destroyedCallbacks: (() => void)[] = [] + return { + destroy: () => { + destroyed = true + for (const callback of destroyedCallbacks) { + callback() + } + }, + registeredCleanupCount: () => destroyedCallbacks.length, + sender: { + id, + isDestroyed: () => destroyed, + once: (event, callback) => { + if (event === 'destroyed') { + destroyedCallbacks.push(callback) + } + }, + send: vi.fn() + } + } +} + +function subscribe(sender: SenderHarness['sender'], subscriptionId: string): void { + const listener = listeners.get('nativeChat:subscribe') + if (!listener) { + throw new Error('subscribe listener not registered') + } + listener({ sender }, { subscriptionId, agent: 'claude', sessionId: `session-${subscriptionId}` }) +} + +function unsubscribe(sender: SenderHarness['sender'], subscriptionId: string): void { + const listener = listeners.get('nativeChat:unsubscribe') + if (!listener) { + throw new Error('unsubscribe listener not registered') + } + listener({ sender }, { subscriptionId }) +} + +async function waitFor(predicate: () => boolean, timeoutMs = 1000): Promise { + const startedAt = Date.now() + while (!predicate()) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error('timed out waiting for lifecycle state') + } + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +describe('nativeChat subscribe lifecycle', () => { + it('closes a watcher that resolves after renderer unsubscribe', async () => { + const pending = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(pending.promise) + const renderer = createSender(1) + + subscribe(renderer.sender, 'unmount') + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(1) + unsubscribe(renderer.sender, 'unmount') + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(0) + unsubscribe(renderer.sender, 'unmount') + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(0) + + pending.resolve() + await waitFor(() => pending.unsubscribe.mock.calls.length === 1) + unsubscribe(renderer.sender, 'unmount') + expect(pending.unsubscribe).toHaveBeenCalledOnce() + renderer.destroy() + expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) + }) + + it('closes a watcher that resolves after renderer destruction', async () => { + const pending = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(pending.promise) + const renderer = createSender(2) + + subscribe(renderer.sender, 'destroy') + renderer.destroy() + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(0) + expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) + + pending.resolve() + await waitFor(() => pending.unsubscribe.mock.calls.length === 1) + }) + + it('keeps the latest same-id subscribe when setup resolves in reverse order', async () => { + const older = deferredSubscription() + const newer = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise) + const renderer = createSender(3) + + subscribe(renderer.sender, 'same-id') + subscribe(renderer.sender, 'same-id') + expect(_getNativeChatPendingSubscriptionCountForTest()).toBe(1) + + newer.resolve() + await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0) + expect(newer.unsubscribe).not.toHaveBeenCalled() + older.resolve() + await waitFor(() => older.unsubscribe.mock.calls.length === 1) + + unsubscribe(renderer.sender, 'same-id') + expect(newer.unsubscribe).toHaveBeenCalledOnce() + renderer.destroy() + }) + + it('clears rejected setup without duplicating sender cleanup registration', async () => { + const failed = deferredSubscription() + const retry = deferredSubscription() + subscribeTranscript.mockReturnValueOnce(failed.promise).mockReturnValueOnce(retry.promise) + const renderer = createSender(4) + + subscribe(renderer.sender, 'retry') + failed.reject(new Error('watch setup failed')) + await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0) + expect(_getNativeChatSenderCleanupCountForTest()).toBe(1) + + subscribe(renderer.sender, 'retry') + expect(renderer.registeredCleanupCount()).toBe(1) + retry.resolve() + await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0) + unsubscribe(renderer.sender, 'retry') + expect(retry.unsubscribe).toHaveBeenCalledOnce() + renderer.destroy() + expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) + }) + + it('isolates late setup from a replacement renderer reusing the sender id', async () => { + const oldPending = deferredSubscription() + const replacementPending = deferredSubscription() + subscribeTranscript + .mockReturnValueOnce(oldPending.promise) + .mockReturnValueOnce(replacementPending.promise) + const oldRenderer = createSender(41) + const replacementRenderer = createSender(41) + + subscribe(oldRenderer.sender, 'remount') + oldRenderer.destroy() + subscribe(replacementRenderer.sender, 'remount') + expect(replacementRenderer.registeredCleanupCount()).toBe(1) + + replacementPending.resolve() + await waitFor(() => _getNativeChatPendingSubscriptionCountForTest() === 0) + oldPending.resolve() + await waitFor(() => oldPending.unsubscribe.mock.calls.length === 1) + expect(replacementPending.unsubscribe).not.toHaveBeenCalled() + + replacementRenderer.destroy() + expect(replacementPending.unsubscribe).toHaveBeenCalledOnce() + expect(_getNativeChatSenderCleanupCountForTest()).toBe(0) + }) +}) diff --git a/src/main/ipc/native-chat.test.ts b/src/main/ipc/native-chat.test.ts index fd09a14ff3f..d16a39b1f13 100644 --- a/src/main/ipc/native-chat.test.ts +++ b/src/main/ipc/native-chat.test.ts @@ -20,6 +20,7 @@ vi.mock('electron', () => ({ })) import { + _getNativeChatSenderCleanupCountForTest, clearNativeChatSubscriptions, clearNativeChatTranscriptCache, registerNativeChatHandlers @@ -238,6 +239,67 @@ describe('nativeChat:readSession handler', () => { } }) + it('drops cleanup registration when sender is destroyed before subscribe completes', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-destroy-race-')) + tempRoots.push(root) + const projectDir = join(root, '.claude', 'projects', '-repo') + await mkdir(projectDir, { recursive: true }) + await writeFile( + join(projectDir, 'sess-race.jsonl'), + `${jsonLines([ + { + type: 'user', + uuid: 'u-race', + timestamp: '2026-06-01T10:00:00.000Z', + message: { role: 'user', content: 'Race' } + } + ])}\n` + ) + + registerNativeChatHandlers() + const subscribe = listeners.get('nativeChat:subscribe') + expect(subscribe).toBeDefined() + + let destroyed = false + let destroyedCb: (() => void) | undefined + const sender = { + id: 41, + isDestroyed: () => destroyed, + once: (event: string, cb: () => void) => { + if (event === 'destroyed') { + destroyedCb = cb + } + }, + send: vi.fn() + } + + const previousHome = process.env.HOME + process.env.HOME = root + try { + subscribe!( + { sender }, + { + subscriptionId: 'sub-race', + agent: 'claude', + sessionId: 'sess-race' + } + ) + + expect(destroyedCb).toBeDefined() + destroyed = true + destroyedCb!() + + await waitFor(() => _getNativeChatSenderCleanupCountForTest() === 0) + expect(sender.send).not.toHaveBeenCalled() + } finally { + if (previousHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = previousHome + } + } + }) + it('returns an error for an unknown session without throwing', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-missing-')) tempRoots.push(root) diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index ad6117db0c1..3ed4e9b74a6 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -71,9 +71,17 @@ type LiveSubscription = { // same renderer can watch several panes, and a destroyed window tears down all // of its watchers — strict teardown to avoid fd leaks (plan U4 risk). const liveSubscriptions = new Map>() +// Why: unsubscribe and renderer destruction must invalidate async watcher setup +// before it can publish a late subscription into the live map. +const pendingSubscriptions = new Map>() const senderCleanupRegistered = new Set() function teardownSubscription(senderId: number, subscriptionId: string): void { + const pendingBySubId = pendingSubscriptions.get(senderId) + pendingBySubId?.delete(subscriptionId) + if (pendingBySubId?.size === 0) { + pendingSubscriptions.delete(senderId) + } const bySubId = liveSubscriptions.get(senderId) const live = bySubId?.get(subscriptionId) if (!live || !bySubId) { @@ -87,6 +95,9 @@ function teardownSubscription(senderId: number, subscriptionId: string): void { } function teardownAllForSender(senderId: number): void { + // The destroyed event can arrive before async subscription setup stores a watcher. + senderCleanupRegistered.delete(senderId) + pendingSubscriptions.delete(senderId) const bySubId = liveSubscriptions.get(senderId) if (!bySubId) { return @@ -95,7 +106,6 @@ function teardownAllForSender(senderId: number): void { live.subscription.unsubscribe() } liveSubscriptions.delete(senderId) - senderCleanupRegistered.delete(senderId) } function registerSenderCleanup(sender: WebContents): void { @@ -107,6 +117,27 @@ function registerSenderCleanup(sender: WebContents): void { sender.once('destroyed', () => teardownAllForSender(sender.id)) } +function beginPendingSubscription(senderId: number, subscriptionId: string): symbol { + teardownSubscription(senderId, subscriptionId) + const token = Symbol(subscriptionId) + const bySubId = pendingSubscriptions.get(senderId) ?? new Map() + bySubId.set(subscriptionId, token) + pendingSubscriptions.set(senderId, bySubId) + return token +} + +function takePendingSubscription(senderId: number, subscriptionId: string, token: symbol): boolean { + const bySubId = pendingSubscriptions.get(senderId) + if (bySubId?.get(subscriptionId) !== token) { + return false + } + bySubId.delete(subscriptionId) + if (bySubId.size === 0) { + pendingSubscriptions.delete(senderId) + } + return true +} + async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArgs): Promise { const sender = event.sender if (sender.isDestroyed()) { @@ -114,26 +145,32 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg } const { subscriptionId, agent, sessionId, transcriptPath } = args // Replace any prior subscription under the same id (session change/resubscribe). - teardownSubscription(sender.id, subscriptionId) + const pendingToken = beginPendingSubscription(sender.id, subscriptionId) registerSenderCleanup(sender) - const subscription = await subscribeNativeChatTranscript({ - agent, - sessionId, - transcriptPath, - onAppend: (messages) => { - if (sender.isDestroyed()) { - return + let subscription: NativeChatTranscriptSubscription + try { + subscription = await subscribeNativeChatTranscript({ + agent, + sessionId, + transcriptPath, + onAppend: (messages) => { + if (sender.isDestroyed()) { + return + } + const payload: NativeChatAppendedPayload = { subscriptionId, messages } + sender.send('nativeChat:appended', payload) } - const payload: NativeChatAppendedPayload = { subscriptionId, messages } - sender.send('nativeChat:appended', payload) - } - }) + }) + } catch { + takePendingSubscription(sender.id, subscriptionId, pendingToken) + return + } - // The window may have gone away (or the subscription been replaced) while we - // resolved the file path — don't register a now-orphaned watcher. - const stillCurrent = !sender.isDestroyed() - if (!stillCurrent) { + // Why: unmount, destruction, or a newer same-id subscribe can invalidate setup + // while path resolution is pending; only the owning token may publish its watcher. + const stillCurrent = takePendingSubscription(sender.id, subscriptionId, pendingToken) + if (sender.isDestroyed() || !stillCurrent) { subscription.unsubscribe() return } @@ -147,12 +184,26 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg liveSubscriptions.set(sender.id, bySubId) } -/** Test-only: drop all live transcript subscriptions between runs. */ +/** Test-only: drop all live and pending transcript subscriptions between runs. */ export function clearNativeChatSubscriptions(): void { - const senderIds = Array.from(liveSubscriptions.keys()) + const senderIds = new Set([...liveSubscriptions.keys(), ...pendingSubscriptions.keys()]) for (const senderId of senderIds) { teardownAllForSender(senderId) } + pendingSubscriptions.clear() + senderCleanupRegistered.clear() +} + +export function _getNativeChatSenderCleanupCountForTest(): number { + return senderCleanupRegistered.size +} + +export function _getNativeChatPendingSubscriptionCountForTest(): number { + let count = 0 + for (const bySubId of pendingSubscriptions.values()) { + count += bySubId.size + } + return count } export function registerNativeChatHandlers(): void { diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 751245fb62d..94b047d8968 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -3221,6 +3221,40 @@ describe('registerPtyHandlers', () => { }) }) + it('routes runtime foreground confirmation to the provider owning the captured PTY', async () => { + const confirmForegroundProcess = vi.fn(async () => 'codex') + registerSshPtyProvider('ssh-1', { confirmForegroundProcess } as never) + setPtyOwnership('remote-pty', 'ssh-1') + const runtime = { setPtyController: vi.fn() } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + confirmForegroundProcess: (ptyId: string) => Promise + } + + await expect(controller.confirmForegroundProcess('remote-pty')).resolves.toBe('codex') + expect(confirmForegroundProcess).toHaveBeenCalledOnce() + expect(confirmForegroundProcess).toHaveBeenCalledWith('remote-pty') + deletePtyOwnership('remote-pty') + }) + + it('returns unavailable runtime confirmation for unsupported or missing providers', async () => { + registerSshPtyProvider('ssh-1', {} as never) + setPtyOwnership('unsupported-pty', 'ssh-1') + setPtyOwnership('missing-pty', 'missing-connection') + const runtime = { setPtyController: vi.fn() } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as { + confirmForegroundProcess: (ptyId: string) => Promise + } + + await expect(controller.confirmForegroundProcess('unsupported-pty')).resolves.toBeNull() + await expect(controller.confirmForegroundProcess('missing-pty')).resolves.toBeNull() + deletePtyOwnership('unsupported-pty') + deletePtyOwnership('missing-pty') + }) + it('rethrows non-not-found local provider shutdown failures', async () => { setLocalPtyProvider({ spawn: vi.fn(), diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 4dcc451324a..493cc494058 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -3397,6 +3397,15 @@ export function registerPtyHandlers( return null } }, + confirmForegroundProcess: async (ptyId) => { + try { + const provider = getProviderForPty(ptyId) + // Why: cached foreground evidence cannot resolve a fresh shell conflict. + return (await provider.confirmForegroundProcess?.(ptyId)) ?? null + } catch { + return null + } + }, getCwd: async (ptyId) => { try { const cwd = await getProviderForPty(ptyId).getCwd(ptyId) diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index e0fd8ee8fbc..6a5db1be3de 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -1903,8 +1903,8 @@ export async function createRemoteWorktree( }) const workspaceLineage = recordWorkspaceLineageForCreatedWorktree(store, args, worktree, now) - // Why: `experimentalWorktreeSymlinks` is intentionally not wired up for - // remote (SSH) worktrees. Creating symlinks on the remote host would + // Why: shared paths are intentionally not wired up for remote (SSH) + // worktrees. Creating symlinks on the remote host would // require a new relay method and authorization surface; the feature is // local-only until that protocol work is in scope. Remote repos with // `symlinkPaths` configured have them silently ignored here. @@ -2536,10 +2536,8 @@ export async function createLocalWorktree( // Why: materialize user-configured paths from the primary checkout into the // new worktree before any setup script runs, so scripts that reuse shared // state (e.g. `node_modules`, `.env`) see those paths already in place. - // Gated on the experimental flag so disabling the feature globally skips - // the work even when a repo still has paths configured. const symlinkPaths = repo.symlinkPaths ?? [] - if (settings.experimentalWorktreeSymlinks && symlinkPaths.length > 0) { + if (symlinkPaths.length > 0) { await timing.time('create_symlinks', async () => { await createWorktreeLinkedPaths(repo.path, created.path, symlinkPaths) }) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index db5bf3b1a4a..031f9cf703d 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -246,7 +246,11 @@ import { notifyWorktreesChanged } from './worktree-remote' import { invalidateAuthorizedRootsCache, resolveRegisteredWorktreePath } from './filesystem-auth' -import { __resetDetectedWorktreeScanCacheForTests, registerWorktreeHandlers } from './worktrees' +import { + __getDetectedWorktreeScanCacheStatsForTests, + __resetDetectedWorktreeScanCacheForTests, + registerWorktreeHandlers +} from './worktrees' type HandlerMap = Record unknown> @@ -2633,6 +2637,199 @@ describe('registerWorktreeHandlers', () => { expect(listWorktreesMock).toHaveBeenCalledTimes(1) }) + it('does not retain invalidated detected scans after they settle', async () => { + let resolveScan: (worktrees: GitWorktreeInfo[]) => void = () => {} + listWorktreesMock.mockImplementation( + () => + new Promise((resolve) => { + resolveScan = resolve as (worktrees: GitWorktreeInfo[]) => void + }) + ) + + const pendingList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + + expect(__getDetectedWorktreeScanCacheStatsForTests()).toMatchObject({ + cacheSize: 0, + inFlightSize: 1 + }) + + notifyWorktreesChanged(mainWindow as never, 'repo-1') + + expect(__getDetectedWorktreeScanCacheStatsForTests()).toMatchObject({ + cacheSize: 0, + inFlightSize: 0 + }) + + resolveScan([ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + await pendingList + + expect(__getDetectedWorktreeScanCacheStatsForTests()).toMatchObject({ + cacheSize: 0, + inFlightSize: 0 + }) + }) + + it('does not accumulate scan bookkeeping across prolonged repository churn', async () => { + store.getRepo.mockImplementation((repoId: string) => ({ + id: repoId, + path: `/workspace/${repoId}`, + displayName: repoId, + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: null + })) + listWorktreesMock.mockImplementation(async (repoPath: string) => [ + { + path: repoPath, + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + for (let index = 0; index < 128; index += 1) { + const repoId = `repo-${index}` + await handlers['worktrees:listDetected'](null, { repoId }) + notifyWorktreesChanged(mainWindow as never, repoId) + } + + expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({ + cacheSize: 0, + inFlightSize: 0 + }) + expect(listWorktreesMock).toHaveBeenCalledTimes(128) + }) + + it('keeps a replacement scan current after an older scan settles first', async () => { + const resolvers: ((worktrees: GitWorktreeInfo[]) => void)[] = [] + listWorktreesMock.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve as (worktrees: GitWorktreeInfo[]) => void) + }) + ) + const result = [ + { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ] + + const staleList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + notifyWorktreesChanged(mainWindow as never, 'repo-1') + const replacementList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + + resolvers[0](result) + await staleList + expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({ + cacheSize: 0, + inFlightSize: 1 + }) + + resolvers[1](result) + await replacementList + expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({ + cacheSize: 1, + inFlightSize: 0 + }) + }) + + it('does not let an older scan overwrite a replacement that settles first', async () => { + const resolvers: ((worktrees: GitWorktreeInfo[]) => void)[] = [] + listWorktreesMock.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve as (worktrees: GitWorktreeInfo[]) => void) + }) + ) + store.getAllWorktreeLineage.mockReturnValue({ + 'repo-1::/workspace/fresh-worktree': { + worktreeId: 'repo-1::/workspace/fresh-worktree', + worktreeInstanceId: 'child-instance', + parentWorktreeId: 'repo-1::/workspace/repo', + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { + source: 'manual-action', + confidence: 'explicit' + }, + createdAt: 0 + } + }) + const mainWorktree: GitWorktreeInfo = { + path: '/workspace/repo', + head: 'main-head', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + + const staleList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + notifyWorktreesChanged(mainWindow as never, 'repo-1') + const replacementList = handlers['worktrees:listDetected'](null, { repoId: 'repo-1' }) + await Promise.resolve() + + resolvers[1]([ + mainWorktree, + { + path: '/workspace/fresh-worktree', + head: 'fresh-head', + branch: 'refs/heads/fresh-worktree', + isBare: false, + isMainWorktree: false + } + ]) + await replacementList + + resolvers[0]([ + mainWorktree, + { + path: '/workspace/stale-worktree', + head: 'stale-head', + branch: 'refs/heads/stale-worktree', + isBare: false, + isMainWorktree: false + } + ]) + await staleList + + const cached = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: Worktree[] } + expect(cached.worktrees.map((worktree) => worktree.path)).toEqual([ + '/workspace/repo', + '/workspace/fresh-worktree' + ]) + expect(store.removeWorktreeLineage).not.toHaveBeenCalled() + await expect( + resolveRegisteredWorktreePath('/workspace/fresh-worktree', store as never) + ).resolves.toBe(resolve('/workspace/fresh-worktree')) + await expect( + resolveRegisteredWorktreePath('/workspace/stale-worktree', store as never) + ).rejects.toThrow('Access denied: unknown repository or worktree path') + expect(listWorktreesMock).toHaveBeenCalledTimes(2) + expect(__getDetectedWorktreeScanCacheStatsForTests()).toEqual({ + cacheSize: 1, + inFlightSize: 0 + }) + }) + it('fetches the same-repo PR head via the SSH tracking-ref RPC, not git.exec', async () => { const fetchRemoteTrackingRef = vi.fn(async () => {}) const exec = vi.fn(async (args: string[]) => { diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index df0dfd4c585..6aeb1144313 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -469,37 +469,59 @@ type DetectedWorktreeScanCacheEntry = { worktrees: GitWorktreeInfo[] } +type DetectedWorktreeScan = { + invalidated: boolean + promise: Promise +} + type DetectedWorktreeScanResult = { gitWorktrees: GitWorktreeInfo[] fresh: boolean } const detectedWorktreeScanCache = new Map() -const detectedWorktreeScanInFlight = new Map>() -const detectedWorktreeScanGenerations = new Map() +const detectedWorktreeScanInFlight = new Map() function invalidateDetectedWorktreeScanCache(repoId: string): void { const keyPrefix = `${repoId}\0` for (const key of new Set([ ...detectedWorktreeScanCache.keys(), - ...detectedWorktreeScanInFlight.keys(), - ...detectedWorktreeScanGenerations.keys() + ...detectedWorktreeScanInFlight.keys() ])) { if (!key.startsWith(keyPrefix)) { continue } detectedWorktreeScanCache.delete(key) - detectedWorktreeScanInFlight.delete(key) - detectedWorktreeScanGenerations.set(key, (detectedWorktreeScanGenerations.get(key) ?? 0) + 1) + const inFlight = detectedWorktreeScanInFlight.get(key) + if (inFlight) { + // Why: the detached scan keeps this token, so later scans can settle + // without making an older result fresh again. + inFlight.invalidated = true + detectedWorktreeScanInFlight.delete(key) + } } } registerWorktreeChangeInvalidator(invalidateDetectedWorktreeScanCache) export function __resetDetectedWorktreeScanCacheForTests(): void { + // Why: scans still pending across a test reset must not repopulate the + // cache afterward and leak state into the next test. + for (const scan of detectedWorktreeScanInFlight.values()) { + scan.invalidated = true + } detectedWorktreeScanCache.clear() detectedWorktreeScanInFlight.clear() - detectedWorktreeScanGenerations.clear() +} + +export function __getDetectedWorktreeScanCacheStatsForTests(): { + cacheSize: number + inFlightSize: number +} { + return { + cacheSize: detectedWorktreeScanCache.size, + inFlightSize: detectedWorktreeScanInFlight.size + } } async function listDetectedGitWorktrees( @@ -522,24 +544,25 @@ async function listDetectedGitWorktrees( const inFlight = detectedWorktreeScanInFlight.get(cacheKey) if (inFlight) { - return { gitWorktrees: await inFlight, fresh: false } + return { gitWorktrees: await inFlight.promise, fresh: false } } - const scan = listRepoWorktrees(repo, localWorktreeGitOptions) - const generation = detectedWorktreeScanGenerations.get(cacheKey) ?? 0 + const scan: DetectedWorktreeScan = { + invalidated: false, + promise: listRepoWorktrees(repo, localWorktreeGitOptions) + } detectedWorktreeScanInFlight.set(cacheKey, scan) try { - const gitWorktrees = await scan + const gitWorktrees = await scan.promise // Why: a create/remove notification can invalidate while the git scan is // still running. Do not let that stale scan repopulate the cache afterward. - const isCurrentGeneration = (detectedWorktreeScanGenerations.get(cacheKey) ?? 0) === generation - if (isCurrentGeneration) { + if (!scan.invalidated) { detectedWorktreeScanCache.set(cacheKey, { worktrees: gitWorktrees, expiresAt: Date.now() + DETECTED_WORKTREE_SCAN_CACHE_TTL_MS }) } - return { gitWorktrees, fresh: isCurrentGeneration } + return { gitWorktrees, fresh: !scan.invalidated } } finally { if (detectedWorktreeScanInFlight.get(cacheKey) === scan) { detectedWorktreeScanInFlight.delete(cacheKey) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 4e921151e4f..4ae746a1f28 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -562,6 +562,50 @@ describe('Store', () => { expect(ui.setupGuideSidebarDismissed).toBe(false) expect(ui.setupGuideBrowserMilestoneMigrated).toBe(true) expect(ui.setupGuideBrowserMilestoneLegacyComplete).toBe(false) + // Why: brand-new profiles never saw remaining-as-default. + expect(ui.usagePercentageDisplayChangeNoticeDismissed).toBe(true) + }) + + it('surfaces the usage percentage display change notice for upgraded profiles', async () => { + const persisted = getDefaultPersistedState(testState.dir) + writeDataFile({ + ...persisted, + onboarding: { + ...persisted.onboarding, + closedAt: 1, + outcome: 'completed' + }, + ui: { + ...persisted.ui, + // Why: omit the notice key so load resolves eligibility for existing profiles. + usagePercentageDisplayChangeNoticeDismissed: undefined + } + }) + + const store = await createStore() + + expect(store.getUI().usagePercentageDisplayChangeNoticeDismissed).toBe(false) + }) + + it('keeps the usage percentage display change notice dismissed when remaining was chosen', async () => { + const persisted = getDefaultPersistedState(testState.dir) + writeDataFile({ + ...persisted, + onboarding: { + ...persisted.onboarding, + closedAt: 1, + outcome: 'completed' + }, + ui: { + ...persisted.ui, + usagePercentageDisplay: 'remaining', + usagePercentageDisplayChangeNoticeDismissed: undefined + } + }) + + const store = await createStore() + + expect(store.getUI().usagePercentageDisplayChangeNoticeDismissed).toBe(true) }) it('defaults minimizeToTrayOnClose to false when unset', async () => { @@ -6017,6 +6061,65 @@ describe('Store', () => { expect(store.getSettings().terminalMacOptionAsAltMigrated).toBe(true) }) + it('migrates inherited right-click paste to each platform default once', async () => { + for (const [platform, expected] of [ + ['win32', true], + ['darwin', false], + ['linux', false] + ] as const) { + await withPlatform(platform, async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { terminalRightClickToPaste: true }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + expect(store.getSettings().terminalRightClickToPaste).toBe(expected) + expect(store.getSettings().terminalRightClickToPasteDefaultedForPlatform).toBe(true) + }) + } + }) + + it('preserves an explicit Windows right-click paste opt-out during migration', async () => { + await withPlatform('win32', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { terminalRightClickToPaste: false }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + expect(store.getSettings().terminalRightClickToPaste).toBe(false) + expect(store.getSettings().terminalRightClickToPasteDefaultedForPlatform).toBe(true) + }) + }) + + it('preserves right-click paste choices after the platform migration', async () => { + await withPlatform('darwin', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: { + terminalRightClickToPaste: true, + terminalRightClickToPasteDefaultedForPlatform: true + }, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {} + }) + const store = await createStore() + expect(store.getSettings().terminalRightClickToPaste).toBe(true) + }) + }) + it('migrates inherited terminal bar cursor defaults to block on first load', async () => { writeDataFile({ schemaVersion: 1, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 0cac89b3f17..44d0827bc5b 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -104,6 +104,9 @@ import { ONBOARDING_FINAL_STEP } from '../shared/constants' import { parseWorkspaceSession } from '../shared/workspace-session-schema' +import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display' +import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice' +import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../shared/usage-percentage-display-change-notice' import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostOrder, @@ -2996,6 +2999,11 @@ export class Store { const migratedTerminalLineHeight = normalizeTerminalLineHeight( parsed.settings?.terminalLineHeight ) + const terminalRightClickToPasteDefaultedForPlatform = + parsed.settings?.terminalRightClickToPasteDefaultedForPlatform === true + if (!terminalRightClickToPasteDefaultedForPlatform) { + this.loadNeedsSave = true + } if ( parsed.settings?.terminalLineHeight !== undefined && parsed.settings.terminalLineHeight !== migratedTerminalLineHeight @@ -3135,6 +3143,15 @@ export class Store { ...migratedAutoRenameBranchFromWork, ...migratedTerminalCursorStyle, terminalLineHeight: migratedTerminalLineHeight, + // Why: the old global true default was inherited, while false was + // always an explicit opt-out and must survive this one-shot reset. + terminalRightClickToPaste: terminalRightClickToPasteDefaultedForPlatform + ? (parsed.settings?.terminalRightClickToPaste ?? + defaults.settings.terminalRightClickToPaste) + : parsed.settings?.terminalRightClickToPaste === false + ? false + : defaults.settings.terminalRightClickToPaste, + terminalRightClickToPasteDefaultedForPlatform: true, ...migratedTerminalTuiScrollSensitivity.settings, experimentalActivity: migratedExperimentalActivity, experimentalActivityDefaultedOffForAllUsers: true, @@ -3305,6 +3322,25 @@ export class Store { ) { this.loadNeedsSave = true } + // Why: only upgraded profiles that still use the new default get + // the one-time usage-display change notice; brand-new profiles and + // users who already chose remaining stay quiet. + const usagePercentageDisplayChangeNoticeDismissed = + resolveUsagePercentageDisplayChangeNoticeDismissed({ + rawDismissed: parsed.ui?.usagePercentageDisplayChangeNoticeDismissed, + rawUsagePercentageDisplay: parsed.ui?.usagePercentageDisplay, + isExistingProfile: isExistingPersistedProfile({ + repoCount: parsed.repos?.length ?? 0, + onboardingClosedAt: normalizedOnboarding.closedAt, + ui: parsed.ui + }) + }) + if ( + parsed.ui?.usagePercentageDisplayChangeNoticeDismissed !== + usagePercentageDisplayChangeNoticeDismissed + ) { + this.loadNeedsSave = true + } return { ...defaults.ui, // Why: missing card properties should follow the persisted card @@ -3318,6 +3354,7 @@ export class Store { rightSidebarOpen, rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab), setupGuideSidebarDismissed, + usagePercentageDisplayChangeNoticeDismissed, setupGuideBrowserMilestoneMigrated: typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean' ? parsed.ui.setupGuideBrowserMilestoneMigrated @@ -5333,6 +5370,9 @@ export class Store { this.state.ui?.workspaceBoardColumnWidth ), syncTaskStatusFromWorkspaceBoard: this.state.ui?.syncTaskStatusFromWorkspaceBoard === true, + usagePercentageDisplay: normalizeUsagePercentageDisplay( + this.state.ui?.usagePercentageDisplay + ), // Why: strict boolean coercion so a missing/legacy value reads as false // (first-run notice still fires) rather than leaking a non-bool through. trayMinimizeNoticeShown: this.state.ui?.trayMinimizeNoticeShown === true, @@ -5412,6 +5452,9 @@ export class Store { sanitizedUpdates.syncTaskStatusFromWorkspaceBoard !== undefined ? sanitizedUpdates.syncTaskStatusFromWorkspaceBoard === true : this.state.ui?.syncTaskStatusFromWorkspaceBoard === true, + usagePercentageDisplay: normalizeUsagePercentageDisplay( + sanitizedUpdates.usagePercentageDisplay ?? this.state.ui?.usagePercentageDisplay + ), markdownTocPanelWidth: clampMarkdownTocPanelWidth( sanitizedUpdates.markdownTocPanelWidth ?? this.state.ui?.markdownTocPanelWidth ), diff --git a/src/main/providers/local-pty-utils.test.ts b/src/main/providers/local-pty-utils.test.ts index 66fde349ded..0527922f4cc 100644 --- a/src/main/providers/local-pty-utils.test.ts +++ b/src/main/providers/local-pty-utils.test.ts @@ -33,7 +33,11 @@ vi.mock('./macos-tcc-login-shell', () => ({ wrapShellSpawnForMacosTccAttribution: wrapSpawnMock })) -import { spawnShellWithFallback, validateWorkingDirectory } from './local-pty-utils' +import { + resolveUnixShellPath, + spawnShellWithFallback, + validateWorkingDirectory +} from './local-pty-utils' const WSL_UNC_DIR = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo' const NATIVE_DIR = 'C:\\Users\\jin\\repo' @@ -102,6 +106,48 @@ describe('validateWorkingDirectory', () => { }) }) +describe('resolveUnixShellPath', () => { + beforeEach(() => { + existsSyncMock.mockReset() + accessSyncMock.mockReset() + }) + + it('preserves non-absolute shells for execvp PATH and cwd resolution', () => { + expect(resolveUnixShellPath('bash')).toBe('bash') + expect(resolveUnixShellPath('./bin/custom-shell')).toBe('./bin/custom-shell') + expect(existsSyncMock).not.toHaveBeenCalled() + }) + + it('selects an executable fallback before node-pty can fork a missing shell', () => { + existsSyncMock.mockImplementation((path) => path === '/bin/sh') + accessSyncMock.mockReturnValue(undefined) + + expect(resolveUnixShellPath('/missing/zsh')).toBe('/bin/sh') + expect(existsSyncMock.mock.calls.map(([path]) => path)).toEqual([ + '/missing/zsh', + '/bin/zsh', + '/bin/bash', + '/bin/sh' + ]) + }) + + it('reports every attempted shell when no executable candidate exists', () => { + existsSyncMock.mockReturnValue(false) + + expect(() => resolveUnixShellPath('/missing/zsh')).toThrow( + 'No executable Unix shell found (tried: /missing/zsh, /bin/zsh, /bin/bash, /bin/sh)' + ) + }) + + it('dedupes a preferred shell that is already a fallback candidate', () => { + existsSyncMock.mockImplementation((path) => path === '/bin/sh') + accessSyncMock.mockReturnValue(undefined) + + expect(resolveUnixShellPath('/bin/sh')).toBe('/bin/sh') + expect(existsSyncMock.mock.calls.map(([path]) => path)).toEqual(['/bin/sh']) + }) +}) + describe('spawnShellWithFallback macOS TCC login wrapping', () => { let origPlatform: PropertyDescriptor | undefined diff --git a/src/main/providers/local-pty-utils.ts b/src/main/providers/local-pty-utils.ts index 53d016d312f..3e21095d535 100644 --- a/src/main/providers/local-pty-utils.ts +++ b/src/main/providers/local-pty-utils.ts @@ -1,4 +1,4 @@ -import { basename, join } from 'node:path' +import { basename, isAbsolute, join } from 'node:path' import { existsSync, accessSync, statSync, chmodSync, constants as fsConstants } from 'node:fs' import type * as pty from 'node-pty' import { isWslUncPath } from '../../shared/wsl-paths' @@ -7,6 +7,8 @@ import { wrapShellSpawnForMacosTccAttribution } from './macos-tcc-login-shell' let didEnsureSpawnHelperExecutable = false +const UNIX_SHELL_FALLBACKS = ['/bin/zsh', '/bin/bash', '/bin/sh'] as const + function toUnpackedAsarPath(candidate: string): string { return candidate .replace(/app\.asar([/\\])/, 'app.asar.unpacked$1') @@ -46,6 +48,25 @@ export function getShellValidationError(shellPath: string): string | null { return null } +/** + * Resolves an absolute Unix shell before node-pty forks. Bare commands and + * relative paths stay untouched so execvp can resolve them against PATH or cwd. + */ +export function resolveUnixShellPath(shellPath: string): string { + if (!isAbsolute(shellPath)) { + return shellPath + } + const candidates = [ + shellPath, + ...UNIX_SHELL_FALLBACKS.filter((candidate) => candidate !== shellPath) + ] + const resolved = candidates.find((candidate) => getShellValidationError(candidate) === null) + if (resolved) { + return resolved + } + throw new Error(`No executable Unix shell found (tried: ${candidates.join(', ')})`) +} + /** * Ensure the node-pty spawn-helper binary has the executable bit set. * @@ -252,7 +273,7 @@ export function spawnShellWithFallback(params: ShellSpawnParams): ShellSpawnResu // Try fallback shells on Unix if (process.platform !== 'win32') { - const fallbackShells = ['/bin/zsh', '/bin/bash', '/bin/sh'].filter((s) => s !== shellPath) + const fallbackShells = UNIX_SHELL_FALLBACKS.filter((candidate) => candidate !== shellPath) for (const fallback of fallbackShells) { if (getShellValidationError(fallback)) { continue diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index 6571177a928..da85dc69100 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -214,6 +214,87 @@ describe('fetchClaudeRateLimits', () => { }) }) + it('maps active Fable usage from the scoped OAuth limits array without a PTY read', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'managed:account-1' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 36 }, + seven_day: { utilization: 73 }, + fable_weekly: { utilization: 12 }, + limits: [ + { kind: 'weekly_scoped', percent: 55, scope: null }, + { + kind: 'weekly_scoped', + percent: 100, + resets_at: '2026-07-17T20:00:00.099908+00:00', + is_active: true, + scope: { model: { display_name: 'Fable' } } + } + ] + }), + { status: 200 } + ) + ) + + await expect( + fetchClaudeRateLimits({ authPreparation, allowUsagePanelSupplement: true }) + ).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + fableWeekly: { + usedPercent: 100, + resetsAt: Date.parse('2026-07-17T20:00:00.099908+00:00') + }, + usageMetadata: { attemptedSources: ['oauth'] } + }) + expect(fetchViaPty).not.toHaveBeenCalled() + }) + + it('ignores inactive scoped Fable usage and retains the legacy OAuth fallback', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 11 }, + seven_day: { utilization: 22 }, + fable_weekly: { utilization: 33 }, + limits: [ + { + kind: 'weekly_scoped', + percent: 90, + is_active: false, + scope: { model: { display_name: 'fable' } } + } + ] + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + fableWeekly: { usedPercent: 33 } + }) + }) + it('supplements managed-account OAuth usage with Fable from the CLI usage panel', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index f84e1d11892..af0563762c2 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -300,12 +300,21 @@ type OAuthUsageWindow = { resets_at?: string | number } +type OAuthUsageLimit = { + kind?: string + percent?: number + resets_at?: string | number + is_active?: boolean + scope?: { model?: { display_name?: string } | null } | null +} + type OAuthUsageResponse = { five_hour?: OAuthUsageWindow seven_day?: OAuthUsageWindow fable_weekly?: OAuthUsageWindow fable_seven_day?: OAuthUsageWindow seven_day_fable?: OAuthUsageWindow + limits?: OAuthUsageLimit[] | null } type ClaudeUsageAttemptState = { @@ -391,9 +400,22 @@ function mapWindow( } function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null { - // Why: a bare "fable" field does not prove the window length. Only accept - // explicit weekly/seven-day names for the distinct Fable meter. + // Why: model quotas moved into structured scoped limits; prefer that current + // contract while retaining explicit legacy weekly fields for older responses. + const scoped = Array.isArray(data.limits) + ? data.limits.find( + (limit) => + limit?.kind === 'weekly_scoped' && + limit.is_active !== false && + Number.isFinite(limit.percent) && + limit.scope?.model?.display_name?.trim().toLowerCase() === 'fable' + ) + : undefined return ( + mapWindow( + scoped ? { used_percentage: scoped.percent, resets_at: scoped.resets_at } : undefined, + 10080 + ) ?? mapWindow(data.fable_weekly, 10080) ?? mapWindow(data.fable_seven_day, 10080) ?? mapWindow(data.seven_day_fable, 10080) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index b8d9eee175f..fdd1096529e 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -30,10 +30,7 @@ import { removeWorktree } from '../git/worktree' import * as gitRunner from '../git/runner' -import { - clearSubmodulePathsCacheForTests, - listSubmodulePaths -} from '../git/status' +import { clearSubmodulePathsCacheForTests, listSubmodulePaths } from '../git/status' import { createSetupRunnerScript, getEffectiveHooks, @@ -1179,6 +1176,67 @@ const store = { getProjects: () => [] } +async function createExplicitAgentStatusHarness(options: { + getForegroundProcess: (ptyId: string) => Promise + confirmForegroundProcess?: (ptyId: string) => Promise + title?: string +}): Promise<{ + runtime: OrcaRuntimeService + handle: string + syncPty: (ptyId: string | null) => void +}> { + const leafId = '11111111-1111-4111-8111-111111111111' + const paneKey = makePaneKey('tab-1', leafId) + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + state: 'working', + prompt: '', + agentType: 'codex', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now(), + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID + } + ] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }), + write: () => true, + kill: () => true, + getForegroundProcess: options.getForegroundProcess, + confirmForegroundProcess: options.confirmForegroundProcess + }) + runtime.attachWindow(1) + const syncPty = (ptyId: string | null): void => { + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: options.title ?? 'repo terminal', + activeLeafId: leafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId, + paneRuntimeId: 1, + ptyId + } + ] + }) + } + syncPty('pty-1') + const [terminal] = (await runtime.listTerminals()).terminals + return { runtime, handle: terminal.handle, syncPty } +} + function makeHeadlessTerminalLayout( ptyIdsByLeafId: Record ): TerminalLayoutSnapshot { @@ -8486,6 +8544,203 @@ describe('OrcaRuntimeService', () => { }) }) + it('uses strong provider confirmation to authorize fresh hook state over a shell foreground', async () => { + const getForegroundProcess = vi.fn(async () => 'powershell.exe') + const confirmForegroundProcess = vi.fn(async () => 'claude') + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({ + handle, + isRunningAgent: true, + status: 'working' + }) + expect(getForegroundProcess).toHaveBeenCalledOnce() + expect(getForegroundProcess).toHaveBeenCalledWith('pty-1') + expect(confirmForegroundProcess).toHaveBeenCalledOnce() + expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1') + }) + + it('calls foreground confirmation with its controller receiver', async () => { + const getForegroundProcess = vi.fn(async () => 'powershell.exe') + const confirmForegroundProcess = vi.fn( + async function (this: { getForegroundProcess: typeof getForegroundProcess }) { + return this.getForegroundProcess === getForegroundProcess ? 'codex' : null + } + ) + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + expect(confirmForegroundProcess).toHaveBeenCalledOnce() + }) + + it.each([ + ['shell', async () => 'pwsh.exe'], + ['non-agent', async () => 'vim'], + ['unavailable', async () => null], + [ + 'failure', + async () => { + throw new Error('provider unavailable') + } + ] + ])('fails closed when shell-conflict confirmation returns %s', async (_case, confirm) => { + const confirmForegroundProcess = vi.fn(confirm) + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess: async () => 'zsh', + confirmForegroundProcess + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({ + handle, + isRunningAgent: false, + status: null + }) + expect(confirmForegroundProcess).toHaveBeenCalledOnce() + }) + + it('fails closed on a shell conflict when the controller cannot confirm it', async () => { + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess: async () => 'zsh' + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toEqual({ + handle, + isRunningAgent: false, + status: null + }) + }) + + it('skips strong confirmation when ordinary foreground evidence recognizes an agent', async () => { + const getForegroundProcess = vi.fn(async () => 'codex') + const confirmForegroundProcess = vi.fn(async () => 'codex') + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + expect(getForegroundProcess).toHaveBeenCalledOnce() + expect(confirmForegroundProcess).not.toHaveBeenCalled() + }) + + it('skips both foreground reads when current title evidence blocks explicit hook state', async () => { + const getForegroundProcess = vi.fn(async () => 'zsh') + const confirmForegroundProcess = vi.fn(async () => 'codex') + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess, + title: 'zsh' + }) + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: false, + status: null + }) + expect(getForegroundProcess).not.toHaveBeenCalled() + expect(confirmForegroundProcess).not.toHaveBeenCalled() + }) + + it('skips foreground reads for permission title and blocked wait evidence', async () => { + for (const blocked of ['title', 'wait'] as const) { + const getForegroundProcess = vi.fn(async () => 'zsh') + const confirmForegroundProcess = vi.fn(async () => 'codex') + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess + }) + runtime.onPtyData( + 'pty-1', + blocked === 'title' + ? '\x1b]0;Codex waiting for permission\x07' + : 'Hooks need review. Press enter to confirm\n', + Date.now() + 1000 + ) + getForegroundProcess.mockClear() + confirmForegroundProcess.mockClear() + + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'permission' + }) + expect(getForegroundProcess).not.toHaveBeenCalled() + expect(confirmForegroundProcess).not.toHaveBeenCalled() + } + }) + + it('rejects foreground evidence when the handle rebinds during the ordinary read', async () => { + const foreground = deferred() + const getForegroundProcess = vi.fn(() => foreground.promise) + const confirmForegroundProcess = vi.fn(async () => 'codex') + const { runtime, handle, syncPty } = await createExplicitAgentStatusHarness({ + getForegroundProcess, + confirmForegroundProcess + }) + + const status = runtime.getTerminalAgentStatus(handle) + await vi.waitFor(() => expect(getForegroundProcess).toHaveBeenCalledWith('pty-1')) + syncPty('pty-2') + foreground.resolve('zsh') + + await expect(status).rejects.toThrow('terminal_handle_stale') + expect(confirmForegroundProcess).not.toHaveBeenCalled() + }) + + it('rejects a handle rebind while a controller-less status check yields', async () => { + const { runtime, handle, syncPty } = await createExplicitAgentStatusHarness({ + getForegroundProcess: async () => 'zsh' + }) + runtime.setPtyController(null) + + const status = runtime.getTerminalAgentStatus(handle) + syncPty('pty-2') + + await expect(status).rejects.toThrow('terminal_handle_stale') + }) + + it('rejects confirmation evidence when the handle rebinds during the fresh read', async () => { + const confirmation = deferred() + const confirmForegroundProcess = vi.fn(() => confirmation.promise) + const { runtime, handle, syncPty } = await createExplicitAgentStatusHarness({ + getForegroundProcess: async () => 'powershell.exe', + confirmForegroundProcess + }) + + const status = runtime.getTerminalAgentStatus(handle) + await vi.waitFor(() => expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1')) + syncPty('pty-2') + confirmation.resolve('codex') + + await expect(status).rejects.toThrow('terminal_handle_stale') + }) + + it('rejects confirmation evidence when the owning PTY exits', async () => { + const confirmation = deferred() + const confirmForegroundProcess = vi.fn(() => confirmation.promise) + const { runtime, handle } = await createExplicitAgentStatusHarness({ + getForegroundProcess: async () => 'powershell.exe', + confirmForegroundProcess + }) + + const status = runtime.getTerminalAgentStatus(handle) + await vi.waitFor(() => expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1')) + runtime.onPtyExit('pty-1', 0) + confirmation.resolve('codex') + + await expect(status).rejects.toThrow('terminal_exited') + }) + it('reports permission from a title-derived action-required agent state', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 84f1954d194..19d08a619ba 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -853,7 +853,6 @@ type RuntimeStore = { minimaxGroupId?: GlobalSettings['minimaxGroupId'] minimaxUsageModels?: GlobalSettings['minimaxUsageModels'] gitlabProjects?: GlobalSettings['gitlabProjects'] - experimentalWorktreeSymlinks?: boolean mobileAutoRestoreFitMs?: number | null mobileEmulatorEnabled?: boolean mobileEmulatorDefaultDeviceUdid?: string | null @@ -1206,6 +1205,7 @@ type RuntimePtyController = { stopAndWait?(ptyId: string, opts?: { keepHistory?: boolean }): Promise getCwd?(ptyId: string): Promise getForegroundProcess(ptyId: string): Promise + confirmForegroundProcess?(ptyId: string): Promise hasChildProcesses?(ptyId: string): Promise clearBuffer?(ptyId: string): Promise resize?(ptyId: string, cols: number, rows: number): boolean @@ -10227,7 +10227,8 @@ export class OrcaRuntimeService { } async getTerminalAgentStatus(handle: string): Promise { - const terminal = this.getTerminalAgentStatusSnapshot(handle) + const ptyId = this.getTerminalAgentStatusPtyId(handle) + const terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId) const explicitStatus = this.getFreshExplicitAgentStatusForHandle(handle) const blockedByWaitText = detectTerminalWaitBlockedReason(terminal.waitText) const liveTitleClearsBlockedText = @@ -10251,7 +10252,8 @@ export class OrcaRuntimeService { // Fresh hook state is tighter, but current shell/management evidence wins. const isRunningAgent = !terminalTitleBlocksExplicitAgentStatus(terminal.title) && - !(await this.terminalHasShellForegroundProcess(handle)) + !(await this.terminalHasShellForegroundProcess(handle, ptyId)) + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) return { handle, isRunningAgent, @@ -10262,14 +10264,42 @@ export class OrcaRuntimeService { return { handle, isRunningAgent: true, status: terminal.titleStatus } } - return { - handle, - isRunningAgent: await this.isTerminalRunningAgent(handle), - status: null - } + const isRunningAgent = await this.isTerminalRunningAgent(handle) + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + return { handle, isRunningAgent, status: null } } - private getTerminalAgentStatusSnapshot(handle: string): { + private getTerminalAgentStatusPtyId(handle: string): string { + const pty = this.getLivePtyForHandle(handle) + if (pty) { + if (!pty.pty.connected) { + throw new Error('terminal_gone') + } + return pty.pty.ptyId + } + const { leaf } = this.getLiveLeafForHandle(handle) + if (getTerminalState(leaf) !== 'running') { + throw new Error('terminal_exited') + } + if (!leaf.ptyId) { + throw new Error('terminal_gone') + } + return leaf.ptyId + } + + private assertTerminalAgentStatusPtyBinding(handle: string, expectedPtyId: string): void { + if (this.getTerminalAgentStatusPtyId(handle) === expectedPtyId) { + return + } + // Why: delayed process evidence belongs only to the PTY that started the + // read, while callers still rely on the established stale-handle contract. + throw new Error('terminal_handle_stale') + } + + private getTerminalAgentStatusSnapshot( + handle: string, + expectedPtyId: string + ): { waitText: string waitBlockedAt: number | null title: string | null @@ -10278,8 +10308,8 @@ export class OrcaRuntimeService { } { const pty = this.getLivePtyForHandle(handle) if (pty) { - if (!pty.pty.connected) { - throw new Error('terminal_gone') + if (!pty.pty.connected || pty.pty.ptyId !== expectedPtyId) { + throw new Error('terminal_not_writable') } const leaf = this.getPrimaryLeafForPty(pty.pty.ptyId) const leafTitle = leaf @@ -10317,6 +10347,9 @@ export class OrcaRuntimeService { if (!leaf.ptyId) { throw new Error('terminal_gone') } + if (leaf.ptyId !== expectedPtyId) { + throw new Error('terminal_not_writable') + } const title = getLatestAgentCandidateTitleInfo( { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt }, @@ -10331,21 +10364,36 @@ export class OrcaRuntimeService { } } - private async terminalHasShellForegroundProcess(handle: string): Promise { + private async terminalHasShellForegroundProcess(handle: string, ptyId: string): Promise { if (!this.ptyController) { return false } + let foregroundProcess: string | null try { - const pty = this.getLivePtyForHandle(handle) - const ptyId = pty?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId - if (!ptyId) { - return false - } - const foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) - return foregroundProcess !== null && isShellProcess(foregroundProcess) + foregroundProcess = await this.ptyController.getForegroundProcess(ptyId) } catch { + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) return false } + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + if (!foregroundProcess || !isShellProcess(foregroundProcess)) { + return false + } + const confirmationController = this.ptyController + if (!confirmationController?.confirmForegroundProcess) { + return true + } + let confirmedProcess: string | null + try { + confirmedProcess = await confirmationController.confirmForegroundProcess(ptyId) + } catch { + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + return true + } + this.assertTerminalAgentStatusPtyBinding(handle, ptyId) + // Why: hook identity is generic; strong provider evidence only needs to + // prove that some recognized agent still owns this exact PTY. + return recognizeAgentProcess(confirmedProcess) === null } private shouldDelayPtyBackedMobileSnapshotForForegroundAgent( @@ -15142,11 +15190,7 @@ export class OrcaRuntimeService { warnings: lineageWarnings } = this.recordCreatedWorktreeLineage(worktree, lineageResolution) - if ( - settings.experimentalWorktreeSymlinks && - repo.symlinkPaths && - repo.symlinkPaths.length > 0 - ) { + if (repo.symlinkPaths && repo.symlinkPaths.length > 0) { await createWorktreeLinkedPaths(repo.path, created.path, repo.symlinkPaths) } @@ -20881,7 +20925,8 @@ export class OrcaRuntimeService { // status without throwing on stale handles, so this returns null on any error. getAgentStatusForHandle(handle: string): string | null { try { - return this.getTerminalAgentStatusSnapshot(handle).titleStatus + const ptyId = this.getTerminalAgentStatusPtyId(handle) + return this.getTerminalAgentStatusSnapshot(handle, ptyId).titleStatus } catch { return null } diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 9b08e6decd3..d262890d8d7 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -208,6 +208,7 @@ const UiUpdate = z _antigravityStatusBarDefaultAdded: z.boolean().optional(), _grokStatusBarDefaultAdded: z.boolean().optional(), statusBarVisible: z.boolean().optional(), + usagePercentageDisplay: z.enum(['used', 'remaining']).optional(), dismissedUpdateVersion: NullableString.optional(), lastUpdateCheckAt: z.number().finite().nullable().optional(), pendingUpdateNudgeId: NullableString.optional(), @@ -238,6 +239,7 @@ const UiUpdate = z trustedOrcaHooks: z.record(z.string(), z.unknown()).optional(), setupScriptPromptDismissedRepoIds: StringArray.optional(), projectOrderManualDefaultNoticeDismissed: z.boolean().optional(), + usagePercentageDisplayChangeNoticeDismissed: z.boolean().optional(), usageEmptyStateDismissed: z.boolean().optional(), petVisible: z.boolean().optional(), petId: z.string().optional(), diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 9c39a65f456..7a9b9e448a6 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -348,6 +348,31 @@ function isTerminalSendGuardNotWritable(error: unknown): boolean { return message.includes('terminal_guard_not_writable') } +function isTerminalAgentStatusNotWritable(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return [ + 'terminal_not_writable', + 'terminal_handle_stale', + 'terminal_gone', + 'terminal_exited' + ].some((code) => message.includes(code)) +} + +function assertTerminalSendExactPtyBinding( + runtime: OrcaRuntimeService, + handle: string, + expectedPtyId: string | undefined +): void { + try { + if (expectedPtyId && runtime.resolveLiveLeafForHandle(handle)?.ptyId === expectedPtyId) { + return + } + } catch { + // Fall through to the stable guarded-send result below. + } + throw new Error('terminal_guard_not_writable') +} + function appendPendingMultiplexOutput( stream: TerminalMultiplexStream, data: string, @@ -1148,10 +1173,22 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const assertSendPreconditions = params.requireAgentStatus === 'sendable' ? async (ptyId?: string): Promise => { + assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) if (ptyId && isTerminalInputLockedForClient(runtime, ptyId, params.client)) { throw new Error('terminal_guard_not_writable') } - const agentStatus = await runtime.getTerminalAgentStatus(params.terminal) + let agentStatus + try { + agentStatus = await runtime.getTerminalAgentStatus(params.terminal) + } catch (error) { + if (isTerminalAgentStatusNotWritable(error)) { + throw new Error('terminal_guard_not_writable') + } + throw error + } + // Why: a send callback can race a pane reconnect; status evidence + // must never authorize the callback's replacement PTY. + assertTerminalSendExactPtyBinding(runtime, params.terminal, ptyId) if (!agentStatus.isRunningAgent) { throw new Error('terminal_guard_no_agent') } diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index 12e40c2c977..58b37bcd7fc 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -525,6 +525,84 @@ describe('terminal send RPC', () => { ) }) + it('writes zero bytes when a guarded callback observes a handle rebind during status', async () => { + let boundPtyId = 'pty-1' + let statusCalls = 0 + const write = vi.fn() + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(() => ({ ptyId: boundPtyId })), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + getTerminalAgentStatus: vi.fn().mockImplementation(async () => { + statusCalls += 1 + if (statusCalls === 2) { + boundPtyId = 'pty-2' + } + return { + handle: 'terminal-1', + isRunningAgent: true, + status: 'working' + } + }), + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite('pty-1') + write('pty-1', '\r') + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 } + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + enter: true, + requireAgentStatus: 'sendable', + client: { id: 'desktop-1', type: 'desktop' } + }) + ) + + expect(response).toMatchObject({ + ok: true, + result: { send: { accepted: false, bytesWritten: 0 } } + }) + expect(runtime.getTerminalAgentStatus).toHaveBeenCalledTimes(2) + expect(write).not.toHaveBeenCalled() + }) + + it('rejects a guarded callback whose actual write PTY differs from the handle binding', async () => { + const write = vi.fn() + const runtime = stubRuntime({ + resolveLiveLeafForHandle: vi.fn(() => ({ ptyId: 'pty-1' })), + getDriver: vi.fn().mockReturnValue({ kind: 'desktop' }), + getTerminalAgentStatus: vi.fn().mockResolvedValue({ + handle: 'terminal-1', + isRunningAgent: true, + status: 'working' + }), + sendTerminal: vi.fn().mockImplementation(async (_handle, _action, options) => { + await options.beforeWrite('pty-2') + write('pty-2', '\r') + return { handle: 'terminal-1', accepted: true, bytesWritten: 1 } + }) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.send', { + terminal: 'terminal-1', + enter: true, + requireAgentStatus: 'sendable', + client: { id: 'desktop-1', type: 'desktop' } + }) + ) + + expect(response).toMatchObject({ + ok: true, + result: { send: { accepted: false, bytesWritten: 0 } } + }) + expect(runtime.getTerminalAgentStatus).toHaveBeenCalledTimes(1) + expect(write).not.toHaveBeenCalled() + }) + it('refuses guarded combined text and submit sends before any PTY write', async () => { const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 56841b77245..25a5a4e751b 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1209,6 +1209,31 @@ background: color-mix(in srgb, var(--worktree-sidebar-foreground) 14%, var(--worktree-sidebar)); } +/* Why: one-shot status-bar callout is portaled fixed above the meters. + Opaque popover surface (not glass) so titlebar/terminal chrome cannot + bleed through; use design tokens + the documented floating elevation. */ +.status-bar-change-notice-card { + border: 1px solid var(--border); + background: var(--popover); + color: var(--popover-foreground); + /* STYLEGUIDE: floating elevation for popovers that escape the editor surface. */ + box-shadow: 0 10px 24px rgb(0 0 0 / 0.18); +} + +/* Why: caret points down at the usage meters this notice explains. */ +.status-bar-change-notice-card::after { + content: ''; + position: absolute; + left: 1.25rem; + bottom: -6px; + width: 12px; + height: 12px; + transform: rotate(45deg); + border-right: 1px solid var(--border); + border-bottom: 1px solid var(--border); + background: var(--popover); +} + /* Why: the first-run Mobile Emulator intro sits directly under that menu row; an upward caret aligned to the phone icon reads as a callout, not a footer. */ .mobile-emulator-tab-intro-callout--menu { diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index ccf79ad3638..38c625f0d37 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -1026,6 +1026,27 @@ color: #58a6ff; } +.rich-markdown-editor sup[data-rich-markdown-html-superscript-link] { + line-height: 0; +} + +.rich-markdown-editor .rich-markdown-html-superscript-link { + color: #0969da; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, currentColor 40%, transparent); + text-underline-offset: 2px; +} + +.dark .rich-markdown-editor .rich-markdown-html-superscript-link { + color: #58a6ff; +} + +.rich-markdown-mod-held + .rich-markdown-editor + .rich-markdown-html-superscript-link[data-actionable] { + cursor: pointer; +} + .rich-markdown-editor img { max-width: 100%; border-radius: 8px; diff --git a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx index 5e45c6e71da..f8f11c9567b 100644 --- a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx +++ b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx @@ -7,6 +7,10 @@ import { LoaderCircle } from 'lucide-react' import { createRichMarkdownExtensions } from '@/components/editor/rich-markdown-extensions' import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html' +import { + createRichMarkdownEditorCodec, + type RichMarkdownEditorCodec +} from '@/components/editor/rich-markdown-source-transport' import { LinearIssueMarkdownToolbar } from '@/components/LinearIssueMarkdownToolbar' import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { cn } from '@/lib/utils' @@ -26,8 +30,8 @@ type LinearIssueMarkdownDescriptionEditorProps = { submitShortcutLabel: string } -function createLinearIssueMarkdownExtensions() { - const extensions = createRichMarkdownExtensions() +function createLinearIssueMarkdownExtensions(codec: RichMarkdownEditorCodec) { + const extensions = createRichMarkdownExtensions({ codec }) return [ ...extensions, Placeholder.configure({ @@ -54,18 +58,24 @@ export function LinearIssueMarkdownDescriptionEditor({ const richMarkdownSpellcheckEnabled = useAppStore( (s) => s.settings?.richMarkdownSpellcheckEnabled ?? true ) + // Why: changing language recreates Tiptap and re-registers tokenizers, so it + // must also receive a fresh private Marked registry instead of growing one. + const codec = useMemo(() => { + void language + return createRichMarkdownEditorCodec() + }, [language]) const linearIssueMarkdownExtensions = useMemo(() => { // Why: Tiptap freezes extension options when the editor is created; the // language value is the recreation key for translated extension options. void language - return createLinearIssueMarkdownExtensions() - }, [language]) + return createLinearIssueMarkdownExtensions(codec) + }, [codec, language]) const editor = useEditor( { immediatelyRender: false, extensions: linearIssueMarkdownExtensions, - content: encodeRawMarkdownHtmlForRichEditor(value), + content: encodeRawMarkdownHtmlForRichEditor(value, codec), contentType: 'markdown', editable: !disabled, editorProps: { @@ -99,7 +109,7 @@ export function LinearIssueMarkdownDescriptionEditor({ onChange(nextValue) } }, - [language] + [codec, language] ) useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled) @@ -124,12 +134,12 @@ export function LinearIssueMarkdownDescriptionEditor({ // Why: Linear remains the source of truth when the selected issue changes // or an optimistic save is reverted; keep the rich view aligned with it. - editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value), { + editor.commands.setContent(encodeRawMarkdownHtmlForRichEditor(value, codec), { contentType: 'markdown', emitUpdate: false }) lastEditorMarkdownRef.current = value - }, [editor, value]) + }, [codec, editor, value]) return (
true) +const connectionOwner = vi.hoisted(() => ({ value: null as string | null | undefined })) +const targetConnectionOwners = vi.hoisted(() => new Map()) +const worktreeLookup = vi.hoisted(() => ({ + value: [] as { id: string; path: string; diffComments: never[] }[] +})) +const statRuntimePathMock = vi.hoisted(() => vi.fn(async () => ({ isDirectory: false }))) // Minimal store: MarkdownPreview reads settings/worktreesByRepo plus a handful // of action functions. None of the actions fire on the http path under test. @@ -31,6 +39,9 @@ const storeState = { clearDeliveredDiffComments: vi.fn(), keybindings: {}, worktreesByRepo: {}, + repos: [], + folderWorkspaces: [], + projectGroups: [], openFiles: [], activeFileIdByWorktree: {}, settings: { openLinksInApp: true }, @@ -44,14 +55,22 @@ vi.mock('@/store', () => { ) return { useAppStore } }) -vi.mock('@/store/slices/worktree-helpers', () => ({ findWorktreeById: () => null })) +vi.mock('@/store/slices/worktree-helpers', () => ({ + findWorktreeById: (_worktrees: unknown, id: string) => + worktreeLookup.value.find((worktree) => worktree.id === id) ?? null +})) vi.mock('@/runtime/runtime-rpc-client', () => ({ settingsForRuntimeOwner: (settings: unknown) => settings })) vi.mock('@/runtime/runtime-file-client', () => ({ - statRuntimePath: vi.fn(async () => ({ isDirectory: false })) + statRuntimePath: statRuntimePathMock +})) +vi.mock('@/lib/connection-context', () => ({ + getConnectionIdForFile: (worktreeId: string) => targetConnectionOwners.get(worktreeId) +})) +vi.mock('@/lib/connection-owner-resolution', () => ({ + createConnectionIdForFileSelector: () => () => connectionOwner.value })) -vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: (src?: string) => src })) vi.mock('./MermaidBlock', () => ({ default: () => null })) @@ -77,8 +96,8 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => { ;(window as unknown as { api: unknown }).api = { shell: { openUrl: openUrlMock, - openFileUri: vi.fn(), - pathExists: vi.fn(async () => true) + openFileUri: openFileUriMock, + pathExists: pathExistsMock }, ui: { writeClipboardText: vi.fn(async () => true) } } @@ -91,6 +110,14 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => { createBrowserTabMock.mockClear() setActiveWorktreeMock.mockClear() openUrlMock.mockClear() + openFileUriMock.mockClear() + pathExistsMock.mockClear() + connectionOwner.value = null + targetConnectionOwners.clear() + worktreeLookup.value = [] + storeState.worktreesByRepo = {} + storeState.openMarkdownPreview.mockClear() + statRuntimePathMock.mockClear() }) afterEach(() => { @@ -100,21 +127,28 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => { container.remove() }) - function render(): HTMLAnchorElement { + function render( + content = '[example](https://example.com)', + expectedHref = 'https://example.com', + sourceWorktreeId = 'wt-1', + filePath = '/repo/docs/README.md' + ): HTMLAnchorElement { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) act(() => { root.render( ) }) - const anchor = container.querySelector('a[href="https://example.com"]') + const anchor = Array.from(container.querySelectorAll('a')).find( + (candidate) => candidate.getAttribute('href') === expectedHref + ) if (!anchor) { throw new Error('expected a rendered http anchor') } @@ -144,4 +178,59 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => { expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') expect(createBrowserTabMock).not.toHaveBeenCalled() }) + + it('keeps system-browser HTTP and file links inert while ownership is unknown', () => { + connectionOwner.value = undefined + const httpAnchor = render() + click(httpAnchor, { metaKey: true, shiftKey: true }) + expect(openUrlMock).not.toHaveBeenCalled() + expect(createBrowserTabMock).not.toHaveBeenCalled() + + act(() => { + root.unmount() + }) + container.remove() + const fileAnchor = render('[file](file:///tmp/example.md)', 'file:///tmp/example.md') + click(fileAnchor, { metaKey: true, shiftKey: true }) + expect(pathExistsMock).not.toHaveBeenCalled() + expect(openFileUriMock).not.toHaveBeenCalled() + }) + + it('keeps same-path file links on the explicit SSH source worktree', async () => { + const localWorktree = { id: 'local-wt', path: '/srv/repo', diffComments: [] as never[] } + const sshWorktree = { id: 'ssh-wt', path: '/srv/repo', diffComments: [] as never[] } + worktreeLookup.value = [localWorktree, sshWorktree] + storeState.worktreesByRepo = { repo: [localWorktree, sshWorktree] } + connectionOwner.value = 'ssh-1' + targetConnectionOwners.set('local-wt', null) + targetConnectionOwners.set('ssh-wt', 'ssh-1') + const anchor = render('[child](child.md)', 'child.md', 'ssh-wt', '/srv/repo/README.md') + + await act(async () => { + anchor.dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true })) + }) + + expect(statRuntimePathMock).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: 'ssh-wt', connectionId: 'ssh-1' }), + '/srv/repo/child.md' + ) + expect(storeState.openMarkdownPreview).toHaveBeenCalledWith( + expect.objectContaining({ worktreeId: 'ssh-wt', filePath: '/srv/repo/child.md' }), + { anchor: null } + ) + }) + + it('renders a raw HTML superscript citation and routes it like a Markdown link', () => { + const source = '[12]' + const anchor = render(source) + + expect(anchor.parentElement?.tagName).toBe('SUP') + expect(anchor.textContent).toBe('[12]') + expect(container.textContent).not.toContain('') + click(anchor, { metaKey: true }) + expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', { + activate: true + }) + expect(openUrlMock).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx b/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx index 46301826f01..23f66fa9ee5 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.toc-visibility-gate.test.tsx @@ -28,6 +28,9 @@ const storeState = { clearDeliveredDiffComments: vi.fn(), keybindings: {}, worktreesByRepo: {}, + repos: [], + folderWorkspaces: [], + projectGroups: [], openFiles: [], activeFileIdByWorktree: {}, settings: { openLinksInApp: true }, @@ -48,7 +51,10 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({ vi.mock('@/runtime/runtime-file-client', () => ({ statRuntimePath: vi.fn(async () => ({ isDirectory: false })) })) -vi.mock('@/lib/connection-context', () => ({ getConnectionId: () => null })) +vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => null })) +vi.mock('@/lib/connection-owner-resolution', () => ({ + createConnectionIdForFileSelector: () => () => null +})) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('./useLocalImageSrc', () => ({ useLocalImageSrc: (src?: string) => src })) vi.mock('./MermaidBlock', () => ({ default: () => null })) diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index b77aaa33fb5..38d146c1312 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -38,7 +38,8 @@ import { Input } from '@/components/ui/input' import { useAppStore } from '@/store' import { toast } from 'sonner' import { computeEditorFontSize } from '@/lib/editor-font-zoom' -import { getConnectionId } from '@/lib/connection-context' +import { getConnectionIdForFile } from '@/lib/connection-context' +import { createConnectionIdForFileSelector } from '@/lib/connection-owner-resolution' import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache' import { detectLanguage } from '@/lib/language-detect' import type { DiffComment, MarkdownDocument, Worktree } from '../../../../shared/types' @@ -68,7 +69,7 @@ import { setActiveMarkdownPreviewSearchMatch } from './markdown-preview-search' import { usePreserveSectionDuringExternalEdit } from './usePreserveSectionDuringExternalEdit' -import { openHttpLink } from '@/lib/http-link-routing' +import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { isLocalPathOpenBlocked, showLocalPathOpenBlockedToast } from '@/lib/local-path-open-guard' import { markdownPreviewUrlTransform } from './markdown-preview-url-transform' @@ -390,14 +391,18 @@ export function deriveMarkdownPreviewSourceRoot( function findWorktreeForMarkdownPreviewPath( worktreesByRepo: Record, - absolutePath: string + absolutePath: string, + acceptsWorktree: (worktree: Worktree) => boolean = () => true ): Worktree | null { let bestMatch: Worktree | null = null let bestMatchLength = -1 for (const worktrees of Object.values(worktreesByRepo)) { for (const worktree of worktrees) { - if (relativePathInsideRoot(worktree.path, absolutePath) !== null) { + if ( + acceptsWorktree(worktree) && + relativePathInsideRoot(worktree.path, absolutePath) !== null + ) { const normalizedWorktreePathLength = normalizeMarkdownPreviewAbsolutePath( worktree.path ).length @@ -412,6 +417,27 @@ function findWorktreeForMarkdownPreviewPath( return bestMatch } +function findMarkdownPreviewTargetWorktree( + worktreesByRepo: Record, + absolutePath: string, + sourceWorktree: Worktree | null, + sourceOwner: HttpLinkSourceOwner +): Worktree | null { + if (sourceWorktree && relativePathInsideRoot(sourceWorktree.path, absolutePath) !== null) { + return sourceWorktree + } + return findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath, (worktree) => { + const connectionId = getConnectionIdForFile(worktree.id, absolutePath) + if (sourceOwner.kind === 'local') { + return connectionId === null + } + if (sourceOwner.kind === 'ssh') { + return connectionId === sourceOwner.connectionId + } + return false + }) +} + export function resolveMarkdownPreviewSourceWorktree( worktreesByRepo: Record, sourceWorktreeId: string | null | undefined, @@ -498,9 +524,26 @@ export default function MarkdownPreview({ ) const allDiffComments = sourceWorktree?.diffComments const sourceRoutingWorktreeId = sourceWorktree?.id ?? resolvedSourceWorktreeId - const sourceConnectionId = sourceRoutingWorktreeId - ? (getConnectionId(sourceRoutingWorktreeId) ?? null) - : null + const runtimeOwnerId = resolvedSourceRuntimeEnvironmentId?.trim() + const sourceConnectionIdSelector = useMemo( + () => + createConnectionIdForFileSelector(sourceRoutingWorktreeId, filePath, { + skip: Boolean(runtimeOwnerId) + }), + [filePath, runtimeOwnerId, sourceRoutingWorktreeId] + ) + const sourceConnectionId = useAppStore(sourceConnectionIdSelector) + const sourceOwner = useMemo( + () => + runtimeOwnerId + ? { kind: 'runtime', runtimeEnvironmentId: runtimeOwnerId } + : sourceConnectionId === undefined + ? { kind: 'unknown' } + : sourceConnectionId === null + ? { kind: 'local' } + : { kind: 'ssh', connectionId: sourceConnectionId }, + [runtimeOwnerId, sourceConnectionId] + ) const worktreeRoot = sourceWorktree?.path ?? (sourceRoutingWorktreeId @@ -1254,6 +1297,9 @@ export default function MarkdownPreview({ // dangling in-worktree .md, pre-check existence so the user sees a // toast instead of the silent no-op from shell.openFileUri. if (isMarkdownPreviewSystemBrowserModifier(event, isMac)) { + if (sourceOwner.kind === 'unknown') { + return + } const osTarget = getMarkdownPreviewLinkTarget(href, filePath) if (!osTarget) { return @@ -1267,7 +1313,12 @@ export default function MarkdownPreview({ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { openHttpLink( parsed.toString(), - resolveMarkdownPreviewHttpOpenOptions(event, isMac, sourceRoutingWorktreeId) + resolveMarkdownPreviewHttpOpenOptions( + event, + isMac, + sourceRoutingWorktreeId, + sourceOwner + ) ) return } @@ -1326,7 +1377,12 @@ export default function MarkdownPreview({ // handled above; this path only sees non-escape-hatch clicks.) openHttpLink( target.toString(), - resolveMarkdownPreviewHttpOpenOptions(event, isMac, sourceRoutingWorktreeId) + resolveMarkdownPreviewHttpOpenOptions( + event, + isMac, + sourceRoutingWorktreeId, + sourceOwner + ) ) return } @@ -1347,12 +1403,25 @@ export default function MarkdownPreview({ ? { line: classifiedFileTarget.line, column: classifiedFileTarget.column } : parseLineTarget(target.hash) + // Why: same-file anchors need no ownership/filesystem resolution (e.g. + // `./README.md#heading` when this file is README.md). Run before the + // unknown-ownership guard so ambiguous folder-workspace ownership still + // scrolls within the open document. if (absolutePath === filePath && target.hash && !lineTarget) { void scrollToAnchor(target.hash.slice(1)) return } - const targetWorktree = findWorktreeForMarkdownPreviewPath(worktreesByRepo, absolutePath) + if (sourceOwner.kind === 'unknown') { + return + } + + const targetWorktree = findMarkdownPreviewTargetWorktree( + worktreesByRepo, + absolutePath, + sourceWorktree, + sourceOwner + ) if (!targetWorktree) { if (sourceRoutingWorktreeId && worktreeRoot) { // Why: floating markdown files are owned by a synthetic workspace, @@ -1362,7 +1431,8 @@ export default function MarkdownPreview({ sourceFilePath: filePath, worktreeId: sourceRoutingWorktreeId, worktreeRoot, - runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId + runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId, + sourceOwner }) return } @@ -1384,8 +1454,15 @@ export default function MarkdownPreview({ return } - const relativePath = absolutePath.slice(targetWorktree.path.length + 1) + const relativePath = relativePathInsideRoot(targetWorktree.path, absolutePath) + if (relativePath === null) { + return + } const language = detectLanguage(absolutePath) + const targetConnectionId = getConnectionIdForFile(targetWorktree.id, absolutePath) + if (targetConnectionId === undefined) { + return + } try { const stats = await statRuntimePath( { @@ -1395,7 +1472,7 @@ export default function MarkdownPreview({ ), worktreeId: targetWorktree.id, worktreePath: targetWorktree.path, - connectionId: getConnectionId(targetWorktree.id) ?? undefined + connectionId: targetConnectionId ?? undefined }, absolutePath ) @@ -1512,7 +1589,8 @@ export default function MarkdownPreview({ sourceFilePath: filePath, worktreeId: sourceRoutingWorktreeId, worktreeRoot, - runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId + runtimeEnvironmentId: resolvedSourceRuntimeEnvironmentId, + sourceOwner }) } @@ -1673,6 +1751,8 @@ export default function MarkdownPreview({ setMarkdownViewMode, setPendingEditorReveal, sourceConnectionId, + sourceOwner, + sourceWorktree, resolvedSourceRuntimeEnvironmentId, sourceRoutingWorktreeId, worktreeRoot, diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index c5954052906..1c0cb2734d2 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -1,9 +1,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { Editor } from '@tiptap/react' +import { useEditorState, type Editor } from '@tiptap/react' import type { DiffComment, MarkdownDocument } from '../../../../shared/types' import { useAppStore } from '@/store' import { selectWorktreeDiffComments } from '@/store/worktree-diff-comments-selector' -import { getIndexedWorktreeById } from '@/store/worktree-repo-index' import { useLocalImagePick } from './useLocalImagePick' import { useRichMarkdownSearch } from './useRichMarkdownSearch' import type { LinkBubbleState } from './RichMarkdownLinkBubble' @@ -25,6 +24,11 @@ import { runRichMarkdownContextCommand } from './rich-markdown-context-command-routing' import { useRichMarkdownSpellcheckAttribute } from './rich-markdown-spellcheck' +import { useRichMarkdownSuperscriptLinkSetup } from './useRichMarkdownSuperscriptLinkSetup' +import { + formatSelectedHtmlSuperscriptLinkStatus, + getSelectedHtmlSuperscriptLinkStatus +} from './rich-markdown-selected-link-actions' type RichMarkdownEditorProps = { fileId: string @@ -86,9 +90,11 @@ export default function RichMarkdownEditor({ const allDiffComments = useAppStore((s): DiffComment[] | undefined => selectWorktreeDiffComments(s, worktreeId) ) - const worktreeRoot = useAppStore( - (s) => getIndexedWorktreeById(s.worktreesByRepo, worktreeId)?.path ?? null - ) + const { codec, htmlSuperscriptLinkContext, worktreeRoot } = useRichMarkdownSuperscriptLinkSetup({ + filePath, + runtimeEnvironmentId, + worktreeId + }) const scrollContainerRef = useRef(null) const menu = useRichMarkdownMenuController({ markdownDocuments }) const isMac = navigator.userAgent.includes('Mac') @@ -196,6 +202,8 @@ export default function RichMarkdownEditor({ ) const editor = useRichMarkdownEditorInstance({ + codec, + htmlSuperscriptLinkContext, content, filePath, worktreeId, @@ -240,6 +248,13 @@ export default function RichMarkdownEditor({ setSlashMenu: menu.setSlashMenu, setDocLinkMenu: menu.setDocLinkMenu }) + // Why: useEditor defaults shouldRerenderOnTransaction to false, so selection-only + // citation NodeSelections would leave aria status stale without useEditorState. + const selectedCitationStatus = useEditorState({ + editor, + selector: (snapshot) => + getSelectedHtmlSuperscriptLinkStatus(snapshot.editor, htmlSuperscriptLinkContext) + }) useRichMarkdownSpellcheckAttribute(editor, richMarkdownSpellcheckEnabled) // Why: use useLayoutEffect (synchronous cleanup) so the pending serialization @@ -265,6 +280,7 @@ export default function RichMarkdownEditor({ }) useRichMarkdownProgrammaticSync({ + codec, content, docLinkMenuSetter: menu.setDocLinkMenu, editor, @@ -289,12 +305,14 @@ export default function RichMarkdownEditor({ handleLinkRemove, handleLinkEditCancel, handleLinkOpen, + handleLinkCopy, toggleLinkFromToolbar } = useLinkBubble(editor, rootRef, linkBubble, setLinkBubble, setIsEditingLink, { sourceFilePath: filePath, worktreeId, worktreeRoot, - runtimeEnvironmentId + runtimeEnvironmentId, + htmlSuperscriptLinkContext }) useEffect(() => { @@ -370,6 +388,12 @@ export default function RichMarkdownEditor({ showTableOfContents={showTableOfContents} searchState={searchState} searchActions={searchActions} + citationStatus={ + selectedCitationStatus + ? formatSelectedHtmlSuperscriptLinkStatus(selectedCitationStatus) + : '' + } + linkBubbleOwnerId={codec.transport.key} linkBubbleActions={{ dismissLinkBubble: () => { setLinkBubble(null) @@ -379,6 +403,7 @@ export default function RichMarkdownEditor({ handleLinkRemove, handleLinkEditCancel, handleLinkOpen, + handleLinkCopy, setIsEditingLink }} onToggleLink={toggleLinkFromToolbar} diff --git a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx index f5283616abb..fd619247c2a 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx @@ -76,6 +76,7 @@ type RichMarkdownEditorSurfaceProps = { matchCase: boolean matchCount: number replaceQuery: string + replaceDisabled: boolean searchQuery: string searchInputRef: React.RefObject wholeWord: boolean @@ -91,12 +92,15 @@ type RichMarkdownEditorSurfaceProps = { toggleReplaceMode: () => void toggleWholeWord: () => void } + citationStatus: string + linkBubbleOwnerId: string linkBubbleActions: { dismissLinkBubble: () => void handleLinkSave: (href: string) => void handleLinkRemove: () => void handleLinkEditCancel: () => void handleLinkOpen: () => void + handleLinkCopy: () => void setIsEditingLink: (editing: boolean) => void } onToggleLink: () => void @@ -155,6 +159,8 @@ export function RichMarkdownEditorSurface({ showTableOfContents, searchState, searchActions, + citationStatus, + linkBubbleOwnerId, linkBubbleActions, onToggleLink, onImagePick, @@ -237,6 +243,7 @@ export function RichMarkdownEditorSurface({ matchCount={searchState.matchCount} query={searchState.searchQuery} replaceQuery={searchState.replaceQuery} + replaceDisabled={searchState.replaceDisabled} searchInputRef={searchState.searchInputRef} wholeWord={searchState.wholeWord} onClose={searchActions.closeSearch} @@ -262,8 +269,13 @@ export function RichMarkdownEditorSurface({ onEditStart={() => linkBubbleActions.setIsEditingLink(true)} onEditCancel={linkBubbleActions.handleLinkEditCancel} onOpen={linkBubbleActions.handleLinkOpen} + onCopy={linkBubbleActions.handleLinkCopy} + ownerId={linkBubbleOwnerId} /> ) : null} + + {citationStatus} + {slashMenu ? ( void onEditCancel: () => void onOpen: () => void + onCopy: () => void + ownerId?: string portalToDocument?: boolean } @@ -173,6 +181,8 @@ export function RichMarkdownLinkBubble({ onEditStart, onEditCancel, onOpen, + onCopy, + ownerId, portalToDocument = false }: RichMarkdownLinkBubbleProps): React.JSX.Element { const bubbleRef = useRef(null) @@ -270,6 +280,8 @@ export function RichMarkdownLinkBubble({
{ // Prevent editor blur when clicking bubble buttons, but let inputs @@ -278,7 +290,28 @@ export function RichMarkdownLinkBubble({ e.preventDefault() } }} - onKeyDown={(e) => e.stopPropagation()} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === 'Escape') { + event.preventDefault() + onDismiss() + anchorElement?.querySelector('[contenteditable="true"]')?.focus() + return + } + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') { + return + } + const buttons = Array.from( + bubbleRef.current?.querySelectorAll('button:not([disabled])') ?? [] + ) + const currentIndex = buttons.indexOf(document.activeElement as HTMLButtonElement) + if (currentIndex === -1 || buttons.length === 0) { + return + } + event.preventDefault() + const direction = event.key === 'ArrowRight' ? 1 : -1 + buttons[(currentIndex + direction + buttons.length) % buttons.length]?.focus() + }} > {isEditing ? ( @@ -287,39 +320,45 @@ export function RichMarkdownLinkBubble({ {linkBubble.href.length > 40 ? `${linkBubble.href.slice(0, 40)}…` : linkBubble.href} - - - + + + {linkBubble.kind === 'markdown' ? ( + <> + + + + + + + + ) : null} )}
@@ -329,3 +368,36 @@ export function RichMarkdownLinkBubble({ // actions must portal to the app layer to remain above the right sidebar. return portalToDocument ? createPortal(bubble, document.body) : bubble } + +function LinkBubbleAction({ + children, + disabled = false, + label, + onClick +}: { + children: React.ReactNode + disabled?: boolean + label: string + onClick: () => void +}): React.JSX.Element { + return ( + + + + + + {label} + + + ) +} diff --git a/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx b/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx index ca7fd042a83..9ea8d17e068 100644 --- a/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx +++ b/src/renderer/src/components/editor/RichMarkdownSearchBar.tsx @@ -22,6 +22,7 @@ type RichMarkdownSearchBarProps = { matchCount: number query: string replaceQuery: string + replaceDisabled: boolean searchInputRef: React.RefObject wholeWord: boolean onClose: () => void @@ -43,6 +44,7 @@ export function RichMarkdownSearchBar({ matchCount, query, replaceQuery, + replaceDisabled, searchInputRef, wholeWord, onClose, @@ -58,6 +60,7 @@ export function RichMarkdownSearchBar({ // Why: surface the same replace shortcut the source editor uses so the toggle // is discoverable; reads the user's effective binding, formatted per platform. const replaceShortcut = useOptionalShortcutLabel('editor.replace') + const readOnlyExplanationId = React.useId() if (!isOpen) { return null @@ -71,6 +74,10 @@ export function RichMarkdownSearchBar({ } const noMatches = matchCount === 0 + const readOnlyReplaceExplanation = translate( + 'auto.components.editor.RichMarkdownSearchBar.preservedRichContentReadOnly', + 'Preserved rich content is read-only in rich mode.' + ) const toggleReplaceLabel = isReplaceMode ? translate('auto.components.editor.RichMarkdownSearchBar.e8c147435f', 'Hide replace') : translate('auto.components.editor.RichMarkdownSearchBar.9cdc38be33', 'Toggle replace') @@ -259,6 +266,7 @@ export function RichMarkdownSearchBar({ 'auto.components.editor.RichMarkdownSearchBar.44682b4159', 'Replace in rich markdown editor' )} + aria-describedby={replaceDisabled ? readOnlyExplanationId : undefined} />
+ {replaceDisabled ? ( + + {readOnlyReplaceExplanation} + + ) : null} ) : null} diff --git a/src/renderer/src/components/editor/html-character-reference-decoder.ts b/src/renderer/src/components/editor/html-character-reference-decoder.ts new file mode 100644 index 00000000000..e8bd9d1689d --- /dev/null +++ b/src/renderer/src/components/editor/html-character-reference-decoder.ts @@ -0,0 +1,22 @@ +export type HtmlAttributeQuote = '"' | "'" | null + +export function decodeHtmlTextCharacterReferences(value: string): string { + // Why: a