From 17e471c51e9ec8721d4acfbbd9036e6379183c67 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:32:34 -0700 Subject: [PATCH 01/10] perf(editor): memoize the markdown preview render pipeline (#9843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react-markdown's has no internal memoization: it rebuilds the whole unified remark->rehype->highlight->katex processor and re-parses the document on every render. MarkdownPreview re-renders on internal state that does not affect the rendered output — most visibly, every keystroke in Find (query/match-index state) — so a large doc re-ran the full parse + syntax-highlight + KaTeX pass per keypress, making Find laggy. Hoist the two fully-static plugin arrays to module scope (a fresh array identity per render would defeat the memo) and render the body through a React.memo'd MarkdownBody keyed on content + components. The pipeline now re-runs only when the rendered content or the components map actually changes; Find/review-pulse/copied- note re-renders skip it. The components map was already memoized, so its identity is stable across those re-renders. Behavior unchanged: 106 existing MarkdownPreview tests pass. Co-authored-by: Orca --- .../src/components/editor/MarkdownPreview.tsx | 73 +++++++++++++------ 1 file changed, 50 insertions(+), 23 deletions(-) diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index f74e48c4c3a..26f791332b2 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- Why: MarkdownPreview keeps rendering, link interception, search, and viewport state together so preview behavior stays coherent. */ /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: search match state is synchronized with DOM highlights inserted into the rendered markdown body. */ import React, { + memo, useCallback, useEffect, useLayoutEffect, @@ -30,7 +31,7 @@ import { Plus, X } from 'lucide-react' -import type { Components } from 'react-markdown' +import type { Components, Options as ReactMarkdownOptions } from 'react-markdown' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { useAppStore } from '@/store' @@ -328,6 +329,53 @@ const markdownPreviewSanitizeSchema = { } } +// Why: react-markdown's has no internal memoization — it rebuilds the whole +// unified remark→rehype pipeline and re-parses the document on EVERY render. These plugin +// lists are fully static, so hoist them to module scope; a fresh array identity per render +// would otherwise defeat the memoized body below. +type MarkdownPluginList = NonNullable +const MARKDOWN_REMARK_PLUGINS: MarkdownPluginList = [ + remarkGfm, + remarkBreaks, + remarkFrontmatter, + remarkMath, + remarkMarkdownDocLinks +] +// Why: sanitize raw HTML before KaTeX/highlight expand it, so their generated markup needn't be whitelisted in the schema. +const MARKDOWN_REHYPE_PLUGINS: MarkdownPluginList = [ + rehypeRaw, + [rehypeSanitize, markdownPreviewSanitizeSchema], + rehypeSlug, + rehypeHighlight, + rehypeKatex +] + +// Why: render the body through a memoized wrapper so the expensive pipeline only re-runs when +// the rendered content or the components map changes. Find state (query/match index) and +// body-unrelated toolbar re-renders touch neither prop, so keystrokes in Find no longer +// re-parse+re-highlight the whole doc. (Inline-annotation/review state — attentionReviewCommentId, +// copiedReviewNoteId, activeAnnotationBlockKey — deliberately rebuilds `components`, so those +// re-renders still re-run the pipeline; that's required to update the live annotation markup.) +const MarkdownBody = memo(function MarkdownBody({ + content, + components +}: { + content: string + components: Components +}) { + return ( + + {content} + + ) +}) + function parseLineTarget(hash: string): { line: number; column?: number } | null { if (!hash) { return null @@ -1947,28 +1995,7 @@ export default function MarkdownPreview({ ) : null} - - {renderedContent} - + From 8cfb8a2a2be19b6a6cd35f155c4f2b363d3f4d74 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:33:03 -0700 Subject: [PATCH 02/10] perf(emulator): park device stream when the window is hidden (#9842) The iOS MJPEG and Android scrcpy device streams are gated only on the pane being the active tab (isActive, PR #7382). When the emulator tab is frontmost but the whole Orca window is hidden/minimized/occluded/display-asleep, the full-fps pipeline keeps running: main-process socket read + JPEG/H.264 decode + IPC + renderer decode. Renderer background-throttling (#9395) cannot stop it because the pipeline is IPC-push driven from main. Gate showStream additionally on window visibility via a new occlusion-safe hook that honors the terminal stale-visibility latch (so a display-sleep occlusion wedge can't freeze the emulator on a black frame) and delays the visible->hidden park by 500ms so a quick Cmd+Tab round-trip doesn't renegotiate the device stream. Co-authored-by: Orca --- .../emulator-device-frame-visibility.test.tsx | 81 +++++++++++++++++++ .../emulator-pane/emulator-device-frame.tsx | 11 ++- .../use-emulator-stream-window-visibility.ts | 59 ++++++++++++++ 3 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/components/emulator-pane/use-emulator-stream-window-visibility.ts diff --git a/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx b/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx index b2a9621bae1..f202aa9dcdd 100644 --- a/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx +++ b/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx @@ -4,6 +4,8 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { EmulatorDeviceFrame } from './emulator-device-frame' +import { resetStaleDocumentVisibilityForTesting } from '../terminal-pane/stale-document-visibility' +import { EMULATOR_STREAM_PARK_DELAY_MS } from './use-emulator-stream-window-visibility' // Why: a backgrounded but still-attached emulator must stop streaming frames. // The perf contract is that no frame stream is started (no per-frame IPC / MJPEG @@ -56,9 +58,20 @@ afterEach(() => { delete (URL as Partial).createObjectURL delete (URL as Partial).revokeObjectURL delete (window as { api?: unknown }).api + setDocumentVisibility('visible') + resetStaleDocumentVisibilityForTesting() + vi.useRealTimers() vi.restoreAllMocks() }) +function setDocumentVisibility(state: 'visible' | 'hidden'): void { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => state + }) + document.dispatchEvent(new Event('visibilitychange')) +} + async function renderFrame(isActive: boolean): Promise { await act(async () => { root.render( @@ -102,3 +115,71 @@ describe('EmulatorDeviceFrame visibility gating', () => { expect(startFrameStream).toHaveBeenCalledTimes(2) }) }) + +describe('EmulatorDeviceFrame window-visibility gating', () => { + it('parks the stream after the window stays hidden past the park delay, and resumes when visible', async () => { + vi.useFakeTimers() + await renderFrame(true) + expect(startFrameStream).toHaveBeenCalledTimes(1) + + // Hiding the window does not tear down immediately — a short grace covers a + // quick Cmd+Tab round-trip. + await act(async () => { + setDocumentVisibility('hidden') + }) + expect(stopFrameStream).not.toHaveBeenCalled() + + // Once the grace elapses, the stream parks at the source. + await act(async () => { + vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS) + }) + expect(stopFrameStream).toHaveBeenCalledWith({ streamId: 'stream-1' }) + + // Returning to the window resumes immediately; the session was never detached. + await act(async () => { + setDocumentVisibility('visible') + }) + expect(startFrameStream).toHaveBeenCalledTimes(2) + }) + + it('does not tear down on a quick hide/show within the park delay', async () => { + vi.useFakeTimers() + await renderFrame(true) + expect(startFrameStream).toHaveBeenCalledTimes(1) + + await act(async () => { + setDocumentVisibility('hidden') + }) + await act(async () => { + vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS - 100) + }) + await act(async () => { + setDocumentVisibility('visible') + }) + // The park timer was cancelled by the return-to-visible, so the stream never + // stopped and no reconnect was needed. + await act(async () => { + vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS) + }) + expect(stopFrameStream).not.toHaveBeenCalled() + expect(startFrameStream).toHaveBeenCalledTimes(1) + }) + + it('keeps streaming while hidden when occlusion state is proven stale (display-sleep wedge)', async () => { + vi.useFakeTimers() + await renderFrame(true) + expect(startFrameStream).toHaveBeenCalledTimes(1) + + // Window reports hidden, but real user input proves the occlusion tracker is + // wedged; the stream must keep running instead of freezing on a black frame. + await act(async () => { + setDocumentVisibility('hidden') + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })) + }) + await act(async () => { + vi.advanceTimersByTime(EMULATOR_STREAM_PARK_DELAY_MS * 2) + }) + expect(stopFrameStream).not.toHaveBeenCalled() + expect(startFrameStream).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx b/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx index 9b278241d17..1dd787c9c99 100644 --- a/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx +++ b/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx @@ -31,6 +31,7 @@ import { EmulatorScreenSurface } from './emulator-screen-surface' import { useEmulatorControlStream } from './use-emulator-control-stream' import { useEmulatorPaneSize } from './use-emulator-pane-size' import { useEmulatorScreenKeyboard } from './use-emulator-screen-keyboard' +import { useEmulatorStreamWindowVisible } from './use-emulator-stream-window-visibility' type EmulatorDeviceFrameProps = { previewUrl?: string @@ -335,9 +336,13 @@ export function EmulatorDeviceFrame({ setStreamError(true) }, []) - // Why: hidden panes still receive emulator frames, including over SSH, so - // parking the stream avoids background decode/IPC churn while staying attached. - const showStream = isActive && isLive && Boolean(previewUrl) + // Why: a hidden/occluded window (or a background tab) still receives emulator + // frames, including over SSH; parking the stream avoids background decode/IPC + // churn while staying attached. isActive covers the background-tab case; + // windowVisibleForStream additionally parks when the whole window is hidden + // (minimize / occlusion / display sleep), which no tab gate catches. + const windowVisibleForStream = useEmulatorStreamWindowVisible() + const showStream = isActive && isLive && windowVisibleForStream && Boolean(previewUrl) const streamAspectRatio = streamSize ? streamSize.width / streamSize.height : 9 / 19 // Why: serve-sim may keep portrait-sized pixels for portrait-locked apps; the // physical frame still follows the last successful rotate request. diff --git a/src/renderer/src/components/emulator-pane/use-emulator-stream-window-visibility.ts b/src/renderer/src/components/emulator-pane/use-emulator-stream-window-visibility.ts new file mode 100644 index 00000000000..3ca818b2d6f --- /dev/null +++ b/src/renderer/src/components/emulator-pane/use-emulator-stream-window-visibility.ts @@ -0,0 +1,59 @@ +import { useEffect, useState, useSyncExternalStore } from 'react' +import { isWindowVisible } from '@/lib/window-visibility-interval' +import { + isDocumentVisibilityProvenStale, + registerStaleDocumentVisibilityRecovery +} from '../terminal-pane/stale-document-visibility' + +// Why: after display sleep macOS can wedge document.visibilityState at 'hidden' +// with no further visibilitychange event; honor the terminal occlusion-staleness +// latch so a window the user is actually looking at is never treated as hidden — +// otherwise the emulator freezes on a black frame with no recovery (same bug class +// as the 78MB terminal drop that motivated the latch). +function getWindowVisibleSnapshot(): boolean { + return isWindowVisible() || isDocumentVisibilityProvenStale() +} + +function subscribeWindowVisible(onChange: () => void): () => void { + const handler = (): void => onChange() + document.addEventListener('visibilitychange', handler) + // Why: the stale latch flips visibility to proven-visible without emitting a + // visibilitychange, so recompute when it fires too. + const unregister = registerStaleDocumentVisibilityRecovery(handler) + return () => { + document.removeEventListener('visibilitychange', handler) + unregister() + } +} + +// Why: parking is delayed so a quick Cmd+Tab / app-switch round-trip does not tear +// down and renegotiate the device stream (MJPEG reconnect or scrcpy H.264 keyframe), +// which is heavier than a terminal resync and flashes the "Connecting…" UI. Re-showing +// restores immediately. +export const EMULATOR_STREAM_PARK_DELAY_MS = 500 + +/** + * Reactive "is this window visible enough to keep the emulator device stream + * running" signal. Returns true while the window is visible (or occlusion state is + * proven stale) and defers the visible→hidden transition by `parkDelayMs`. + */ +export function useEmulatorStreamWindowVisible( + parkDelayMs = EMULATOR_STREAM_PARK_DELAY_MS +): boolean { + const rawVisible = useSyncExternalStore( + subscribeWindowVisible, + getWindowVisibleSnapshot, + getWindowVisibleSnapshot + ) + const [effectiveVisible, setEffectiveVisible] = useState(rawVisible) + useEffect(() => { + if (rawVisible) { + // Restore immediately so returning to the window resumes without delay. + setEffectiveVisible(true) + return + } + const timer = window.setTimeout(() => setEffectiveVisible(false), parkDelayMs) + return () => window.clearTimeout(timer) + }, [rawVisible, parkDelayMs]) + return effectiveVisible +} From b0e36169d8fe882263a28ea2261c6acf55dc5a8b Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:37:34 -0700 Subject: [PATCH 03/10] fix: return native promise from awaitClick on Zone.js pages (#9995) Zone.js patches the global Promise with a non-native thenable. When a bare `new Promise(...)` crosses the Electron executeJavaScript boundary, it's serialized as-is, losing { page, target } and exposing __zone_symbol__* fields instead. Wrap in an async IIFE to return a native promise that Electron always unwraps correctly. --- src/main/browser/grab-guest-script.test.ts | 199 +++++++++++++++++++++ src/main/browser/grab-guest-script.ts | 151 ++++++++-------- 2 files changed, 280 insertions(+), 70 deletions(-) diff --git a/src/main/browser/grab-guest-script.test.ts b/src/main/browser/grab-guest-script.test.ts index 17f9f95b483..a86764a5733 100644 --- a/src/main/browser/grab-guest-script.test.ts +++ b/src/main/browser/grab-guest-script.test.ts @@ -1,5 +1,8 @@ +import { types } from 'node:util' +import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' import { buildGuestOverlayScript } from './grab-guest-script' +import { clampGrabPayload } from './browser-grab-payload' describe('buildGuestOverlayScript', () => { it('returns a non-empty string for arm action', () => { @@ -158,3 +161,199 @@ describe('buildGuestOverlayScript', () => { expect(script).not.toContain('ariaLabelledBy.split(/\\s+/)') }) }) + +// Regression coverage for issue #9947: Zone.js swaps the global Promise for a +// non-native thenable, which executeJavaScript won't unwrap — so a page-global +// `new Promise(...)` crossed as its raw `__zone_symbol__*` wrapper, not { page, target }. +describe('awaitClick under a Zone.js-patched global Promise', () => { + type Executor = (resolve: (value: T) => void, reject: (reason: unknown) => void) => void + + // Native promises are kept off the instance so its own enumerable keys match + // Zone.js exactly: ['__zone_symbol__state', '__zone_symbol__value']. + const nativeOf = new WeakMap>() + + /** A non-native thenable that mimics Zone.js's ZoneAwarePromise wrapper. */ + class ZoneAwarePromiseLike { + __zone_symbol__state: unknown = null + __zone_symbol__value: unknown = undefined + + constructor(executor: Executor) { + nativeOf.set( + this, + new Promise((res, rej) => { + executor( + (value) => { + this.__zone_symbol__state = true + this.__zone_symbol__value = value + res(value) + }, + (reason) => { + this.__zone_symbol__state = false + this.__zone_symbol__value = reason + rej(reason) + } + ) + }) + ) + } + + // Why: real Zone.js defines `get [Symbol.toStringTag]() { return 'Promise' }`, + // so an Object.prototype.toString check is fooled into seeing a promise. The + // boundary below deliberately uses the brand-based V8 check instead, and the + // control test asserts this tag does NOT let the wrapper masquerade as native. + get [Symbol.toStringTag](): string { + return 'Promise' + } + + // oxlint-disable-next-line unicorn/no-thenable -- intentionally a non-native thenable modeling Zone.js's ZoneAwarePromise + then( + onFulfilled?: ((value: unknown) => unknown) | null, + onRejected?: ((reason: unknown) => unknown) | null + ): Promise { + const native = nativeOf.get(this) as Promise + return native.then(onFulfilled ?? undefined, onRejected ?? undefined) + } + } + + /** + * Models Electron's boundary: it awaits only genuine V8 promises and + * serializes anything else — including non-native thenables — by value. + * `util.types.isPromise` is V8's brand-based IsPromise (what Electron uses): + * realm-independent and unforgeable, unlike an Object.prototype.toString / + * Symbol.toStringTag check that a ZoneAwarePromise would defeat. + */ + async function crossExecuteJavaScriptBoundary(completionValue: unknown): Promise { + if (types.isPromise(completionValue)) { + return await (completionValue as Promise) + } + return { ...(completionValue as Record) } + } + + const validPayload = (): Record => ({ + // A minimal but structurally valid payload — page + target are what + // clampGrabPayload requires and what the bug stripped away. + page: { title: 'Angular App' }, + target: { tagName: 'button' }, + nearbyText: [], + ancestorPath: [], + screenshot: null + }) + + function armGrabHarness(options?: { + extractPayload?: () => unknown + getCurrentElement?: () => unknown + }): { + window: { __orcaGrab: Record } + click: () => void + contextmenu: () => void + cancel: () => void + } { + const handlers: Record void> = {} + const noopEvent = { + preventDefault(): void {}, + stopPropagation(): void {}, + stopImmediatePropagation(): void {} + } + const grab: Record = { + host: { + addEventListener(type: string, fn: (event: unknown) => void): void { + handlers[type] = fn + }, + removeEventListener(): void {} + }, + extractPayload: options?.extractPayload ?? validPayload, + getCurrentElement: options?.getCurrentElement ?? ((): unknown => ({})), + freezeHighlight(): void {}, + cleanup(): void {} + } + const window = { __orcaGrab: grab } + return { + window, + click: () => handlers.click?.(noopEvent), + contextmenu: () => handlers.contextmenu?.(noopEvent), + // cancelAwait is installed on __orcaGrab by the script itself at runtime. + cancel: () => (window.__orcaGrab.cancelAwait as (() => void) | undefined)?.() + } + } + + const runAwaitClick = (harness: ReturnType): unknown => + runInNewContext(buildGuestOverlayScript('awaitClick'), { + window: harness.window, + Promise: ZoneAwarePromiseLike, + Error + }) + + it('returns the payload through a native async promise, not the page-global Promise', () => { + const script = buildGuestOverlayScript('awaitClick') + expect(script).toContain('(async function()') + expect(script).toContain('return await new Promise(') + }) + + it('resolves { page, target } across the boundary despite ZoneAwarePromise', async () => { + const harness = armGrabHarness() + const completion = runAwaitClick(harness) + + // The async IIFE hands Electron an intrinsic promise even though the global + // Promise is a non-native thenable — so the boundary unwraps it. + expect(types.isPromise(completion)).toBe(true) + + harness.click() + const received = await crossExecuteJavaScriptBoundary(completion) + + expect(received).toHaveProperty('page') + expect(received).toHaveProperty('target') + expect(received).not.toHaveProperty('__zone_symbol__value') + expect(clampGrabPayload(received)).not.toBeNull() + }) + + it('resolves the context-menu marker across the boundary despite ZoneAwarePromise', async () => { + const harness = armGrabHarness() + const completion = runAwaitClick(harness) + + harness.contextmenu() + const received = (await crossExecuteJavaScriptBoundary(completion)) as Record + + expect(received).toHaveProperty('__orcaContextMenu', true) + expect(received.payload).toHaveProperty('page') + expect(clampGrabPayload(received.payload)).not.toBeNull() + }) + + it('resolves the teardown cancel marker across the boundary despite ZoneAwarePromise', async () => { + const harness = armGrabHarness() + const completion = runAwaitClick(harness) + + harness.cancel() + const received = await crossExecuteJavaScriptBoundary(completion) + + expect(received).toEqual({ __orcaCancelled: true }) + }) + + it('rejects across the boundary when selection fails despite ZoneAwarePromise', async () => { + // getCurrentElement -> null drives onClick's reject(new Error('cancelled')), + // which must surface as a rejected intrinsic promise (not a serialized value) + // so the controller classifies it as a cancellation rather than a payload. + const harness = armGrabHarness({ getCurrentElement: () => null }) + const completion = runAwaitClick(harness) + + harness.click() + await expect(crossExecuteJavaScriptBoundary(completion)).rejects.toThrow('cancelled') + }) + + it('control: a bare page-global new Promise would cross as the raw wrapper', async () => { + // Proves the harness detects the regression: without the async wrapper the + // completion value is the ZoneAwarePromise itself, which the boundary + // serializes to __zone_symbol__* fields with no page/target. + const bare = runInNewContext('new Promise(function(r){ r({ page: {}, target: {} }); })', { + Promise: ZoneAwarePromiseLike + }) + // Symbol.toStringTag='Promise' fools a toString check — exactly why the + // boundary must use the brand-based IsPromise, which still rejects it. + expect(Object.prototype.toString.call(bare)).toBe('[object Promise]') + expect(types.isPromise(bare)).toBe(false) + + const received = await crossExecuteJavaScriptBoundary(bare) + expect(received).not.toHaveProperty('page') + expect(received).toHaveProperty('__zone_symbol__value') + expect(clampGrabPayload(received)).toBeNull() + }) +}) diff --git a/src/main/browser/grab-guest-script.ts b/src/main/browser/grab-guest-script.ts index 8841fb6cf97..d1d089be1af 100644 --- a/src/main/browser/grab-guest-script.ts +++ b/src/main/browser/grab-guest-script.ts @@ -823,80 +823,90 @@ const ARM_SCRIPT = `(function() { })()` // awaitClick: resolve when the user clicks the overlay; stopPropagation + pointer-events:all keep the click off the page. -const AWAIT_CLICK_SCRIPT = `new Promise(function(resolve, reject) { - 'use strict'; - var grab = window.__orcaGrab; - if (!grab) { - reject(new Error('Grab not armed')); - return; - } - - function extractSelectedPayload(el) { - try { - return grab.extractPayload(el); - } catch (error) { - grab.cleanup(); - reject(error instanceof Error ? error : new Error('Failed to extract element context')); - return null; - } - } - - function onClick(e) { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - grab.host.removeEventListener('click', onClick, true); - grab.host.removeEventListener('contextmenu', onContext, true); - var el = grab.getCurrentElement(); - if (!el) { - grab.cleanup(); - reject(new Error('cancelled')); +const AWAIT_CLICK_SCRIPT = `(async function() { + // Why: hand the click result to executeJavaScript through a native (intrinsic) + // Promise. On pages that replace the global Promise with a non-native thenable + // — e.g. Angular Zone.js's ZoneAwarePromise — a bare \`new Promise(...)\` is not + // recognized as a promise by Electron, so its raw wrapper object (exposing + // __zone_symbol__state/__value instead of { page, target }) crosses the boundary + // and main rejects it as an invalid payload structure. An async function's + // promise comes from the engine intrinsic that page code cannot reassign, so + // Electron always unwraps it to the resolved payload. + return await new Promise(function(resolve, reject) { + 'use strict'; + var grab = window.__orcaGrab; + if (!grab) { + reject(new Error('Grab not armed')); return; } - var payload = extractSelectedPayload(el); - if (!payload) return; - // Why: freeze the highlight instead of removing it so the user sees - // which element was selected while the copy menu is shown. Teardown - // happens later when the renderer calls setGrabMode(false) or re-arms. - grab.freezeHighlight(); - resolve(payload); - } - function onContext(e) { - // Why: right-click resolves with the payload wrapped in a context-menu - // marker so the renderer can show the full action dropdown instead of - // auto-copying. This gives users a deliberate path to screenshot and - // other secondary actions while keeping left-click as the fast copy path. - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - grab.host.removeEventListener('click', onClick, true); - grab.host.removeEventListener('contextmenu', onContext, true); - var el = grab.getCurrentElement(); - if (!el) { - grab.cleanup(); - reject(new Error('cancelled')); - return; + function extractSelectedPayload(el) { + try { + return grab.extractPayload(el); + } catch (error) { + grab.cleanup(); + reject(error instanceof Error ? error : new Error('Failed to extract element context')); + return null; + } } - var payload = extractSelectedPayload(el); - if (!payload) return; - grab.freezeHighlight(); - resolve({ __orcaContextMenu: true, payload: payload }); - } - grab.host.addEventListener('click', onClick, true); - grab.host.addEventListener('contextmenu', onContext, true); + function onClick(e) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + grab.host.removeEventListener('click', onClick, true); + grab.host.removeEventListener('contextmenu', onContext, true); + var el = grab.getCurrentElement(); + if (!el) { + grab.cleanup(); + reject(new Error('cancelled')); + return; + } + var payload = extractSelectedPayload(el); + if (!payload) return; + // Why: freeze the highlight instead of removing it so the user sees + // which element was selected while the copy menu is shown. Teardown + // happens later when the renderer calls setGrabMode(false) or re-arms. + grab.freezeHighlight(); + resolve(payload); + } - // Store cancel hook so teardown can settle the Promise - grab.cancelAwait = function() { - grab.host.removeEventListener('click', onClick, true); - grab.host.removeEventListener('contextmenu', onContext, true); - grab.cleanup(); - // Why: teardown cancellation is a normal user flow; resolving a marker - // avoids a noisy guest-console Error while main still treats it as cancel. - resolve({ __orcaCancelled: true }); - }; -})` + function onContext(e) { + // Why: right-click resolves with the payload wrapped in a context-menu + // marker so the renderer can show the full action dropdown instead of + // auto-copying. This gives users a deliberate path to screenshot and + // other secondary actions while keeping left-click as the fast copy path. + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + grab.host.removeEventListener('click', onClick, true); + grab.host.removeEventListener('contextmenu', onContext, true); + var el = grab.getCurrentElement(); + if (!el) { + grab.cleanup(); + reject(new Error('cancelled')); + return; + } + var payload = extractSelectedPayload(el); + if (!payload) return; + grab.freezeHighlight(); + resolve({ __orcaContextMenu: true, payload: payload }); + } + + grab.host.addEventListener('click', onClick, true); + grab.host.addEventListener('contextmenu', onContext, true); + + // Store cancel hook so teardown can settle the Promise + grab.cancelAwait = function() { + grab.host.removeEventListener('click', onClick, true); + grab.host.removeEventListener('contextmenu', onContext, true); + grab.cleanup(); + // Why: teardown cancellation is a normal user flow; resolving a marker + // avoids a noisy guest-console Error while main still treats it as cancel. + resolve({ __orcaCancelled: true }); + }; + }); +})()` const FINALIZE_SCRIPT = `(function() { 'use strict'; @@ -933,8 +943,9 @@ const TEARDOWN_SCRIPT = `(function() { 'use strict'; var grab = window.__orcaGrab; if (!grab) return true; - // If there's an active awaitClick Promise, cancel it so the - // executeJavaScript call in main rejects and settles the grab op. + // If there's an active awaitClick Promise, cancel it: cancelAwait resolves + // it with the __orcaCancelled marker so the executeJavaScript call in main + // settles the grab op as a cancellation. if (grab.cancelAwait) { grab.cancelAwait(); } else { From 4062ed5a73865cb6d3aeb2ac04147633a1991aa8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:42:58 -0700 Subject: [PATCH 04/10] perf(worktrees): back off worktree scans for agent-scratch repos (#9985) Production crash diagnostics measured ~128 `git worktree list` execs/min (9,400 in one 80-minute session, ~16% of wall-clock in git subprocesses): the resolved-worktree scan fans out over every registered repo on a 30s cache TTL, and most registered repos on the affected installs were agent-CLI scratch repos (~/.codex-tmp capsules, vendor imports, skill checkouts) that need no freshness. Classify agent-scratch repo roots with a curated shared matcher and stamp their scan-cache entries with a 5-minute TTL instead of 30s. Orca-driven mutations still bypass the TTL via the per-repo generation bump, so only passive pickup of external changes slows for scratch repos. Expected steady-state reduction on the measured install: ~82% fewer git spawns. --- src/main/runtime/orca-runtime.test.ts | 81 ++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 13 +++- src/shared/agent-scratch-worktrees.test.ts | 42 +++++++++++ src/shared/agent-scratch-worktrees.ts | 24 +++++++ 4 files changed, 159 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index f58d0610f57..3a5099edde2 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -56,6 +56,7 @@ import { OrcaRuntimeService, recentTerminalPathCandidatesIncludePath, recentTerminalOutputIncludesPath, + resolveWorktreeScanCacheTtlMs, type RuntimeTerminalAgentStatusEvent } from './orca-runtime' import { RecentPtyOutputBuffer } from './recent-pty-output-buffer' @@ -34979,3 +34980,83 @@ describe('OrcaRuntimeService', () => { }) }) }) + +describe('resolveWorktreeScanCacheTtlMs', () => { + const BASE_TTL_MS = 30_000 + const SCRATCH_TTL_MS = 5 * 60_000 + + it('keeps the base TTL for ordinary local repos', () => { + expect( + resolveWorktreeScanCacheTtlMs({ path: '/Users/dev/projects/app', connectionId: '' }) + ).toBe(BASE_TTL_MS) + }) + + it('extends the TTL for agent-scratch repo roots', () => { + expect( + resolveWorktreeScanCacheTtlMs({ + path: '/Users/dev/.codex-tmp/foragent-capsule-b1-repo-zP9Az6', + connectionId: '' + }) + ).toBe(SCRATCH_TTL_MS) + expect( + resolveWorktreeScanCacheTtlMs({ + path: '/Users/dev/.claude/skills/obsidian-second-brain', + connectionId: '' + }) + ).toBe(SCRATCH_TTL_MS) + }) + + it('never extends the TTL for SSH repos', () => { + // Why: scratch classification reads local path conventions; a remote path + // that merely looks similar must keep normal freshness. + expect( + resolveWorktreeScanCacheTtlMs({ + path: '/home/dev/.codex-tmp/capsule', + connectionId: 'ssh-1' + }) + ).toBe(BASE_TTL_MS) + }) + + it('keeps a scratch repo scan cached past the base TTL while normal repos rescan', async () => { + // Why: the whole fix lives in the cache-stamp call site; pin the wiring so + // a revert to the flat TTL fails CI, not just the pure-function tests. + vi.useFakeTimers() + // Why: the shared listWorktrees stub keeps call history across this file's + // tests; absolute counts need a clean baseline. + vi.mocked(listWorktrees).mockClear() + try { + const scratchPath = '/tmp/.codex-tmp/capsule-a' + const runtime = new OrcaRuntimeService({ + ...store, + getRepos: () => [ + { id: 'repo-1', path: '/tmp/repo', displayName: 'repo', badgeColor: 'blue', addedAt: 1 }, + { + id: 'repo-scratch', + path: scratchPath, + displayName: 'capsule', + badgeColor: 'blue', + addedAt: 1 + } + ] + } as never) + const internals = runtime as unknown as { listResolvedWorktrees: () => Promise } + const scanCallsFor = (path: string): number => + vi.mocked(listWorktrees).mock.calls.filter((call) => call[0] === path).length + + await internals.listResolvedWorktrees() + expect(scanCallsFor('/tmp/repo')).toBe(1) + expect(scanCallsFor(scratchPath)).toBe(1) + + vi.advanceTimersByTime(BASE_TTL_MS + 1_000) + await internals.listResolvedWorktrees() + expect(scanCallsFor('/tmp/repo')).toBe(2) + expect(scanCallsFor(scratchPath)).toBe(1) + + vi.advanceTimersByTime(SCRATCH_TTL_MS) + await internals.listResolvedWorktrees() + expect(scanCallsFor(scratchPath)).toBe(2) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5c25ce81792..bbeba293b58 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -314,6 +314,7 @@ import { } from '../../shared/worktree-ownership' import { createAgentScratchWorktreePathMatcher, + isAgentScratchRepoRootPath, type AgentScratchWorktreePathMatcher } from '../../shared/agent-scratch-worktrees' import { @@ -23853,7 +23854,7 @@ export class OrcaRuntimeService { generation, runtimeKey, result, - expiresAt: Date.now() + WORKTREE_SCAN_CACHE_TTL_MS + expiresAt: Date.now() + resolveWorktreeScanCacheTtlMs(repo) }) } return result @@ -29253,7 +29254,17 @@ const DEFAULT_WORKTREE_PS_LIMIT = 200 const DISCONNECTED_PTY_RECORD_MAX = 128 const RESOLVED_WORKTREE_CACHE_TTL_MS = 1000 const WORKTREE_SCAN_CACHE_TTL_MS = 30_000 +// Why: agent-scratch repos don't need 30s freshness — the steady-state scan +// fan-out was measured at ~128 git execs/min on real installs, mostly against +// these (crash-cluster diagnostics, 2026-07). +const WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS = 5 * 60_000 const RESOLVED_WORKTREE_REPO_TIMEOUT_MS = 5000 + +export function resolveWorktreeScanCacheTtlMs(repo: Pick): number { + return !repo.connectionId && isAgentScratchRepoRootPath(repo.path) + ? WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS + : WORKTREE_SCAN_CACHE_TTL_MS +} const PTY_CONTROLLER_LIST_TIMEOUT_MS = 3000 // Why: the renderer waits 15s; leave room for the verified failure response and release the spawn fence before its caller times out. const WORKTREE_TERMINAL_SLEEP_TIMEOUT_MS = 12_000 diff --git a/src/shared/agent-scratch-worktrees.test.ts b/src/shared/agent-scratch-worktrees.test.ts index b2714bd6464..839833c004e 100644 --- a/src/shared/agent-scratch-worktrees.test.ts +++ b/src/shared/agent-scratch-worktrees.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { createAgentScratchWorktreePathMatcher, + isAgentScratchRepoRootPath, isAgentScratchWorktreePath } from './agent-scratch-worktrees' @@ -101,3 +102,44 @@ describe('isAgentScratchWorktreePath', () => { expect(isAgentScratchWorktreePath('/Users/dev/app', '/orca/workspaces/app/feature')).toBe(false) }) }) + +describe('isAgentScratchRepoRootPath', () => { + it('matches codex scratch capsule repos', () => { + expect( + isAgentScratchRepoRootPath('/Users/dev/.codex-tmp/foragent-capsule-b1-repo-zP9Az6') + ).toBe(true) + expect(isAgentScratchRepoRootPath('/Users/dev/.codex-tmp/rc-fwd-qEXuEq')).toBe(true) + }) + + it('matches codex vendor imports and claude skills containers', () => { + expect(isAgentScratchRepoRootPath('/Users/dev/.codex/vendor_imports/skills')).toBe(true) + expect(isAgentScratchRepoRootPath('/Users/dev/.claude/skills/obsidian-second-brain')).toBe(true) + }) + + it('matches a repo registered at the scratch container itself', () => { + expect(isAgentScratchRepoRootPath('/Users/dev/.codex-tmp')).toBe(true) + expect(isAgentScratchRepoRootPath('/Users/dev/.codex/vendor_imports')).toBe(true) + }) + + it('matches scratch worktree containers used as repo roots', () => { + expect(isAgentScratchRepoRootPath('/Users/dev/app/.claude/worktrees/agent-a04ccaaa')).toBe(true) + expect(isAgentScratchRepoRootPath('/Users/dev/app/.gsd-workspaces/phase-1')).toBe(true) + }) + + it('matches Windows separators and casing', () => { + expect(isAgentScratchRepoRootPath('C:\\Users\\Dev\\.codex-tmp\\Capsule-X')).toBe(true) + expect(isAgentScratchRepoRootPath('C:\\Users\\Dev\\.Claude\\Skills\\foo')).toBe(true) + }) + + it('does not match ordinary user repos', () => { + expect(isAgentScratchRepoRootPath('/Users/dev/projects/app')).toBe(false) + expect(isAgentScratchRepoRootPath('/Users/dev/codex-tmp/app')).toBe(false) + expect(isAgentScratchRepoRootPath('/Users/dev/.codex/checkouts/app')).toBe(false) + expect(isAgentScratchRepoRootPath('/Users/dev/skills/.claude-app')).toBe(false) + }) + + it('does not match partial multi-segment markers', () => { + expect(isAgentScratchRepoRootPath('/Users/dev/.claude/config')).toBe(false) + expect(isAgentScratchRepoRootPath('/Users/dev/vendor_imports/app')).toBe(false) + }) +}) diff --git a/src/shared/agent-scratch-worktrees.ts b/src/shared/agent-scratch-worktrees.ts index 2ff216fbd6e..88be8646435 100644 --- a/src/shared/agent-scratch-worktrees.ts +++ b/src/shared/agent-scratch-worktrees.ts @@ -37,3 +37,27 @@ export function createAgentScratchWorktreePathMatcher( export function isAgentScratchWorktreePath(repoPath: string, worktreePath: string): boolean { return createAgentScratchWorktreePathMatcher([repoPath])(worktreePath) } + +/** Why: agent CLIs also mint whole scratch *repos* under these containers; a + * repo registered at such a root is agent-internal, not a user project (#9388). */ +const AGENT_SCRATCH_REPO_ROOT_SEGMENTS: readonly (readonly string[])[] = [ + ['.codex-tmp'], + ['.codex', 'vendor_imports'], + ['.claude', 'skills'], + ...AGENT_SCRATCH_PATH_PREFIXES +] + +export function isAgentScratchRepoRootPath(repoPath: string): boolean { + const segments = normalizeRuntimePathForComparison(repoPath).split('/') + for (const marker of AGENT_SCRATCH_REPO_ROOT_SEGMENTS) { + // Why: match the marker anywhere above the repo root (the repo lives at or + // under the scratch container), unlike worktree matching which anchors to a + // registered checkout path. + for (let index = 0; index + marker.length <= segments.length; index += 1) { + if (marker.every((segment, offset) => segments[index + offset] === segment)) { + return true + } + } + } + return false +} From 8dc1ca49200377531caee20cbe2dbb5e3c344c41 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:45:03 -0700 Subject: [PATCH 05/10] fix(daemon): retire macOS daemons whose login session died (#7936) (#9826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(daemon): retire macOS daemons whose login session died (#7936) A daemon that survives a full macOS logout is unsalvageable: its PAM context can no longer host login(1) spawns (every new PTY becomes a 'Login incorrect' prompt zombie) and its Mach bootstrap namespace has lost the system DNS resolver, so terminals it hosts have no egress. Today it also keeps the #9301 preflight's cached 'accepted' verdict, so it keeps wrapping spawns in login(1) forever; only a manual daemon restart recovers. GUI-spawned daemons now watch for login-session death from the inside: a fresh cache-bypassing PAM probe (triggered by PTY-exit bursts, fresh client hellos, and a slow periodic timer) must conclusively reject three consecutive times AND the in-process system resolver must be degraded; then the daemon exits crash-style so session meta stays unclean and the replacement daemon cold-restores scrollback. A conclusive rejection also flips the spawn-wrapper cache off immediately. Headless serve/SSH daemons never get the watch (they must survive their spawning session ending), and a session that never conclusively accepted login(1) never arms it — a PAM anomaly alone can't kill a healthy daemon (fast user switching keeps accepting, so switched-away sessions are preserved). * test(daemon): e2e seam to drive login-session death oracles from a verdict file A dead macOS login session cannot be fabricated without root (PAM owns audit-session teardown), so live lifecycle QA drives the death watch's probe and resolver oracles from ORCA_E2E_LOGIN_SESSION_PROBE_FILE: 'alive' → accepted/healthy, 'dead' → rejected/unhealthy, anything else inconclusive — with compressed watch timing. Mirrors the existing ORCA_E2E_DAEMON_INIT_DELAY_MS seam; inert unless the env var is set. * fix(daemon): close the hang-shaped gap in login-session death detection The conclusive-PAM-verdict trigger had one blind failure shape: login(1) hanging at the prompt past the probe bound (killed → inconclusive forever → the watch never fires). Three changes close it: - The death-watch probe gets its own 4s bound (the 500ms preflight bound exists for spawn-path latency, which doesn't apply off-path), so a slow-but-answering PAM stack isn't misread as a hang. - An inconclusive pipe probe escalates to a PTY-hosted probe via script(1) — a dead session's PAM stack may only misbehave under a real tty (the pipe-vs-PTY fidelity limit the preflight documents). - A streak of timeout-killed probes (which a live session never produces) is a second retirement trigger, at a higher threshold (5) and still gated on the degraded resolver, logged with a distinct cause so field logs discriminate the two paths. Every dead-session behavior — fast reject, prompt-then-EOF, or hang — now fires retirement; all inconclusive states still fail toward preserving the daemon. * fix(daemon): keep login-session retirement conclusive * fix(daemon): stop login watch before clean shutdown * fix(daemon): make login-session PTY probe reliable * fix(daemon): close login-session watch races * fix(daemon): ignore health probes for login watch activity --- ...emon-authenticated-client-activity.test.ts | 77 ++++ src/main/daemon/daemon-entry.test.ts | 13 + src/main/daemon/daemon-entry.ts | 83 +++- src/main/daemon/daemon-init.test.ts | 6 +- src/main/daemon/daemon-init.ts | 13 +- src/main/daemon/daemon-main.ts | 6 + src/main/daemon/daemon-server.ts | 12 +- .../macos-login-session-death-watch.test.ts | 374 ++++++++++++++++++ .../daemon/macos-login-session-death-watch.ts | 317 +++++++++++++++ src/main/daemon/terminal-host-options.ts | 2 + src/main/daemon/terminal-host.test.ts | 3 + src/main/daemon/terminal-host.ts | 3 + src/main/index.ts | 6 +- .../macos-system-resolver-health.test.ts | 17 + .../network/macos-system-resolver-health.ts | 12 +- .../macos-login-session-pty-probe.test.ts | 129 ++++++ .../macos-login-session-pty-probe.ts | 78 ++++ .../providers/macos-tcc-login-shell.test.ts | 221 ++++++++++- src/main/providers/macos-tcc-login-shell.ts | 111 ++++-- 19 files changed, 1440 insertions(+), 43 deletions(-) create mode 100644 src/main/daemon/daemon-authenticated-client-activity.test.ts create mode 100644 src/main/daemon/macos-login-session-death-watch.test.ts create mode 100644 src/main/daemon/macos-login-session-death-watch.ts create mode 100644 src/main/providers/macos-login-session-pty-probe.test.ts create mode 100644 src/main/providers/macos-login-session-pty-probe.ts diff --git a/src/main/daemon/daemon-authenticated-client-activity.test.ts b/src/main/daemon/daemon-authenticated-client-activity.test.ts new file mode 100644 index 00000000000..7da5e00e387 --- /dev/null +++ b/src/main/daemon/daemon-authenticated-client-activity.test.ts @@ -0,0 +1,77 @@ +import { readFileSync, mkdtempSync, rmSync } from 'node:fs' +import { connect, type Socket } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DaemonServer } from './daemon-server' +import { getDaemonSocketPath } from './daemon-spawner' +import { encodeNdjson } from './ndjson' +import type { SubprocessHandle } from './session' +import { PROTOCOL_VERSION } from './types' + +function unusedSubprocess(): SubprocessHandle { + throw new Error('Test must not create a PTY') +} + +describe('daemon authenticated client activity', () => { + let dir: string + let socketPath: string + let tokenPath: string + let server: DaemonServer + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'daemon-client-activity-')) + socketPath = getDaemonSocketPath(dir) + tokenPath = join(dir, 'daemon.token') + }) + + afterEach(async () => { + await server?.shutdown() + rmSync(dir, { recursive: true, force: true }) + }) + + async function connectHello(role: 'control' | 'stream', clientId: string): Promise { + const socket = connect(socketPath) + await new Promise((resolve) => socket.once('connect', resolve)) + socket.write( + encodeNdjson({ + type: 'hello', + version: PROTOCOL_VERSION, + token: readFileSync(tokenPath, 'utf8').trim(), + clientId, + role + }) + ) + await new Promise((resolve, reject) => { + socket.once('data', (data) => { + const response = JSON.parse(data.toString().trim()) as { ok?: boolean; error?: string } + if (response.ok) { + resolve() + } else { + reject(new Error(response.error ?? 'hello rejected')) + } + }) + socket.once('error', reject) + }) + return socket + } + + it('excludes control-only health probes and reports one complete app pair', async () => { + const onAuthenticatedClientPair = vi.fn() + server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: unusedSubprocess, + onAuthenticatedClientPair + }) + await server.start() + + await connectHello('control', 'resolver-health-check') + expect(onAuthenticatedClientPair).not.toHaveBeenCalled() + + await connectHello('control', 'app-client') + expect(onAuthenticatedClientPair).not.toHaveBeenCalled() + await connectHello('stream', 'app-client') + expect(onAuthenticatedClientPair).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/daemon/daemon-entry.test.ts b/src/main/daemon/daemon-entry.test.ts index deedb82a2c8..6b02210d72a 100644 --- a/src/main/daemon/daemon-entry.test.ts +++ b/src/main/daemon/daemon-entry.test.ts @@ -97,4 +97,17 @@ describe('daemon-entry parseArgs', () => { it('still requires --socket and --token when --log-file is given', () => { expect(() => parseArgs(['--log-file', '/tmp/daemon.log'])).toThrow('Usage:') }) + + it('parses the GUI-only --login-session-watch flag and omits it when absent', () => { + expect( + parseArgs(['--socket', '/tmp/t.sock', '--token', '/tmp/t.token', '--login-session-watch']) + ).toEqual({ + socketPath: '/tmp/t.sock', + tokenPath: '/tmp/t.token', + loginSessionWatch: true + }) + expect(parseArgs(['--socket', '/tmp/t.sock', '--token', '/tmp/t.token'])).not.toHaveProperty( + 'loginSessionWatch' + ) + }) }) diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 6488ffce048..ba98af21c5b 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -6,19 +6,27 @@ * Signals readiness to parent via IPC: { type: 'ready' } * Shuts down cleanly on SIGTERM. */ +import { readFileSync } from 'node:fs' import { startDaemon, type DaemonHandle } from './daemon-main' import { createPtySubprocess } from './pty-subprocess' import { warmWindowsConptyOnce } from './windows-conpty-warmup' import { warmPwshAvailabilityCache } from '../pwsh' import { createDaemonFileLog, createNoopDaemonFileLog } from './daemon-file-log' import { PROTOCOL_VERSION } from './types' -import { prepareMacosTccLoginShell } from '../providers/macos-tcc-login-shell' +import { + prepareMacosTccLoginShell, + probeMacosLoginSessionAlive +} from '../providers/macos-tcc-login-shell' +import { MacosLoginSessionDeathWatch } from './macos-login-session-death-watch' +import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' export type ParsedDaemonArgs = { socketPath: string tokenPath: string pidPath?: string launchNonce?: string + /** GUI-spawned daemons only — headless serve/SSH daemons must survive session loss. */ + loginSessionWatch?: boolean /** Optional — absent for adopted old daemons and tests, which log nothing. */ logFilePath?: string } @@ -29,6 +37,7 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs { let logFilePath = '' let pidPath = '' let launchNonce = '' + let loginSessionWatch = false for (let i = 0; i < argv.length; i++) { if (argv[i] === '--socket' && argv[i + 1]) { @@ -46,6 +55,8 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs { } else if (argv[i] === '--launch-nonce' && argv[i + 1]) { launchNonce = argv[i + 1] i++ + } else if (argv[i] === '--login-session-watch') { + loginSessionWatch = true } } @@ -61,6 +72,7 @@ export function parseArgs(argv: string[]): ParsedDaemonArgs { socketPath, tokenPath, ...(pidPath ? { pidPath, launchNonce } : {}), + ...(loginSessionWatch ? { loginSessionWatch } : {}), ...(logFilePath ? { logFilePath } : {}) } } @@ -73,7 +85,7 @@ async function main(): Promise { // an otherwise healthy detached daemon. Swallow it: stderr is diagnostic only. process.stderr.on('error', () => {}) - const { socketPath, tokenPath, pidPath, launchNonce, logFilePath } = parseArgs( + const { socketPath, tokenPath, pidPath, launchNonce, loginSessionWatch, logFilePath } = parseArgs( process.argv.slice(2) ) const startedAtMs = Date.now() - process.uptime() * 1000 @@ -123,6 +135,7 @@ async function main(): Promise { }) let daemon: DaemonHandle | null = null + let deathWatch: MacosLoginSessionDeathWatch | null = null let shuttingDown = false // Bound the wait so a wedged native shutdown can't leave the daemon running // forever on SIGTERM/SIGINT (it would then survive a real quit, not just updates). @@ -134,6 +147,7 @@ async function main(): Promise { return } shuttingDown = true + deathWatch?.stop() daemonLog.log('shutdown', { reason }) try { if (daemon) { @@ -157,6 +171,63 @@ async function main(): Promise { process.on('SIGTERM', () => void shutdown('SIGTERM')) process.on('SIGINT', () => void shutdown('SIGINT')) + // Why: a dead macOS login session cannot be fabricated without root (PAM owns + // audit-session teardown), so e2e drives the oracles from a verdict file: + // 'alive' → accepted/healthy, 'dead' → rejected/unhealthy, 'hang' → + // timeout-inconclusive/unhealthy (the fail-safe path), else inconclusive. + const e2eProbeFile = process.env.ORCA_E2E_LOGIN_SESSION_PROBE_FILE + const readE2eVerdict = (): string => { + try { + return readFileSync(e2eProbeFile as string, 'utf8').trim() + } catch { + return '' + } + } + deathWatch = + loginSessionWatch && process.platform === 'darwin' + ? new MacosLoginSessionDeathWatch({ + probeLoginSession: e2eProbeFile + ? async () => { + const verdict = readE2eVerdict() + if (verdict === 'alive') { + return { ok: true, conclusive: true, reason: 'accepted' } + } + if (verdict === 'dead') { + return { ok: false, conclusive: true, reason: 'rejected' } + } + return { ok: false, conclusive: false, reason: 'timeout' } + } + : probeMacosLoginSessionAlive, + readResolverHealth: e2eProbeFile + ? async () => { + const verdict = readE2eVerdict() + return verdict === 'dead' || verdict === 'hang' ? 'unhealthy' : 'healthy' + } + : readCurrentProcessMacSystemResolverHealth, + ...(e2eProbeFile + ? { + timing: { + periodicProbeMs: 2_000, + rejectionRecheckMs: 500, + ptyExitDebounceMs: 200, + clientActivityMinGapMs: 1_000, + minProbeGapMs: 100 + } + } + : {}), + log: daemonLog, + onRetire: (details) => { + shuttingDown = true + daemonLog.log('login-session-dead-retire', details) + daemonLog.close() + // Why: crash-style exit (no PTY teardown) keeps session meta unclean so the + // replacement daemon cold-restores scrollback; stale socket/pid files ride + // the existing dead-endpoint recovery. + process.exit(1) + } + }) + : null + daemon = await startDaemon({ socketPath, tokenPath, @@ -165,14 +236,22 @@ async function main(): Promise { ...(pidPath ? { startedAtMs } : {}), log: daemonLog, preparePtySpawn: runMacosLoginPreflight, + ...(deathWatch + ? { + onPtySessionExit: () => deathWatch.notifyPtyExit(), + onAuthenticatedClientPair: () => deathWatch.notifyClientActivity() + } + : {}), spawnSubprocess: (opts) => createPtySubprocess(opts), onIdleShutdown: () => { + deathWatch?.stop() shuttingDown = true daemonLog.log('shutdown', { reason: 'idle' }) daemonLog.close() process.exit(0) } }) + deathWatch?.start() // Signal readiness to parent via IPC (if available) if (process.send) { diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index c1f72dbc63f..5a3003f767d 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -1086,7 +1086,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { it('respawns instead of reusing a healthy daemon launched from another app path', async () => { const mod = await importFresh() - await mod.initDaemonPtyProvider() + await mod.initDaemonPtyProvider(undefined, { macosLoginSessionWatch: true }) const launcher = spawnerInstances[0].launcher as ( socketPath: string, @@ -1137,6 +1137,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { '/fake/socket', '--token', '/fake/token', + '--login-session-watch', '--log-file', join(FAKE_USER_DATA_PATH, 'logs', 'daemon.log') ]), @@ -1513,6 +1514,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { await launcher('/fake/socket', '/fake/token') + const launchedArgsWithoutWatch = forkMock.mock.calls.at(-1)?.[1] as string[] + expect(launchedArgsWithoutWatch).not.toContain('--login-session-watch') + expect(offMock).toHaveBeenCalledWith('message', expect.any(Function)) expect(offMock).toHaveBeenCalledWith('error', expect.any(Function)) expect(offMock).toHaveBeenCalledWith('exit', expect.any(Function)) diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 32e3baf9365..a748ea10a8b 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -321,7 +321,10 @@ async function shouldPreserveDaemonWithLiveSessions( return true } -function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { +function createOutOfProcessLauncher( + runtimeDir: string, + macosLoginSessionWatch = false +): DaemonLauncher { return async (socketPath, tokenPath, suppliedPidPath, suppliedLaunchNonce) => { const entryPath = getDaemonEntryPath() const pidPath = suppliedPidPath ?? getDaemonPidPath(runtimeDir) @@ -448,6 +451,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { pidPath, '--launch-nonce', launchNonce, + ...(macosLoginSessionWatch ? ['--login-session-watch'] : []), ...daemonLogArgs() ], { @@ -625,7 +629,10 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { } } -export async function initDaemonPtyProvider(signal?: AbortSignal): Promise { +export async function initDaemonPtyProvider( + signal?: AbortSignal, + options: { macosLoginSessionWatch?: boolean } = {} +): Promise { logDaemonMilestone('daemon-init-start') // Why: e2e coverage for the startup PTY gate (#5232) needs a daemon init that deterministically outlasts the first-window timeout. const e2eInitDelayMs = Number(process.env.ORCA_E2E_DAEMON_INIT_DELAY_MS) @@ -636,7 +643,7 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise const newSpawner = new DaemonSpawner({ runtimeDir, - launcher: createOutOfProcessLauncher(runtimeDir) + launcher: createOutOfProcessLauncher(runtimeDir, options.macosLoginSessionWatch ?? false) }) // Why: assign the module-level spawner/adapter only after both succeed, so a failed ensureRunning() leaves no stale spawner. diff --git a/src/main/daemon/daemon-main.ts b/src/main/daemon/daemon-main.ts index 9884cdf0c80..3976c6c21e6 100644 --- a/src/main/daemon/daemon-main.ts +++ b/src/main/daemon/daemon-main.ts @@ -11,6 +11,8 @@ export type DaemonStartOptions = { protocolVersion?: number spawnSubprocess: DaemonServerOptions['spawnSubprocess'] preparePtySpawn?: DaemonServerOptions['preparePtySpawn'] + onPtySessionExit?: DaemonServerOptions['onPtySessionExit'] + onAuthenticatedClientPair?: DaemonServerOptions['onAuthenticatedClientPair'] log?: DaemonFileLog onIdleShutdown?: () => void initialAdoptionTestConfig?: DaemonServerOptions['initialAdoptionTestConfig'] @@ -30,6 +32,10 @@ export async function startDaemon(opts: DaemonStartOptions): Promise Promise preparePtySpawn?: () => Promise + // Why: login-session death detection (#7936) probes on PTY-exit bursts and fresh app connections. + onPtySessionExit?: (sessionId: string) => void + onAuthenticatedClientPair?: () => void log?: DaemonFileLog spawnSubprocess: (opts: { sessionId: string @@ -101,6 +104,7 @@ export class DaemonServer { private startedAtMs: number | null private protocolVersion: number private onIdleShutdown: () => void + private onAuthenticatedClientPair: () => void private ptySpawnHealthCheck: () => Promise private preparePtySpawn: () => Promise private log: DaemonFileLog @@ -180,7 +184,11 @@ export class DaemonServer { now: () => Date.now() } this.token = randomUUID() - this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess }) + this.onAuthenticatedClientPair = opts.onAuthenticatedClientPair ?? (() => {}) + this.host = new TerminalHost({ + spawnSubprocess: opts.spawnSubprocess, + ...(opts.onPtySessionExit ? { onSessionReaped: opts.onPtySessionExit } : {}) + }) this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth this.preparePtySpawn = opts.preparePtySpawn ?? (() => Promise.resolve()) this.stopStreamBacklogProbe = startDaemonStreamBacklogProbe(() => ({ @@ -481,6 +489,8 @@ export class DaemonServer { } this.setupStreamSocket(socket, client) client.authenticatedPairEstablished = true + // Why: one-shot health probes authenticate only a control socket; they are not fresh app activity. + this.onAuthenticatedClientPair() // A complete app connection (unlike a probe) re-owns the endpoint and cancels pending retirement. this.initialAdoptionDeadlineMs = null this.retirementRequested = false diff --git a/src/main/daemon/macos-login-session-death-watch.test.ts b/src/main/daemon/macos-login-session-death-watch.test.ts new file mode 100644 index 00000000000..1531a33ab20 --- /dev/null +++ b/src/main/daemon/macos-login-session-death-watch.test.ts @@ -0,0 +1,374 @@ +import { describe, expect, it, vi } from 'vitest' +import type { LoginPreflightOutcome } from '../providers/macos-tcc-login-shell' +import { createNoopDaemonFileLog } from './daemon-file-log' +import { + MacosLoginSessionDeathWatch, + type MacosLoginSessionDeathWatchOptions +} from './macos-login-session-death-watch' +import type { SystemResolverHealth } from './types' + +const ACCEPTED: LoginPreflightOutcome = { ok: true, conclusive: true, reason: 'accepted' } +const REJECTED: LoginPreflightOutcome = { ok: false, conclusive: true, reason: 'rejected' } +const INCONCLUSIVE: LoginPreflightOutcome = { ok: false, conclusive: false, reason: 'timeout' } + +type FakeTimer = { at: number; callback: () => void; cleared: boolean } + +class FakeClock { + private timers: FakeTimer[] = [] + private nowMs = 0 + + setTimeout = (callback: () => void, delayMs: number): unknown => { + const timer: FakeTimer = { at: this.nowMs + delayMs, callback, cleared: false } + this.timers.push(timer) + return timer + } + + clearTimeout = (handle: unknown): void => { + ;(handle as FakeTimer).cleared = true + } + + now = (): number => this.nowMs + + async advance(ms: number): Promise { + const target = this.nowMs + ms + for (;;) { + const due = this.timers + .filter((t) => !t.cleared && t.at <= target) + .sort((a, b) => a.at - b.at)[0] + if (!due) { + break + } + this.nowMs = due.at + due.cleared = true + due.callback() + // Why: probes are async; let their promise chains settle before firing the next timer. + await drainMicrotasks() + } + this.nowMs = target + } + + pendingCount(): number { + return this.timers.filter((t) => !t.cleared).length + } +} + +async function drainMicrotasks(): Promise { + for (let i = 0; i < 10; i++) { + await Promise.resolve() + } +} + +function createWatch( + overrides: Partial & { + outcomes?: (LoginPreflightOutcome | null)[] + } = {} +): { + watch: MacosLoginSessionDeathWatch + clock: FakeClock + onRetire: ReturnType + probe: ReturnType + setResolverHealth: (health: SystemResolverHealth) => void +} { + const clock = new FakeClock() + const onRetire = vi.fn() + const outcomes = overrides.outcomes ?? [] + // Why length-check, not `??`: an explicit null outcome (wrapper not applicable) must reach the watch. + const probe = vi.fn(async () => (outcomes.length ? outcomes.shift()! : ACCEPTED)) + let resolverHealth: SystemResolverHealth = 'unhealthy' + const watch = new MacosLoginSessionDeathWatch({ + probeLoginSession: overrides.probeLoginSession ?? probe, + readResolverHealth: overrides.readResolverHealth ?? (async () => resolverHealth), + onRetire: overrides.onRetire ?? onRetire, + log: overrides.log ?? createNoopDaemonFileLog(), + clock, + timing: { + periodicProbeMs: 120_000, + rejectionRecheckMs: 10_000, + ptyExitDebounceMs: 2_000, + clientActivityMinGapMs: 30_000, + minProbeGapMs: 5_000, + ...overrides.timing + } + }) + return { + watch, + clock, + onRetire, + probe, + setResolverHealth: (health) => { + resolverHealth = health + } + } +} + +describe('MacosLoginSessionDeathWatch', () => { + it('retires after consecutive conclusive rejections once armed, with a degraded resolver', async () => { + const { watch, clock, onRetire } = createWatch({ + outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED] + }) + watch.start() + await drainMicrotasks() + expect(onRetire).not.toHaveBeenCalled() + + await clock.advance(120_000) // periodic → rejection 1 + await clock.advance(10_000) // recheck → rejection 2 + expect(onRetire).not.toHaveBeenCalled() + await clock.advance(10_000) // recheck → rejection 3 → retire + expect(onRetire).toHaveBeenCalledWith({ + cause: 'pam-rejections', + rejections: 3, + resolverHealth: 'unhealthy' + }) + }) + + it('never retires when the session was never conclusively accepted', async () => { + const { watch, clock, onRetire } = createWatch({ + outcomes: [REJECTED, REJECTED, REJECTED, REJECTED, REJECTED] + }) + watch.start() + await drainMicrotasks() + for (let i = 0; i < 4; i++) { + await clock.advance(120_000) + } + expect(onRetire).not.toHaveBeenCalled() + }) + + it('resets the rejection streak on a conclusive acceptance', async () => { + const { watch, clock, onRetire } = createWatch({ + outcomes: [ACCEPTED, REJECTED, REJECTED, ACCEPTED, REJECTED, REJECTED] + }) + watch.start() + await drainMicrotasks() + await clock.advance(120_000) // rejection 1 + await clock.advance(10_000) // rejection 2 + await clock.advance(10_000) // acceptance → reset + await clock.advance(120_000) // rejection 1 + await clock.advance(10_000) // rejection 2 + expect(onRetire).not.toHaveBeenCalled() + }) + + it('keeps the rejection streak across interleaved inconclusive probes', async () => { + const { watch, clock, onRetire } = createWatch({ + outcomes: [ACCEPTED, REJECTED, INCONCLUSIVE, REJECTED, INCONCLUSIVE, REJECTED] + }) + watch.start() + await drainMicrotasks() + await clock.advance(120_000) // rejection 1 + await clock.advance(10_000) // inconclusive timeout — rejection streak holds + await clock.advance(120_000) // rejection 2 + await clock.advance(10_000) // inconclusive + await clock.advance(120_000) // rejection 3 → retire + expect(onRetire).toHaveBeenCalledTimes(1) + expect(onRetire.mock.calls[0][0].rejections).toBe(3) + expect(onRetire.mock.calls[0][0].cause).toBe('pam-rejections') + }) + + it('keeps repeated inconclusive timeouts on the bounded periodic cadence', async () => { + const readResolverHealth = vi.fn(async () => 'unhealthy' as const) + const { watch, clock, onRetire, probe } = createWatch({ + outcomes: [ACCEPTED, ...Array.from({ length: 10 }, () => INCONCLUSIVE)], + readResolverHealth + }) + watch.start() + await drainMicrotasks() + for (let i = 0; i < 10; i++) { + await clock.advance(120_000) + } + expect(probe).toHaveBeenCalledTimes(11) + expect(readResolverHealth).not.toHaveBeenCalled() + expect(onRetire).not.toHaveBeenCalled() + }) + + it.each(['healthy', 'unknown'] as const)( + 'suppresses retirement while resolver health is %s, then retires on explicit degradation', + async (initialResolverHealth) => { + const { watch, clock, onRetire, probe, setResolverHealth } = createWatch({ + outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED, REJECTED] + }) + setResolverHealth(initialResolverHealth) + watch.start() + await drainMicrotasks() + await clock.advance(120_000) + await clock.advance(10_000) + await clock.advance(10_000) // threshold reached but resolver did not corroborate death + expect(onRetire).not.toHaveBeenCalled() + const probesAtSuppression = probe.mock.calls.length + setResolverHealth('unhealthy') + await clock.advance(10_000) + expect(probe).toHaveBeenCalledTimes(probesAtSuppression) + await clock.advance(110_000) // suppressed states return to the bounded periodic cadence + expect(onRetire).toHaveBeenCalledTimes(1) + } + ) + + it('debounces a sustained PTY-exit burst into one trailing probe', async () => { + const { watch, clock, probe } = createWatch({ outcomes: [ACCEPTED, ACCEPTED] }) + watch.start() + await drainMicrotasks() + await clock.advance(60_000) + const before = probe.mock.calls.length + watch.notifyPtyExit() + await clock.advance(1_500) + watch.notifyPtyExit() + await clock.advance(1_500) + watch.notifyPtyExit() + await clock.advance(1_999) + expect(probe).toHaveBeenCalledTimes(before) + await clock.advance(1) + expect(probe.mock.calls.length).toBe(before + 1) + }) + + it('probes on client activity only after the min gap', async () => { + const { watch, clock, probe } = createWatch({ + outcomes: [ACCEPTED, ACCEPTED, ACCEPTED] + }) + watch.start() + await drainMicrotasks() + const after = probe.mock.calls.length + watch.notifyClientActivity() // too soon after startup probe, so retain one deferred probe + await clock.advance(29_999) + expect(probe.mock.calls.length).toBe(after) + await clock.advance(1) + expect(probe.mock.calls.length).toBe(after + 1) + }) + + it('defers a PTY-exit trigger that lands inside the global probe gap', async () => { + const { watch, clock, probe } = createWatch({ outcomes: [ACCEPTED, ACCEPTED] }) + watch.start() + await drainMicrotasks() + + watch.notifyPtyExit() + await clock.advance(4_999) + expect(probe).toHaveBeenCalledOnce() + await clock.advance(1) + expect(probe).toHaveBeenCalledTimes(2) + }) + + it('retains one follow-up when a logout signal arrives during a probe', async () => { + let resolveStartup!: (outcome: LoginPreflightOutcome) => void + const startup = new Promise((resolve) => { + resolveStartup = resolve + }) + const probe = vi + .fn() + .mockReturnValueOnce(startup) + .mockResolvedValue(ACCEPTED) + const { watch, clock } = createWatch({ probeLoginSession: probe }) + watch.start() + watch.notifyPtyExit() + await clock.advance(2_000) + expect(probe).toHaveBeenCalledOnce() + + resolveStartup(ACCEPTED) + await drainMicrotasks() + await clock.advance(2_999) + expect(probe).toHaveBeenCalledOnce() + await clock.advance(1) + expect(probe).toHaveBeenCalledTimes(2) + }) + + it('retains client activity that arrives during an armed periodic probe', async () => { + let resolvePeriodic!: (outcome: LoginPreflightOutcome) => void + const periodic = new Promise((resolve) => { + resolvePeriodic = resolve + }) + const probe = vi + .fn() + .mockResolvedValueOnce(ACCEPTED) + .mockReturnValueOnce(periodic) + .mockResolvedValue(ACCEPTED) + const { watch, clock } = createWatch({ probeLoginSession: probe }) + watch.start() + await drainMicrotasks() + await clock.advance(120_000) + + watch.notifyClientActivity() + resolvePeriodic(ACCEPTED) + await drainMicrotasks() + await clock.advance(29_999) + expect(probe).toHaveBeenCalledTimes(2) + await clock.advance(1) + expect(probe).toHaveBeenCalledTimes(3) + }) + + it('disables itself when the wrapper machinery does not apply', async () => { + const { watch, clock, probe } = createWatch({ outcomes: [null] }) + watch.start() + await drainMicrotasks() + expect(probe).toHaveBeenCalledTimes(1) + await clock.advance(600_000) + watch.notifyPtyExit() + watch.notifyClientActivity() + await clock.advance(600_000) + expect(probe).toHaveBeenCalledTimes(1) + }) + + it('stop() cancels all pending timers', async () => { + const { watch, clock } = createWatch({ outcomes: [ACCEPTED] }) + watch.start() + await drainMicrotasks() + watch.notifyPtyExit() + watch.stop() + expect(clock.pendingCount()).toBe(0) + }) + + it('stop() aborts an in-flight subprocess probe', async () => { + let probeSignal: AbortSignal | undefined + const probe = vi.fn((signal?: AbortSignal) => { + probeSignal = signal + return new Promise(() => {}) + }) + const { watch } = createWatch({ probeLoginSession: probe }) + watch.start() + expect(probeSignal?.aborted).toBe(false) + + watch.stop() + + expect(probeSignal?.aborted).toBe(true) + }) + + it('stop() prevents an in-flight resolver check from retiring the daemon', async () => { + let resolveHealth!: (health: SystemResolverHealth) => void + let resolverSignal: AbortSignal | undefined + const resolverHealth = new Promise((resolve) => { + resolveHealth = resolve + }) + const { watch, clock, onRetire } = createWatch({ + outcomes: [ACCEPTED, REJECTED, REJECTED, REJECTED], + readResolverHealth: (signal) => { + resolverSignal = signal + return resolverHealth + } + }) + watch.start() + await drainMicrotasks() + await clock.advance(120_000) + await clock.advance(10_000) + await clock.advance(10_000) // retirement is now waiting on resolver health + + watch.stop() + expect(resolverSignal?.aborted).toBe(true) + resolveHealth('unhealthy') + await drainMicrotasks() + + expect(onRetire).not.toHaveBeenCalled() + }) + + it('logs and reschedules when a probe throws instead of surfacing a rejection', async () => { + const outcomes: (() => Promise)[] = [ + async () => ACCEPTED, + async () => { + throw new Error('launchctl exploded') + }, + async () => ACCEPTED + ] + const probe = vi.fn(() => (outcomes.shift() ?? (async () => ACCEPTED))()) + const { watch, clock, onRetire } = createWatch({ probeLoginSession: probe }) + watch.start() + await drainMicrotasks() + await clock.advance(120_000) // throwing probe + await clock.advance(120_000) // rescheduled probe still runs + expect(probe).toHaveBeenCalledTimes(3) + expect(onRetire).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/daemon/macos-login-session-death-watch.ts b/src/main/daemon/macos-login-session-death-watch.ts new file mode 100644 index 00000000000..d0cadccac18 --- /dev/null +++ b/src/main/daemon/macos-login-session-death-watch.ts @@ -0,0 +1,317 @@ +import type { LoginPreflightOutcome } from '../providers/macos-tcc-login-shell' +import type { DaemonFileLog } from './daemon-file-log' +import type { SystemResolverHealth } from './types' + +// Why: three conclusive PAM verdicts spread over recheck intervals keep a transient +// rejection storm (PAM db reload, OS update) from retiring a daemon whose login +// session is still alive; a real logout rejects conclusively on every probe. +const REQUIRED_CONSECUTIVE_REJECTIONS = 3 +const PERIODIC_PROBE_MS = 120_000 +const REJECTION_RECHECK_MS = 10_000 +const PTY_EXIT_DEBOUNCE_MS = 2_000 +// Why: a client hello right after login is the fastest death signal for a stale +// daemon, but steady reconnects must not turn hellos into a PAM probe storm. +const CLIENT_ACTIVITY_MIN_GAP_MS = 30_000 +const MIN_PROBE_GAP_MS = 5_000 + +type WatchClock = { + setTimeout(callback: () => void, delayMs: number): unknown + clearTimeout(handle: unknown): void + now(): number +} + +export type MacosLoginSessionDeathWatchOptions = { + /** Fresh PAM probe (cache-bypassing); null when the login wrapper doesn't apply on this host. */ + probeLoginSession: (signal?: AbortSignal) => Promise + readResolverHealth: (signal?: AbortSignal) => Promise + onRetire: (details: { + cause: 'pam-rejections' + rejections: number + resolverHealth: SystemResolverHealth + }) => void + log: DaemonFileLog + /** Direct-construction seam for deterministic tests; production uses real timers. */ + clock?: WatchClock + timing?: Partial<{ + periodicProbeMs: number + rejectionRecheckMs: number + ptyExitDebounceMs: number + clientActivityMinGapMs: number + minProbeGapMs: number + }> +} + +/** + * Detects that the macOS GUI login session this daemon was born into has died + * (full logout / WindowServer session teardown) and retires the daemon so the + * next app start cold-starts a replacement inside the live session (#7936). + * + * A daemon in a dead login session is unsalvageable: its PAM context can no + * longer host `login(1)` spawns ("Login incorrect" zombies) and its Mach + * bootstrap namespace has lost the system DNS resolver, so every terminal it + * hosts has no egress. Retirement is the only converging heal. + * + * The oracle is the existing TCC login-shell PAM probe: it conclusively accepts + * while the session is valid — including fast-user-switched-away sessions — and + * conclusively rejects once the session is destroyed. Retirement additionally + * requires the in-process system resolver to be explicitly degraded, so an + * inconclusive probe or PAM anomaly alone can never kill a healthy daemon. + */ +export class MacosLoginSessionDeathWatch { + private readonly probeLoginSession: MacosLoginSessionDeathWatchOptions['probeLoginSession'] + private readonly readResolverHealth: MacosLoginSessionDeathWatchOptions['readResolverHealth'] + private readonly onRetire: MacosLoginSessionDeathWatchOptions['onRetire'] + private readonly log: DaemonFileLog + private readonly clock: WatchClock + private readonly periodicProbeMs: number + private readonly rejectionRecheckMs: number + private readonly ptyExitDebounceMs: number + private readonly clientActivityMinGapMs: number + private readonly minProbeGapMs: number + + // Why: retire only a daemon that once proved its session could host login(1); + // a host where the wrapper never worked has no death signal to trust. + private armed = false + private consecutiveRejections = 0 + private lastProbeStartedAtMs: number | null = null + private probeInFlight = false + private stopped = false + private retired = false + private scheduledProbeTimer: unknown | null = null + private scheduledProbeAtMs: number | null = null + private ptyExitDebounceTimer: unknown | null = null + private pendingProbeTrigger: string | null = null + private probeAbortController: AbortController | null = null + + constructor(opts: MacosLoginSessionDeathWatchOptions) { + this.probeLoginSession = opts.probeLoginSession + this.readResolverHealth = opts.readResolverHealth + this.onRetire = opts.onRetire + this.log = opts.log + this.clock = opts.clock ?? { + setTimeout: (callback, delayMs) => { + const timer = setTimeout(callback, delayMs) + timer.unref() + return timer + }, + clearTimeout: (handle) => clearTimeout(handle as ReturnType), + now: () => Date.now() + } + this.periodicProbeMs = opts.timing?.periodicProbeMs ?? PERIODIC_PROBE_MS + this.rejectionRecheckMs = opts.timing?.rejectionRecheckMs ?? REJECTION_RECHECK_MS + this.ptyExitDebounceMs = opts.timing?.ptyExitDebounceMs ?? PTY_EXIT_DEBOUNCE_MS + this.clientActivityMinGapMs = opts.timing?.clientActivityMinGapMs ?? CLIENT_ACTIVITY_MIN_GAP_MS + this.minProbeGapMs = opts.timing?.minProbeGapMs ?? MIN_PROBE_GAP_MS + } + + start(): void { + if (this.stopped) { + return + } + void this.runProbe('startup') + } + + stop(): void { + this.stopped = true + this.probeAbortController?.abort() + this.probeAbortController = null + if (this.scheduledProbeTimer !== null) { + this.clock.clearTimeout(this.scheduledProbeTimer) + this.scheduledProbeTimer = null + this.scheduledProbeAtMs = null + } + if (this.ptyExitDebounceTimer !== null) { + this.clock.clearTimeout(this.ptyExitDebounceTimer) + this.ptyExitDebounceTimer = null + } + } + + /** A mass PTY-exit burst is what a logout's SIGHUP sweep looks like from inside the daemon. */ + notifyPtyExit(): void { + if (this.stopped) { + return + } + if (this.ptyExitDebounceTimer !== null) { + this.clock.clearTimeout(this.ptyExitDebounceTimer) + } + this.ptyExitDebounceTimer = this.clock.setTimeout(() => { + this.ptyExitDebounceTimer = null + void this.runProbe('pty-exit') + }, this.ptyExitDebounceMs) + } + + notifyClientActivity(): void { + if (this.stopped) { + return + } + if (this.probeInFlight) { + if (this.armed) { + this.retainPendingProbe('client-hello') + } + return + } + const elapsedSinceProbe = + this.lastProbeStartedAtMs === null ? null : this.clock.now() - this.lastProbeStartedAtMs + if (elapsedSinceProbe !== null && elapsedSinceProbe < this.clientActivityMinGapMs) { + // Why: dropping a post-login hello here can defer stale-daemon recovery to the two-minute backstop. + this.scheduleProbeNoLaterThan(this.clientActivityMinGapMs - elapsedSinceProbe, 'client-hello') + return + } + void this.runProbe('client-hello') + } + + private scheduleProbe(delayMs: number, trigger: string): void { + if (this.stopped) { + return + } + if (this.scheduledProbeTimer !== null) { + this.clock.clearTimeout(this.scheduledProbeTimer) + } + this.scheduledProbeAtMs = this.clock.now() + delayMs + this.scheduledProbeTimer = this.clock.setTimeout(() => { + this.scheduledProbeTimer = null + this.scheduledProbeAtMs = null + void this.runProbe(trigger) + }, delayMs) + } + + private scheduleNextProbe(delayMs: number): void { + this.scheduleProbe(delayMs, 'periodic') + } + + private scheduleProbeNoLaterThan(delayMs: number, trigger: string): void { + const requestedAtMs = this.clock.now() + delayMs + if (this.scheduledProbeAtMs !== null && this.scheduledProbeAtMs <= requestedAtMs) { + return + } + this.scheduleProbe(delayMs, trigger) + } + + private probeGapMs(trigger: string): number { + return trigger === 'client-hello' ? this.clientActivityMinGapMs : this.minProbeGapMs + } + + private retainPendingProbe(trigger: string): void { + if ( + this.pendingProbeTrigger === null || + this.probeGapMs(trigger) < this.probeGapMs(this.pendingProbeTrigger) + ) { + this.pendingProbeTrigger = trigger + } + } + + private async runProbe(trigger: string): Promise { + if (this.stopped || this.retired) { + return + } + if (this.probeInFlight) { + // Why: the current probe may describe the pre-logout state; retain one follow-up without polling. + this.retainPendingProbe(trigger) + return + } + const elapsedSinceProbe = + this.lastProbeStartedAtMs === null ? null : this.clock.now() - this.lastProbeStartedAtMs + if ( + trigger !== 'startup' && + elapsedSinceProbe !== null && + elapsedSinceProbe < this.minProbeGapMs + ) { + this.scheduleProbeNoLaterThan(this.minProbeGapMs - elapsedSinceProbe, trigger) + return + } + this.probeInFlight = true + this.lastProbeStartedAtMs = this.clock.now() + const abortController = new AbortController() + this.probeAbortController = abortController + try { + const outcome = await this.probeLoginSession(abortController.signal) + if (this.stopped || this.retired) { + return + } + if (outcome === null) { + // Why: no wrapper machinery means no PAM oracle — watching would only ever misfire. + this.log.log('login-session-watch-disabled', { trigger }) + this.stop() + return + } + if (!outcome.conclusive) { + // Why: repeated timeouts are still ambiguous on slow/offline PAM hosts; + // never turn an inconclusive probe into authority to orphan live PTYs. + this.scheduleNextProbe(this.periodicProbeMs) + return + } + if (outcome.ok) { + if (!this.armed) { + this.log.log('login-session-watch-armed', { trigger }) + } + this.armed = true + this.consecutiveRejections = 0 + this.scheduleNextProbe(this.periodicProbeMs) + return + } + if (!this.armed) { + // Session never hosted login(1) here; the preflight already degraded spawns. + this.scheduleNextProbe(this.periodicProbeMs) + return + } + this.consecutiveRejections++ + this.log.log('login-session-probe-rejected', { + trigger, + rejections: this.consecutiveRejections + }) + if (this.consecutiveRejections < REQUIRED_CONSECUTIVE_REJECTIONS) { + this.scheduleNextProbe(this.rejectionRecheckMs) + return + } + await this.retireIfResolverDegraded(() => { + this.consecutiveRejections = REQUIRED_CONSECUTIVE_REJECTIONS - 1 + }, abortController.signal) + } catch (error) { + // Why: a probe failure is diagnostic only; an escaped rejection would trip the + // daemon's fatal unhandled-error path and kill live terminals. + this.log.log('login-session-probe-error', { message: (error as Error)?.message }) + this.scheduleNextProbe(this.periodicProbeMs) + } finally { + this.probeInFlight = false + if (this.probeAbortController === abortController) { + this.probeAbortController = null + } + const pendingTrigger = this.pendingProbeTrigger + this.pendingProbeTrigger = null + if (!this.stopped && !this.retired && pendingTrigger !== null) { + const elapsed = this.clock.now() - (this.lastProbeStartedAtMs ?? this.clock.now()) + this.scheduleProbeNoLaterThan( + Math.max(0, this.probeGapMs(pendingTrigger) - elapsed), + pendingTrigger + ) + } + } + } + + private async retireIfResolverDegraded( + holdAtThreshold: () => void, + signal: AbortSignal + ): Promise { + const resolverHealth = await this.readResolverHealth(signal) + if (this.stopped || this.retired) { + return + } + if (resolverHealth !== 'unhealthy') { + // Why: only explicit resolver degradation corroborates session death; unknown + // probe failures must preserve terminals and avoid a permanent fast retry loop. + this.log.log('login-session-retire-suppressed', { + cause: 'pam-rejections', + resolverHealth + }) + holdAtThreshold() + this.scheduleNextProbe(this.periodicProbeMs) + return + } + this.retired = true + this.onRetire({ + cause: 'pam-rejections', + rejections: this.consecutiveRejections, + resolverHealth + }) + } +} diff --git a/src/main/daemon/terminal-host-options.ts b/src/main/daemon/terminal-host-options.ts index 55496f9e50f..b483914bba3 100644 --- a/src/main/daemon/terminal-host-options.ts +++ b/src/main/daemon/terminal-host-options.ts @@ -18,6 +18,8 @@ export type TerminalHostOptions = { terminalWindowsWslDistro?: string | null terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' }) => SubprocessHandle + // Why: login-session death detection (#7936) needs subprocess exits even when no client is attached. + onSessionReaped?: (sessionId: string) => void // Why: graceful shutdown checkpoints must finish in-process before teardown. onFinalCheckpoint?: ( sessionId: string, diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index cee72eee1cf..e7e83e31a31 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -883,6 +883,8 @@ describe('TerminalHost', () => { }) it('does not list exited sessions', async () => { + const onSessionReaped = vi.fn() + host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn, onSessionReaped }) await host.createOrAttach({ sessionId: 'session-1', cols: 80, @@ -892,6 +894,7 @@ describe('TerminalHost', () => { lastSubprocess._onExitCb?.(0) expect(host.listSessions()).toEqual([]) + expect(onSessionReaped).toHaveBeenCalledWith('session-1') }) it('never force-kills an exited session (recycled-pid SIGKILL safety)', async () => { diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 796f1939fc6..6be96d27d8e 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -27,6 +27,7 @@ export class TerminalHost { private sessionTeardown = new TerminalSessionTeardown(this.sessions) private killedTombstones: TerminalHostTombstones private spawnSubprocess: TerminalHostOptions['spawnSubprocess'] + private onSessionReaped: TerminalHostOptions['onSessionReaped'] private onFinalCheckpoint: TerminalHostOptions['onFinalCheckpoint'] private maxTombstones: number private creationFenced = false @@ -36,6 +37,7 @@ export class TerminalHost { constructor(opts: TerminalHostOptions) { this.spawnSubprocess = opts.spawnSubprocess + this.onSessionReaped = opts.onSessionReaped this.onFinalCheckpoint = opts.onFinalCheckpoint this.maxTombstones = opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES this.killedTombstones = new TerminalHostTombstones(this.maxTombstones) @@ -119,6 +121,7 @@ export class TerminalHost { } session.dispose() this.sessions.delete(sessionId) + this.onSessionReaped?.(sessionId) } signal(sessionId: string, sig: string): void { diff --git a/src/main/index.ts b/src/main/index.ts index 494760c48c4..9ab35ae3b57 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -674,7 +674,11 @@ function startTerminalRuntimeStartupServices(): Promise { // Why: both desktop and headless serve must adopt the same persistent provider before creating terminals or a renderer. startDaemonPtyProvider: async (signal) => { logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' }) - await initDaemonPtyProvider(signal) + // Why: only GUI-spawned macOS daemons watch for login-session death; a headless + // serve daemon must survive its spawning session ending (SSH disconnect). + await initDaemonPtyProvider(signal, { + macosLoginSessionWatch: process.platform === 'darwin' && !isServeMode + }) logStartupMilestone('startup-service-done', { service: 'daemon-pty-provider' }) }, // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from live server state, so the renderer awaits this before restored terminals reconnect. diff --git a/src/main/network/macos-system-resolver-health.test.ts b/src/main/network/macos-system-resolver-health.test.ts index 7cd29d6e22b..804847e6bbf 100644 --- a/src/main/network/macos-system-resolver-health.test.ts +++ b/src/main/network/macos-system-resolver-health.test.ts @@ -132,6 +132,23 @@ resolver #1 expect(child.listenerCount('close')).toBe(0) }) + it('kills scutil and removes listeners when its owner stops', async () => { + mockPlatform('darwin') + const child = createMockScutilProcess() + vi.mocked(spawn).mockReturnValue(child) + const abortController = new AbortController() + + const healthPromise = readCurrentProcessMacSystemResolverHealth(abortController.signal) + abortController.abort() + + await expect(healthPromise).resolves.toBe('unknown') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + expect(child.stdout.listenerCount('data')).toBe(0) + expect(child.stderr.listenerCount('data')).toBe(0) + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + }) + it('removes scutil listeners when the child closes normally', async () => { mockPlatform('darwin') const child = createMockScutilProcess() diff --git a/src/main/network/macos-system-resolver-health.ts b/src/main/network/macos-system-resolver-health.ts index 3545db1963c..9a3dd6f1eb6 100644 --- a/src/main/network/macos-system-resolver-health.ts +++ b/src/main/network/macos-system-resolver-health.ts @@ -16,8 +16,10 @@ export function classifyMacSystemResolverHealth(scutilOutput: string): SystemRes return 'unknown' } -export async function readCurrentProcessMacSystemResolverHealth(): Promise { - if (process.platform !== 'darwin') { +export async function readCurrentProcessMacSystemResolverHealth( + signal?: AbortSignal +): Promise { + if (process.platform !== 'darwin' || signal?.aborted) { return 'unknown' } @@ -35,6 +37,10 @@ export async function readCurrentProcessMacSystemResolverHealth(): Promise { stderr += chunk } + const onAbort = (): void => { + child.kill('SIGKILL') + finish() + } const finish = (): void => { if (settled) { return @@ -48,6 +54,7 @@ export async function readCurrentProcessMacSystemResolverHealth(): Promise { @@ -62,5 +69,6 @@ export async function readCurrentProcessMacSystemResolverHealth(): Promise ({ + execFileMock: vi.fn(), + existsSyncMock: vi.fn(), + stdinEndMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ execFile: execFileMock })) +vi.mock('node:fs', () => ({ existsSync: existsSyncMock })) + +import { runMacosLoginSessionPtyProbe } from './macos-login-session-pty-probe' + +type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void + +describe('runMacosLoginSessionPtyProbe', () => { + beforeEach(() => { + existsSyncMock.mockReturnValue(true) + execFileMock.mockReset() + stdinEndMock.mockReset() + }) + + it('runs login under expect-owned PTY and requires its marker plus a clean exit', async () => { + const abortController = new AbortController() + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(null, '^D\b\bORCA_LOGIN_PREFLIGHT_OK', '') + return { stdin: { end: stdinEndMock } } + } + ) + + await expect( + runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024, abortController.signal) + ).resolves.toEqual({ ok: true, conclusive: true, reason: 'accepted' }) + expect(execFileMock).toHaveBeenCalledWith( + '/usr/bin/expect', + [ + '-c', + expect.stringContaining( + 'spawn -noecho /usr/bin/login -flpq $env(ORCA_LOGIN_PROBE_USERNAME)' + ) + ], + expect.objectContaining({ + cwd: '/Users/ada', + env: expect.objectContaining({ ORCA_LOGIN_PROBE_USERNAME: 'ada' }), + killSignal: 'SIGKILL', + maxBuffer: 1_024, + signal: abortController.signal, + timeout: 4_000 + }), + expect.any(Function) + ) + expect(stdinEndMock).toHaveBeenCalledOnce() + expect(execFileMock.mock.calls[0]?.[1]?.[1]).toContain('send "\\004"; expect eof') + }) + + it('treats a natural exit without the marker as a conclusive rejection', async () => { + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(null, 'Login incorrect\r\nlogin: ', '') + return { stdin: { end: stdinEndMock } } + } + ) + + await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({ + ok: false, + conclusive: true, + reason: 'rejected' + }) + }) + + it('keeps a timeout or output overflow inconclusive', async () => { + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(Object.assign(new Error('killed'), { killed: true }), '', '') + return { stdin: { end: stdinEndMock } } + } + ) + await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({ + ok: false, + conclusive: false, + reason: 'timeout' + }) + + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback( + Object.assign(new Error('stdout maxBuffer length exceeded'), { + code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' + }), + '', + '' + ) + return { stdin: { end: stdinEndMock } } + } + ) + await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({ + ok: false, + conclusive: false, + reason: 'error' + }) + }) + + it('does not mistake an expect wrapper failure for a PAM rejection', async () => { + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(Object.assign(new Error('expect Tcl error'), { code: 1 }), '', '') + return { stdin: { end: stdinEndMock } } + } + ) + + await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({ + ok: false, + conclusive: false, + reason: 'error' + }) + }) + + it('fails safe without spawning when expect is unavailable', async () => { + existsSyncMock.mockReturnValue(false) + + await expect(runMacosLoginSessionPtyProbe('ada', '/Users/ada', 4_000, 1_024)).resolves.toEqual({ + ok: false, + conclusive: false, + reason: 'error' + }) + expect(execFileMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/providers/macos-login-session-pty-probe.ts b/src/main/providers/macos-login-session-pty-probe.ts new file mode 100644 index 00000000000..ffcc2098482 --- /dev/null +++ b/src/main/providers/macos-login-session-pty-probe.ts @@ -0,0 +1,78 @@ +import { execFile, type ExecFileException } from 'node:child_process' +import { existsSync } from 'node:fs' + +const MACOS_EXPECT_PATH = '/usr/bin/expect' +const LOGIN_PREFLIGHT_MARKER = 'ORCA_LOGIN_PREFLIGHT_OK' +const LOGIN_PROBE_USERNAME_ENV = 'ORCA_LOGIN_PROBE_USERNAME' +// Why: expect owns the PTY without adding a long-lived native handle to the daemon. +const EXPECT_LOGIN_PROBE_SCRIPT = + 'log_user 1; ' + + 'spawn -noecho /usr/bin/login -flpq $env(ORCA_LOGIN_PROBE_USERNAME) /usr/bin/printf ORCA_LOGIN_PREFLIGHT_OK; ' + + 'send "\\004"; expect eof; wait; exit 0' + +export type LoginPreflightOutcome = { + ok: boolean + conclusive: boolean + reason: 'accepted' | 'rejected' | 'timeout' | 'error' +} + +export function classifyLoginPreflightError(error: ExecFileException): LoginPreflightOutcome { + // Why: a probe killed by our bound proves nothing about PAM and must not stick. + if (error.killed || error.code === 'ETIMEDOUT') { + return { ok: false, conclusive: false, reason: 'timeout' } + } + // Why: a natural nonzero exit is login(1)'s conclusive rejection verdict. + if (typeof error.code === 'number') { + return { ok: false, conclusive: true, reason: 'rejected' } + } + return { ok: false, conclusive: false, reason: 'error' } +} + +/** Runs the login-session oracle under a real PTY when the pipe probe cannot decide. */ +export function runMacosLoginSessionPtyProbe( + username: string, + accountHome: string, + timeoutMs: number, + maxOutputBytes: number, + signal?: AbortSignal +): Promise { + if (!existsSync(MACOS_EXPECT_PATH)) { + return Promise.resolve({ ok: false, conclusive: false, reason: 'error' }) + } + return new Promise((resolve) => { + try { + const child = execFile( + MACOS_EXPECT_PATH, + ['-c', EXPECT_LOGIN_PROBE_SCRIPT], + { + cwd: accountHome, + encoding: 'utf8', + env: { ...process.env, [LOGIN_PROBE_USERNAME_ENV]: username }, + killSignal: 'SIGKILL', + maxBuffer: maxOutputBytes, + signal, + timeout: timeoutMs + }, + (error, stdout) => { + if (error !== null) { + // Why: a nonzero expect exit can be its own Tcl/PTY failure, not PAM authority. + resolve( + error.killed || error.code === 'ETIMEDOUT' + ? { ok: false, conclusive: false, reason: 'timeout' } + : { ok: false, conclusive: false, reason: 'error' } + ) + return + } + resolve( + stdout.includes(LOGIN_PREFLIGHT_MARKER) + ? { ok: true, conclusive: true, reason: 'accepted' } + : { ok: false, conclusive: true, reason: 'rejected' } + ) + } + ) + child.stdin?.end() + } catch { + resolve({ ok: false, conclusive: false, reason: 'error' }) + } + }) +} diff --git a/src/main/providers/macos-tcc-login-shell.test.ts b/src/main/providers/macos-tcc-login-shell.test.ts index 565d40da9dd..6f1a66a90d3 100644 --- a/src/main/providers/macos-tcc-login-shell.test.ts +++ b/src/main/providers/macos-tcc-login-shell.test.ts @@ -1,23 +1,34 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as LoginSessionPtyProbe from './macos-login-session-pty-probe' -const { existsSyncMock, userInfoMock, execFileMock, stdinEndMock } = vi.hoisted(() => ({ - existsSyncMock: vi.fn(), - userInfoMock: vi.fn(), - execFileMock: vi.fn(), - stdinEndMock: vi.fn() -})) +const { existsSyncMock, userInfoMock, execFileMock, stdinEndMock, ptyProbeMock } = vi.hoisted( + () => ({ + existsSyncMock: vi.fn(), + userInfoMock: vi.fn(), + execFileMock: vi.fn(), + stdinEndMock: vi.fn(), + ptyProbeMock: vi.fn() + }) +) vi.mock('node:fs', () => ({ existsSync: existsSyncMock })) vi.mock('node:os', () => ({ userInfo: userInfoMock })) vi.mock('node:child_process', () => ({ execFile: execFileMock })) +vi.mock('./macos-login-session-pty-probe', async (importOriginal) => ({ + ...(await importOriginal()), + runMacosLoginSessionPtyProbe: ptyProbeMock +})) import { prepareMacosTccLoginShell, + probeMacosLoginSessionAlive, resetMacosLoginShellPreflightForTests, wrapShellSpawnForMacosTccAttribution } from './macos-tcc-login-shell' type ExecFileCallback = (error: Error | null, stdout: string, stderr: string) => void +const ACCEPTED_OUTCOME = { ok: true, conclusive: true, reason: 'accepted' } as const +const REJECTED_OUTCOME = { ok: false, conclusive: true, reason: 'rejected' } as const describe('wrapShellSpawnForMacosTccAttribution', () => { let origPlatform: PropertyDescriptor | undefined @@ -39,6 +50,7 @@ describe('wrapShellSpawnForMacosTccAttribution', () => { return { stdin: { end: stdinEndMock } } } ) + ptyProbeMock.mockResolvedValue({ ok: true, conclusive: true, reason: 'accepted' }) resetMacosLoginShellPreflightForTests() }) @@ -340,3 +352,200 @@ describe('wrapShellSpawnForMacosTccAttribution', () => { expect(execFileMock).not.toHaveBeenCalled() }) }) + +describe('probeMacosLoginSessionAlive', () => { + let origPlatform: PropertyDescriptor | undefined + let origDisable: string | undefined + + function setPlatform(value: string): void { + Object.defineProperty(process, 'platform', { configurable: true, value }) + } + + beforeEach(() => { + origPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + origDisable = process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL + delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL + existsSyncMock.mockReturnValue(true) + userInfoMock.mockReturnValue({ username: 'ada', homedir: '/Users/ada' }) + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(null, 'ORCA_LOGIN_PREFLIGHT_OK', '') + return { stdin: { end: stdinEndMock } } + } + ) + ptyProbeMock.mockResolvedValue({ ok: true, conclusive: true, reason: 'accepted' }) + resetMacosLoginShellPreflightForTests() + }) + + afterEach(() => { + if (origPlatform) { + Object.defineProperty(process, 'platform', origPlatform) + } + if (origDisable === undefined) { + delete process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL + } else { + process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = origDisable + } + vi.restoreAllMocks() + vi.clearAllMocks() + }) + + it('re-probes even after a cached acceptance', async () => { + setPlatform('darwin') + await prepareMacosTccLoginShell() + expect(execFileMock).toHaveBeenCalledTimes(1) + const outcome = await probeMacosLoginSessionAlive() + expect(outcome).toEqual({ ok: true, conclusive: true, reason: 'accepted' }) + expect(execFileMock).toHaveBeenCalledTimes(2) + }) + + it('reuses an in-flight startup warmup instead of spawning a duplicate probe', async () => { + setPlatform('darwin') + let finishPreflight!: ExecFileCallback + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + finishPreflight = callback + return { stdin: { end: stdinEndMock } } + } + ) + + const warmup = prepareMacosTccLoginShell() + const freshProbe = probeMacosLoginSessionAlive() + expect(execFileMock).toHaveBeenCalledOnce() + + finishPreflight(null, 'ORCA_LOGIN_PREFLIGHT_OK', '') + await expect(Promise.all([warmup, freshProbe])).resolves.toEqual([ + ACCEPTED_OUTCOME, + ACCEPTED_OUTCOME + ]) + expect(execFileMock).toHaveBeenCalledOnce() + }) + + it('does not let a spawn-path probe overwrite a newer death verdict', async () => { + setPlatform('darwin') + const callbacks: ExecFileCallback[] = [] + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callbacks.push(callback) + return { stdin: { end: stdinEndMock } } + } + ) + + const freshProbe = probeMacosLoginSessionAlive() + const spawnProbe = prepareMacosTccLoginShell() + expect(execFileMock).toHaveBeenCalledTimes(2) + + callbacks[0](Object.assign(new Error('login incorrect'), { code: 1 }), '', '') + await expect(freshProbe).resolves.toEqual(REJECTED_OUTCOME) + callbacks[1](null, 'ORCA_LOGIN_PREFLIGHT_OK', '') + await expect(spawnProbe).resolves.toEqual(ACCEPTED_OUTCOME) + + expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh') + }) + + it('flips the spawn wrapper off when a fresh probe conclusively rejects (dead login session)', async () => { + setPlatform('darwin') + await prepareMacosTccLoginShell() + expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') + + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(Object.assign(new Error('login incorrect'), { code: 1 }), '', '') + return { stdin: { end: stdinEndMock } } + } + ) + const outcome = await probeMacosLoginSessionAlive() + expect(outcome).toEqual({ ok: false, conclusive: true, reason: 'rejected' }) + // The dead-session daemon must stop minting login(1) prompt zombies (#7936). + expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh') + }) + + it('does not overwrite the cached verdict on an inconclusive probe', async () => { + setPlatform('darwin') + await prepareMacosTccLoginShell() + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(Object.assign(new Error('killed'), { killed: true }), '', '') + return { stdin: { end: stdinEndMock } } + } + ) + ptyProbeMock.mockResolvedValue({ ok: false, conclusive: false, reason: 'timeout' }) + const outcome = await probeMacosLoginSessionAlive() + expect(outcome).toEqual({ ok: false, conclusive: false, reason: 'timeout' }) + expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') + }) + + it('returns null off macOS and when disabled', async () => { + setPlatform('linux') + expect(await probeMacosLoginSessionAlive()).toBeNull() + setPlatform('darwin') + process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL = '1' + expect(await probeMacosLoginSessionAlive()).toBeNull() + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('escalates an inconclusive pipe probe to a PTY probe and accepts its verdict', async () => { + setPlatform('darwin') + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(Object.assign(new Error('killed'), { killed: true }), '', '') + return { stdin: { end: stdinEndMock } } + } + ) + const outcome = await probeMacosLoginSessionAlive() + expect(outcome).toEqual({ ok: true, conclusive: true, reason: 'accepted' }) + expect(execFileMock).toHaveBeenCalledOnce() + expect(ptyProbeMock).toHaveBeenCalledWith('ada', '/Users/ada', 4_000, 1_024, undefined) + expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') + }) + + it('treats a PTY-probe rejection as conclusive and flips the wrapper off', async () => { + setPlatform('darwin') + await prepareMacosTccLoginShell() + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(Object.assign(new Error('killed'), { killed: true }), '', '') + return { stdin: { end: stdinEndMock } } + } + ) + ptyProbeMock.mockResolvedValue({ ok: false, conclusive: true, reason: 'rejected' }) + const outcome = await probeMacosLoginSessionAlive() + expect(outcome).toEqual({ ok: false, conclusive: true, reason: 'rejected' }) + expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/bin/zsh') + }) + + it('stays inconclusive when both pipe and PTY probes time out', async () => { + setPlatform('darwin') + await prepareMacosTccLoginShell() + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(Object.assign(new Error('killed'), { killed: true }), '', '') + return { stdin: { end: stdinEndMock } } + } + ) + ptyProbeMock.mockResolvedValue({ ok: false, conclusive: false, reason: 'timeout' }) + const outcome = await probeMacosLoginSessionAlive() + expect(outcome).toEqual({ ok: false, conclusive: false, reason: 'timeout' }) + // Inconclusive must not disturb the cached acceptance. + expect(wrapShellSpawnForMacosTccAttribution('/bin/zsh', ['-l']).file).toBe('/usr/bin/login') + }) + + it('does not start a PTY fallback after the watch cancels its pipe probe', async () => { + setPlatform('darwin') + const abortController = new AbortController() + abortController.abort() + execFileMock.mockImplementation( + (_file: string, _args: string[], _options: unknown, callback: ExecFileCallback) => { + callback(Object.assign(new Error('aborted'), { code: 'ABORT_ERR' }), '', '') + return { stdin: { end: stdinEndMock } } + } + ) + + await expect(probeMacosLoginSessionAlive(abortController.signal)).resolves.toEqual({ + ok: false, + conclusive: false, + reason: 'error' + }) + expect(ptyProbeMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/providers/macos-tcc-login-shell.ts b/src/main/providers/macos-tcc-login-shell.ts index bd92cacaac5..9ee3a709314 100644 --- a/src/main/providers/macos-tcc-login-shell.ts +++ b/src/main/providers/macos-tcc-login-shell.ts @@ -1,11 +1,21 @@ -import { execFile, type ExecFileException } from 'node:child_process' +import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' import { userInfo } from 'node:os' +import { + classifyLoginPreflightError, + runMacosLoginSessionPtyProbe, + type LoginPreflightOutcome +} from './macos-login-session-pty-probe' + +export type { LoginPreflightOutcome } from './macos-login-session-pty-probe' const MACOS_LOGIN_PATH = '/usr/bin/login' const MACOS_ENV_PATH = '/usr/bin/env' const MACOS_PRINTF_PATH = '/usr/bin/printf' const LOGIN_PREFLIGHT_TIMEOUT_MS = 500 +// Why: the death-watch probe runs off the spawn path, so it can afford a bound +// that outlasts a PAM stack answering slowly rather than misreading it as a hang. +const LOGIN_SESSION_WATCH_PROBE_TIMEOUT_MS = 4_000 const LOGIN_PREFLIGHT_MARKER = 'ORCA_LOGIN_PREFLIGHT_OK' const LOGIN_PREFLIGHT_MAX_BUFFER_BYTES = 1024 const LOGIN_PREFLIGHT_RETRY_BASE_MS = 5_000 @@ -23,15 +33,11 @@ const DISABLE_ENV_VAR = 'ORCA_DISABLE_MACOS_LOGIN_SHELL' * reject) that may be cached; an inconclusive probe (our own timeout/SIGKILL, * maxBuffer, or spawn error) proves nothing about PAM and must not stick. */ -export type LoginPreflightOutcome = { - ok: boolean - conclusive: boolean - reason: 'accepted' | 'rejected' | 'timeout' | 'error' -} - let cachedLoginPreflightResult: boolean | null = null let loginPreflightInFlight: Promise | null = null let transientLoginPreflightFailure: { failureCount: number; retryAtMs: number } | null = null +let loginPreflightCacheEpoch = 0 +let loginSessionProbeInFlight = false function isDisabledByEnv(): boolean { const value = process.env[DISABLE_ENV_VAR] @@ -45,25 +51,15 @@ function loginPreflightRetryDelayMs(failureCount: number): number { ) } -function classifyPreflightError(error: ExecFileException): LoginPreflightOutcome { - // Why: our SIGKILL timeout cap (and maxBuffer, which also kills) is an - // environmental slow-path, not a PAM verdict — retry, don't cache (F1). - if (error.killed || error.code === 'ETIMEDOUT') { - return { ok: false, conclusive: false, reason: 'timeout' } - } - // A numeric exit code means login(1) ran to completion and rejected the user - // (it exits immediately on EOF-driven rejection); that verdict is cacheable. - if (typeof error.code === 'number') { - return { ok: false, conclusive: true, reason: 'rejected' } - } - // Spawn/EOF/other failure: inconclusive, fail open for this spawn but retry. - return { ok: false, conclusive: false, reason: 'error' } -} - // Fidelity limit: the probe runs over pipes while production shells run under a // real PTY, so a tty-sensitive PAM stack could diverge. It fails safe — a probe // pass with a prod failure only degrades to today's direct spawn (no wrapper). -function runLoginPreflight(username: string, accountHome: string): Promise { +function runLoginPreflight( + username: string, + accountHome: string, + timeoutMs = LOGIN_PREFLIGHT_TIMEOUT_MS, + signal?: AbortSignal +): Promise { return new Promise((resolve) => { try { const child = execFile( @@ -78,7 +74,8 @@ function runLoginPreflight(username: string, accountHome: string): Promise { if (error === null) { @@ -91,7 +88,7 @@ function runLoginPreflight(username: string, accountHome: string): Promise { // Why: cache only a conclusive PAM verdict; a killed/timed-out probe is // environmental and must be retried next spawn, not stuck forever (F1). - if (outcome.conclusive) { + const mayUpdateCache = !loginSessionProbeInFlight && cacheEpoch === loginPreflightCacheEpoch + if (outcome.conclusive && mayUpdateCache) { cachedLoginPreflightResult = outcome.ok transientLoginPreflightFailure = null - } else { + } else if (!outcome.conclusive && mayUpdateCache) { const failureCount = (transientLoginPreflightFailure?.failureCount ?? 0) + 1 transientLoginPreflightFailure = { failureCount, @@ -192,6 +191,64 @@ export function resetMacosLoginShellPreflightForTests(): void { cachedLoginPreflightResult = null loginPreflightInFlight = null transientLoginPreflightFailure = null + loginPreflightCacheEpoch = 0 + loginSessionProbeInFlight = false +} + +/** + * Fresh PAM probe for login-session death detection (#7936): bypasses the + * cached verdict and the transient backoff, and writes any conclusive verdict + * back into the cache — so a daemon whose login session died stops wrapping + * spawns in `login(1)` (which would only mint "Login incorrect" zombies) even + * before retirement completes. Escalates to a PTY-hosted probe when the pipe + * probe is inconclusive, since a dead session's PAM stack may only misbehave + * under a real tty. Returns null when the wrapper doesn't apply. + */ +export async function probeMacosLoginSessionAlive( + signal?: AbortSignal +): Promise { + if (process.platform !== 'darwin' || isDisabledByEnv() || !existsSync(MACOS_LOGIN_PATH)) { + return null + } + let username: string + let accountHome: string + try { + const account = userInfo() + username = account.username + accountHome = account.homedir + } catch { + return null + } + if (!username || !accountHome) { + return null + } + // Why: reuse the startup warmup when present, and fence older spawn-path results from restoring a stale verdict. + const existingPreflight = loginPreflightInFlight + loginSessionProbeInFlight = true + loginPreflightCacheEpoch++ + let outcome: LoginPreflightOutcome + try { + outcome = await (existingPreflight ?? + runLoginPreflight(username, accountHome, LOGIN_SESSION_WATCH_PROBE_TIMEOUT_MS, signal)) + if (!outcome.conclusive && !signal?.aborted) { + outcome = await runMacosLoginSessionPtyProbe( + username, + accountHome, + LOGIN_SESSION_WATCH_PROBE_TIMEOUT_MS, + LOGIN_PREFLIGHT_MAX_BUFFER_BYTES, + signal + ) + } + } finally { + // Why: invalidate spawn probes started during this fresh check before they can overwrite its newer verdict. + loginPreflightCacheEpoch++ + loginSessionProbeInFlight = false + } + if (outcome.conclusive) { + cachedLoginPreflightResult = outcome.ok + transientLoginPreflightFailure = null + } + return outcome } /** From 8b6e530ed2c81a7d97e98c6643b0abe0a6e68620 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:54:36 +0000 Subject: [PATCH 06/10] release: v1.4.151-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 02b58ce82ef..918c0f00e6e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.151-rc.0", + "version": "1.4.151-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 9bdb8204e130eb9e16a8458b8cd727598fad209c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:10:29 -0700 Subject: [PATCH 07/10] test(e2e): verify Claude is prefilled with issue URL on start (#9980) * test(e2e): verify Claude is prefilled with issue URL on start Regression test for #6613: when starting a workspace from a newly created GitHub issue, ensure the issue URL is passed to Claude via `--prefill` and `--dangerously-skip-permissions` flags. This prevents context loss after issue creation. * test(e2e): fix GitHub-issue-start prefill test flakiness - Reorder mock API handlers to ensure `/labels` and `/assignees` paths match before the specific issue endpoint - Replace regex heading matcher with exact string for more reliable assertions - Refactor terminal content polling to capture text once and reuse in subsequent assertions --- ...github-created-issue-start-prefill.spec.ts | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 tests/e2e/github-created-issue-start-prefill.spec.ts diff --git a/tests/e2e/github-created-issue-start-prefill.spec.ts b/tests/e2e/github-created-issue-start-prefill.spec.ts new file mode 100644 index 00000000000..c3e6d40820a --- /dev/null +++ b/tests/e2e/github-created-issue-start-prefill.spec.ts @@ -0,0 +1,187 @@ +import { execFileSync } from 'node:child_process' +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { test as base, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { getTerminalContent } from './helpers/terminal' + +const ISSUE_NUMBER = 6613 +const ISSUE_TITLE = 'Start a newly created issue without losing its context' +const ISSUE_URL = `https://github.com/acme/repo/issues/${ISSUE_NUMBER}` +const fakeCliDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-created-issue-prefill-')) + +const fakeGhSource = ` +const args = process.argv.slice(2) +const joined = args.join(' ') +const issue = { + number: ${ISSUE_NUMBER}, + title: ${JSON.stringify(ISSUE_TITLE)}, + state: 'open', + html_url: ${JSON.stringify(ISSUE_URL)}, + labels: [], + assignees: [], + user: { login: 'e2e' }, + updated_at: '2026-07-22T12:00:00.000Z' +} + +if (args[0] === 'auth' && args[1] === 'status') { + console.error('github.com\\n ✓ Logged in to github.com account e2e (GITHUB_TOKEN)') + process.exit(0) +} +if (args[0] === 'api' && args[1] === 'user') { + console.log(JSON.stringify({ login: 'e2e' })) + process.exit(0) +} +if (args[0] === 'api' && args.includes('rate_limit')) { + console.log(JSON.stringify({ resources: { core: { limit: 5000, remaining: 5000, reset: 0 }, graphql: { limit: 5000, remaining: 5000, reset: 0 }, search: { limit: 30, remaining: 30, reset: 0 } } })) + process.exit(0) +} +if (args[0] === 'api' && args.includes('-X') && args.includes('POST') && joined.includes('repos/acme/repo/issues')) { + console.log(JSON.stringify(issue)) + process.exit(0) +} +if (args[0] === 'api' && joined.includes('/labels')) { + process.exit(0) +} +if (args[0] === 'api' && joined.includes('/assignees')) { + process.exit(0) +} +if (args[0] === 'api' && joined.includes('repos/acme/repo/issues/${ISSUE_NUMBER}')) { + console.log(JSON.stringify(issue)) + process.exit(0) +} +if (args[0] === 'api' && joined.includes('search/issues')) { + console.log(JSON.stringify({ total_count: 0, incomplete_results: false, items: [] })) + process.exit(0) +} +if (args[0] === 'issue' && args[1] === 'list') { + console.log('[]') + process.exit(0) +} +if (args[0] === 'pr' && args[1] === 'list') { + console.log('[]') + process.exit(0) +} +if (args[0] === 'api' && args[1] === 'graphql') { + console.log(JSON.stringify({ data: { search: { issueCount: 0, pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] } } })) + process.exit(0) +} +console.error('fake gh: unhandled ' + joined) +process.exit(1) +` + +const fakeClaudeSource = ` +const args = process.argv.slice(2) +process.stdout.write('E2E_CLAUDE_ARGV ' + JSON.stringify(args) + '\\n') +setInterval(() => {}, 60_000) +` + +function installFakeCli(name: 'gh' | 'claude', source: string): void { + if (process.platform === 'win32') { + writeFileSync(path.join(fakeCliDir, `fake-${name}.js`), source) + writeFileSync( + path.join(fakeCliDir, `${name}.cmd`), + `@echo off\r\nnode "%~dp0\\fake-${name}.js" %*\r\n` + ) + return + } + const executable = path.join(fakeCliDir, name) + writeFileSync(executable, `#!/usr/bin/env node\n${source}`) + chmodSync(executable, 0o755) +} + +installFakeCli('gh', fakeGhSource) +installFakeCli('claude', fakeClaudeSource) + +const test = base.extend({ + launchEnv: [ + { + PATH: `${fakeCliDir}${path.delimiter}${process.env.PATH ?? ''}` + }, + { option: true } + ] +}) + +test.afterAll(() => { + rmSync(fakeCliDir, { recursive: true, force: true }) +}) + +function configureGitHubRemote(repoPath: string): void { + try { + execFileSync('git', ['remote', 'remove', 'origin'], { cwd: repoPath, stdio: 'ignore' }) + } catch { + // The disposable E2E repo does not have an origin on its first run. + } + execFileSync('git', ['remote', 'add', 'origin', 'https://github.com/acme/repo.git'], { + cwd: repoPath, + stdio: 'pipe' + }) +} + +test('starting a just-created GitHub issue launches Claude with its URL prefilled', async ({ + orcaPage, + testRepoPath +}) => { + configureGitHubRemote(testRepoPath) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + + await orcaPage.evaluate(async () => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const preparedWorkspace = state + .allWorktrees() + .find((worktree) => worktree.branch?.endsWith('e2e-secondary')) + if (!preparedWorkspace) { + throw new Error('Seeded secondary E2E worktree is not available') + } + // Why: this regression owns renderer-to-PTY command propagation, while the shared fixture already covers Git worktree creation. + store.setState({ + createWorktree: async () => ({ worktree: preparedWorkspace }) + }) + await state.updateSettings({ + defaultTuiAgent: 'claude', + disabledTuiAgents: [], + ...(navigator.userAgent.includes('Windows') ? { terminalWindowsShell: 'git-bash' } : {}) + }) + store.getState().openTaskPage({ taskSource: 'github' }) + }) + + const newIssueButton = orcaPage.getByRole('button', { name: 'New GitHub issue' }) + await expect(newIssueButton).toBeEnabled({ timeout: 15_000 }) + await newIssueButton.click() + + const createDialog = orcaPage.getByRole('dialog', { name: 'New GitHub issue' }) + await expect(createDialog).toBeVisible() + await createDialog.getByPlaceholder('Short summary').fill(ISSUE_TITLE) + await createDialog.getByRole('button', { name: 'Create issue' }).click() + + await expect(createDialog).toBeHidden({ timeout: 10_000 }) + await expect(orcaPage.getByRole('heading', { name: ISSUE_TITLE })).toBeVisible({ + timeout: 10_000 + }) + + await orcaPage.getByRole('button', { name: 'Start workspace from issue' }).click() + + let terminalText = '' + await expect + .poll( + async () => { + terminalText = await getTerminalContent(orcaPage, 12_000) + return terminalText + }, + { + timeout: 30_000, + message: 'Claude prefill command did not reach the active terminal buffer' + } + ) + .toContain('--prefill') + expect(terminalText).toContain('--dangerously-skip-permissions') + expect(terminalText).toContain('--prefill') + expect(terminalText).toContain(ISSUE_URL) + expect(terminalText).not.toMatch(/(?:^|\n)claude '--dangerously-skip-permissions'(?:\r?\n|$)/) +}) From 0d82cc3dbb6ed5bb288b48a8e6864b944da73add Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 22 Jul 2026 18:52:42 -0400 Subject: [PATCH 08/10] Fix legacy worktree lineage projection after stable updates (#9913) --- src/main/ipc/worktrees.test.ts | 121 ++++++++ src/main/ipc/worktrees.ts | 12 +- src/main/runtime/orca-runtime.test.ts | 223 ++++++++++++++- src/main/runtime/orca-runtime.ts | 60 ++-- ...older-workspace-attached-worktrees.test.ts | 107 ++++++++ .../folder-workspace-attached-worktrees.ts | 75 ++--- .../sidebar/WorktreeContextMenu.test.ts | 22 ++ .../sidebar/WorktreeContextMenu.tsx | 64 +++-- .../src/components/sidebar/WorktreeList.tsx | 88 +++--- .../folder-workspace-card-pr-display.test.ts | 103 +++++++ .../folder-workspace-card-pr-display.ts | 37 +-- ...e-visible-workspace-kanban-worktree-ids.ts | 3 +- .../sidebar/visible-worktrees.test.ts | 144 +++++++++- .../components/sidebar/visible-worktrees.ts | 38 ++- .../sidebar/workspace-delete-lineage.test.ts | 65 +++++ .../sidebar/workspace-delete-lineage.ts | 30 +- .../worktree-lineage-drag-drop.test.ts | 107 +++++++- .../sidebar/worktree-lineage-drag-drop.ts | 16 +- .../sidebar/worktree-lineage-projection.ts | 100 +++++++ .../sidebar/worktree-list-groups.test.ts | 259 +++++++++++++++++- .../sidebar/worktree-list-groups.ts | 54 ++-- .../sidebar/worktree-parent-candidates.ts | 59 +++- .../worktree-parent-eligibility.test.ts | 30 +- .../sidebar/worktree-parent-eligibility.ts | 17 +- .../src/store/slices/worktrees.test.ts | 94 +++++++ src/renderer/src/store/slices/worktrees.ts | 68 ++++- src/shared/resolved-worktree-lineage.test.ts | 171 ++++++++++++ src/shared/resolved-worktree-lineage.ts | 108 ++++++++ tests/e2e/worktree-lineage-state.ts | 49 ++-- tests/e2e/worktree-lineage.spec.ts | 37 +++ 30 files changed, 2027 insertions(+), 334 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-lineage-projection.ts create mode 100644 src/shared/resolved-worktree-lineage.test.ts create mode 100644 src/shared/resolved-worktree-lineage.ts diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index bcbc940f72e..6e126be2091 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -2306,6 +2306,127 @@ describe('registerWorktreeHandlers', () => { }) }) + it('hydrates detected worktrees with instance-validated legacy lineage after an update', async () => { + const parentPath = '/workspace/assigned-issues' + const childPath = '/workspace/issue-9276-nested-ssh-runtime-routing' + const parentId = `repo-1::${parentPath}` + const childId = `repo-1::${childPath}` + const metaById: Record = { + [parentId]: { instanceId: 'parent-instance' }, + [childId]: { instanceId: 'child-instance' } + } + store.getWorktreeMeta.mockImplementation((id: string) => metaById[id]) + store.setWorktreeMeta.mockImplementation((id: string, updates: object) => ({ + ...metaById[id], + ...updates + })) + store.getAllWorktreeLineage.mockReturnValue({ + [childId]: { + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 1 + } + }) + listWorktreesMock.mockResolvedValue([ + { + path: childPath, + head: 'child-head', + branch: 'refs/heads/child', + isBare: false, + isMainWorktree: false + }, + { + path: parentPath, + head: 'parent-head', + branch: 'refs/heads/parent', + isBare: false, + isMainWorktree: false + } + ]) + + const result = (await handlers['worktrees:listDetected'](null, { + repoId: 'repo-1' + })) as { worktrees: (Worktree & { lineage?: unknown; parentWorktreeId?: string | null })[] } + + expect(result.worktrees).toEqual([ + expect.objectContaining({ + id: childId, + parentWorktreeId: parentId, + lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' }) + }), + expect.objectContaining({ + id: parentId, + parentWorktreeId: null, + childWorktreeIds: [childId], + lineage: null + }) + ]) + }) + + it('hydrates folder-repo detected rows with instance-validated legacy lineage', async () => { + const folderRepo = { + id: 'repo-1', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: '#000', + addedAt: 0, + kind: 'folder' as const + } + const parentId = `${folderRepo.id}::${folderRepo.path}` + const childId = `${parentId}::workspace:child-instance` + const metaById: Record> = { + [parentId]: makeWorktreeMeta({ + instanceId: 'parent-instance', + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }), + [childId]: makeWorktreeMeta({ + instanceId: 'child-instance', + projectId: 'repo:repo-1', + hostId: 'local', + projectHostSetupId: 'repo-1' + }) + } + store.getRepos.mockReturnValue([folderRepo]) + store.getRepo.mockReturnValue(folderRepo) + store.getAllWorktreeMeta.mockReturnValue(metaById) + store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId]) + store.getAllWorktreeLineage.mockReturnValue({ + [childId]: { + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 1 + } + }) + + const result = (await handlers['worktrees:listDetected'](null, { + repoId: folderRepo.id + })) as { worktrees: (Worktree & { lineage?: unknown; parentWorktreeId?: string | null })[] } + + expect(result.worktrees).toEqual([ + expect.objectContaining({ + id: parentId, + parentWorktreeId: null, + childWorktreeIds: [childId], + lineage: null + }), + expect.objectContaining({ + id: childId, + parentWorktreeId: parentId, + lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' }) + }) + ]) + }) + it('hides agent scratch created inside a linked checkout from desktop listings', async () => { const linkedCheckoutPath = '/workspace/feature-x' const scratchPath = `${linkedCheckoutPath}/.claude/worktrees/agent-a04ccaaa` diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index ee878ae0926..40cf7efd17d 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -13,6 +13,7 @@ import { } from '../../shared/workspace-scope' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' +import { projectResolvedWorktreeLineage } from '../../shared/resolved-worktree-lineage' import { deleteWorktreeHistoryDir } from '../terminal-history' import type { AutomationWorkspaceProvenance, @@ -738,7 +739,7 @@ function buildDetectedGitWorktrees( repo.path, ...liveWorktrees.map((worktree) => worktree.path) ]) - return liveWorktrees.map((gitWorktree) => { + const detected = liveWorktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` let meta = store.getWorktreeMeta(worktreeId) const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) @@ -766,6 +767,7 @@ function buildDetectedGitWorktrees( agentScratchWorktreePathMatcher }) }) + return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) } function stampAndMergeVisibleDetectedWorktree( @@ -959,7 +961,7 @@ function buildDisconnectedDetectedWorktrees( repo.path, ...worktrees.map((worktree) => worktree.path) ]) - return worktrees.map((worktree) => { + const detected = worktrees.map((worktree) => { const meta = store.getWorktreeMeta(worktree.id) const detected = toDetectedWorktree({ repo, @@ -972,6 +974,7 @@ function buildDisconnectedDetectedWorktrees( }) return applyMetadataFallbackVisibility(detected) }) + return projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) } export function registerWorktreeHandlers( @@ -1149,7 +1152,10 @@ export function registerWorktreeHandlers( repoId: repo.id, authoritative: true, source: 'git', - worktrees: buildFolderDetectedWorktrees(store, repo) + worktrees: projectResolvedWorktreeLineage( + buildFolderDetectedWorktrees(store, repo), + store.getAllWorktreeLineage?.() ?? {} + ) } } else if (repo.connectionId) { const provider = getSshGitProvider(repo.connectionId) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 3a5099edde2..897a2fcb8ec 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -8,7 +8,7 @@ import { execFileSync } from 'node:child_process' import { mkdirSync } from 'node:fs' import { lstat, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' -import { join, win32 } from 'node:path' +import { basename, join, win32 } from 'node:path' import { ipcMain } from 'electron' import type { FolderWorkspace, @@ -24854,19 +24854,34 @@ describe('OrcaRuntimeService', () => { }) }) - it('emits only instance-validated lineage parents in mobile summaries', async () => { + it('emits only instance- and boundary-validated lineage parents in mobile summaries', async () => { // Regression: shipped mobile clients trust parentWorktreeId blindly, so worktree.ps must not emit stale same-path lineage. - const parentPath = '/tmp/worktree-parent' - const validChildPath = '/tmp/worktree-child-valid' - const staleChildPath = '/tmp/worktree-child-stale' + const parentPath = join(tmpdir(), 'worktree-parent') + const validChildPath = join(tmpdir(), 'worktree-child-valid') + const staleChildPath = join(tmpdir(), 'worktree-child-stale') + const crossHostChildPath = join(tmpdir(), 'worktree-child-cross-host') const parentId = `${TEST_REPO_ID}::${parentPath}` const validChildId = `${TEST_REPO_ID}::${validChildPath}` const staleChildId = `${TEST_REPO_ID}::${staleChildPath}` + const crossHostChildId = `${TEST_REPO_ID}::${crossHostChildPath}` const metaById: Record = { - [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }), - [validChildId]: makeWorktreeMeta({ instanceId: 'child-instance' }), + [parentId]: makeWorktreeMeta({ + instanceId: 'parent-instance', + hostId: 'local', + projectId: 'project-a' + }), + [validChildId]: makeWorktreeMeta({ + instanceId: 'child-instance', + hostId: 'local', + projectId: 'project-a' + }), // The stale child path was reused by a replacement checkout. - [staleChildId]: makeWorktreeMeta({ instanceId: 'replacement-instance' }) + [staleChildId]: makeWorktreeMeta({ instanceId: 'replacement-instance' }), + [crossHostChildId]: makeWorktreeMeta({ + instanceId: 'cross-host-child-instance', + hostId: 'runtime:other-host', + projectId: 'project-a' + }) } const makeLineage = (childId: string, worktreeInstanceId: string): WorktreeLineage => ({ worktreeId: childId, @@ -24879,7 +24894,8 @@ describe('OrcaRuntimeService', () => { }) const lineageById: Record = { [validChildId]: makeLineage(validChildId, 'child-instance'), - [staleChildId]: makeLineage(staleChildId, 'old-child-instance') + [staleChildId]: makeLineage(staleChildId, 'old-child-instance'), + [crossHostChildId]: makeLineage(crossHostChildId, 'cross-host-child-instance') } const runtimeStore = { ...store, @@ -24893,10 +24909,10 @@ describe('OrcaRuntimeService', () => { getWorktreeLineage: (worktreeId: string) => lineageById[worktreeId] } vi.mocked(listWorktrees).mockResolvedValue( - [parentPath, validChildPath, staleChildPath].map((path) => ({ + [parentPath, validChildPath, staleChildPath, crossHostChildPath].map((path) => ({ path, head: 'abc', - branch: `feature/${path.split('/').pop()}`, + branch: `feature/${basename(path)}`, isBare: false, isMainWorktree: false })) @@ -24918,6 +24934,13 @@ describe('OrcaRuntimeService', () => { }) expect(staleSummary?.lineageWorktreeInstanceId).toBeUndefined() expect(staleSummary?.parentWorktreeInstanceId).toBeUndefined() + const crossHostSummary = worktrees.find((worktree) => worktree.worktreeId === crossHostChildId) + expect(crossHostSummary).toMatchObject({ + parentWorktreeId: null, + worktreeInstanceId: 'cross-host-child-instance' + }) + expect(crossHostSummary?.lineageWorktreeInstanceId).toBeUndefined() + expect(crossHostSummary?.parentWorktreeInstanceId).toBeUndefined() expect(worktrees.find((worktree) => worktree.worktreeId === parentId)).toMatchObject({ childWorktreeIds: [validChildId] }) @@ -28051,6 +28074,80 @@ describe('OrcaRuntimeService', () => { ) }) + it.each([ + { + boundary: 'repository', + childRepoId: 'repo-child', + parentRepoId: 'repo-parent', + childMeta: {}, + parentMeta: {} + }, + { + boundary: 'known host', + childRepoId: TEST_REPO_ID, + parentRepoId: TEST_REPO_ID, + childMeta: { hostId: 'runtime:child-host' as const }, + parentMeta: { hostId: 'runtime:parent-host' as const } + }, + { + boundary: 'known project', + childRepoId: TEST_REPO_ID, + parentRepoId: TEST_REPO_ID, + childMeta: { projectId: 'project-child' }, + parentMeta: { projectId: 'project-parent' } + } + ])('rejects manual lineage writes across a $boundary boundary', async (scenario) => { + const repos = [...new Set([scenario.childRepoId, scenario.parentRepoId])].map((id) => ({ + id, + path: join(tmpdir(), id), + displayName: id, + badgeColor: 'blue' as const, + addedAt: 1 + })) + const childRepoPath = repos.find((repo) => repo.id === scenario.childRepoId)!.path + const parentRepoPath = repos.find((repo) => repo.id === scenario.parentRepoId)!.path + const childPath = join(childRepoPath, 'child') + const parentPath = join(parentRepoPath, 'parent') + const childId = `${scenario.childRepoId}::${childPath}` + const parentId = `${scenario.parentRepoId}::${parentPath}` + const metaById: Record = { + [childId]: makeWorktreeMeta({ instanceId: 'child-instance', ...scenario.childMeta }), + [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance', ...scenario.parentMeta }) + } + const setWorktreeLineage = vi.fn() + const setWorkspaceLineage = vi.fn() + const runtimeStore = { + ...store, + getRepos: () => repos, + getRepo: (id: string) => repos.find((repo) => repo.id === id), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...metaById[worktreeId], ...meta } + return metaById[worktreeId] + }, + getWorktreeLineage: () => undefined, + setWorktreeLineage, + setWorkspaceLineage + } + vi.mocked(listWorktrees).mockImplementation(async (repoPath) => [ + ...(repoPath === childRepoPath ? [makeWorktreeInfo(childPath)] : []), + ...(repoPath === parentRepoPath ? [makeWorktreeInfo(parentPath)] : []) + ]) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + await expect( + runtime.updateManagedWorktreeMeta(`id:${childId}`, { + lineage: { parentWorktree: `id:${parentId}` } + }) + ).rejects.toThrow( + 'Parent worktree must belong to the same repository, execution host, and project.' + ) + + expect(setWorktreeLineage).not.toHaveBeenCalled() + expect(setWorkspaceLineage).not.toHaveBeenCalled() + }) + it('clears workspace lineage when manually removing a parent', async () => { const childPath = '/tmp/worktree-child' const childId = `${TEST_REPO_ID}::${childPath}` @@ -28377,6 +28474,110 @@ describe('OrcaRuntimeService', () => { expect(removeWorktreeLineage).not.toHaveBeenCalled() }) + it('hydrates runtime detected lists with instance-validated legacy lineage', async () => { + const parentPath = join(tmpdir(), 'worktree-parent') + const childPath = join(tmpdir(), 'worktree-child') + const parentId = `${TEST_REPO_ID}::${parentPath}` + const childId = `${TEST_REPO_ID}::${childPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }), + [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) + } + const lineageById: Record = { + [childId]: { + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 1 + } + } + const runtime = new OrcaRuntimeService({ + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + getAllWorktreeLineage: () => lineageById + } as never) + vi.mocked(listWorktrees).mockResolvedValue([ + makeWorktreeInfo(childPath), + makeWorktreeInfo(parentPath) + ]) + + const result = await runtime.listDetectedManagedWorktrees(`id:${TEST_REPO_ID}`) + + expect(result.worktrees).toEqual([ + expect.objectContaining({ + id: childId, + parentWorktreeId: parentId, + lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' }) + }), + expect.objectContaining({ + id: parentId, + parentWorktreeId: null, + childWorktreeIds: [childId], + lineage: null + }) + ]) + }) + + it('hydrates folder-repo detected rows with instance-validated legacy lineage', async () => { + const folderRepo = { + id: 'folder-repo', + path: '/workspace/folder', + displayName: 'folder', + badgeColor: 'blue' as const, + addedAt: 1, + kind: 'folder' as const + } + const parentId = `${folderRepo.id}::${folderRepo.path}` + const childId = `${parentId}::workspace:child-instance` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }), + [childId]: makeWorktreeMeta({ instanceId: 'child-instance' }) + } + const lineageById: Record = { + [childId]: { + worktreeId: childId, + worktreeInstanceId: 'child-instance', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 1 + } + } + const runtime = new OrcaRuntimeService({ + ...store, + getRepos: () => [folderRepo], + getRepo: (id: string) => (id === folderRepo.id ? folderRepo : undefined), + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + }, + getAllWorktreeLineage: () => lineageById + } as never) + + const result = await runtime.listDetectedManagedWorktrees(`id:${folderRepo.id}`) + + expect(result.worktrees).toEqual([ + expect.objectContaining({ + id: parentId, + parentWorktreeId: null, + childWorktreeIds: [childId], + lineage: null + }), + expect.objectContaining({ + id: childId, + parentWorktreeId: parentId, + lineage: expect.objectContaining({ parentWorktreeInstanceId: 'parent-instance' }) + }) + ]) + }) + it('hides agent scratch created inside a linked checkout from runtime listings', async () => { const linkedCheckoutPath = '/tmp/worktree-a' const scratchPath = `${linkedCheckoutPath}/.claude/worktrees/agent-a04ccaaa` diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index bbeba293b58..5bab178d41b 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -301,6 +301,10 @@ import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' +import { + projectResolvedWorktreeLineage, + sharesResolvedWorktreeLineageBoundary +} from '../../shared/resolved-worktree-lineage' import { folderWorkspaceToWorktree } from '../../shared/folder-workspace-worktree' import type { FolderWorkspacePathStatus, @@ -16555,13 +16559,15 @@ export class OrcaRuntimeService { async listDetectedManagedWorktrees(repoSelector: string): Promise { const repo = await this.resolveRepoSelector(repoSelector) + const store = this.requireStore() if (isFolderRepo(repo)) { - const worktrees = listRuntimeFolderWorkspaces(this.requireStore(), repo) + const worktrees = listRuntimeFolderWorkspaces(store, repo) + const detected = worktrees.map((worktree) => this.toRuntimeDetectedWorktree(repo, worktree)) return { repoId: repo.id, authoritative: true, source: 'git', - worktrees: worktrees.map((worktree) => this.toRuntimeDetectedWorktree(repo, worktree)) + worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) } } let scan: RuntimeWorktreeScanResult @@ -16579,7 +16585,7 @@ export class OrcaRuntimeService { ]) const detected = scan.worktrees.map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` - const meta = this.store?.getWorktreeMeta(worktreeId) + const meta = store.getWorktreeMeta(worktreeId) const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName) const detectedWorktree = this.toRuntimeDetectedWorktree( repo, @@ -16595,7 +16601,7 @@ export class OrcaRuntimeService { repoId: repo.id, authoritative: scan.ok, source: scan.ok ? 'git' : 'metadata-fallback', - worktrees: detected + worktrees: projectResolvedWorktreeLineage(detected, store.getAllWorktreeLineage?.() ?? {}) } } @@ -23152,6 +23158,12 @@ export class OrcaRuntimeService { if (childWorktreeId === parentWorktreeId) { throw new RuntimeLineageError('LINEAGE_PARENT_CYCLE', 'A worktree cannot parent itself.') } + if (!sharesResolvedWorktreeLineageBoundary(child, parent)) { + throw new RuntimeLineageError( + 'LINEAGE_PARENT_CONTEXT_CONFLICT', + 'Parent worktree must belong to the same repository, execution host, and project.' + ) + } const instanceByWorktreeId = new Map( this.resolvedWorktreeCache?.worktrees.map((worktree) => [ worktree.id, @@ -23722,7 +23734,10 @@ export class OrcaRuntimeService { }) }) ) - const worktrees = this.attachLineageToResolvedWorktrees(perRepoWorktrees.flat()) + const worktrees = projectResolvedWorktreeLineage( + perRepoWorktrees.flat(), + this.store?.getAllWorktreeLineage?.() ?? {} + ) // Why: short TTL avoids shelling out on every frequent poll while still catching worktree changes made outside Orca. if (generation === this.resolvedWorktreeGeneration) { this.resolvedWorktreeCache = { @@ -23734,41 +23749,6 @@ export class OrcaRuntimeService { return { worktrees, platformByRepoId } } - private attachLineageToResolvedWorktrees(worktrees: ResolvedWorktree[]): ResolvedWorktree[] { - const lineageById = this.store?.getAllWorktreeLineage?.() ?? {} - const worktreeById = new Map(worktrees.map((worktree) => [worktree.id, worktree])) - const validLineageByChildId = new Map() - const childIdsByParentId = new Map() - - for (const [childId, lineage] of Object.entries(lineageById)) { - const child = worktreeById.get(childId) - const parent = worktreeById.get(lineage.parentWorktreeId) - if ( - !child || - !parent || - child.instanceId !== lineage.worktreeInstanceId || - parent.instanceId !== lineage.parentWorktreeInstanceId - ) { - // Why: worktree IDs are path-derived, so instance checks keep replacement checkouts off stale same-path lineage. - continue - } - validLineageByChildId.set(childId, lineage) - const children = childIdsByParentId.get(lineage.parentWorktreeId) ?? [] - children.push(childId) - childIdsByParentId.set(lineage.parentWorktreeId, children) - } - - return worktrees.map((worktree) => { - const lineage = validLineageByChildId.get(worktree.id) ?? null - return { - ...worktree, - parentWorktreeId: lineage?.parentWorktreeId ?? null, - childWorktreeIds: childIdsByParentId.get(worktree.id) ?? [], - lineage - } - }) - } - private pruneLineageForMissingRepoWorktrees(repo: Repo, gitWorktrees: GitWorktreeInfo[]): void { const store = this.store if ( diff --git a/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts b/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts index 3244e5c82e5..ed765b0886d 100644 --- a/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts +++ b/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.test.ts @@ -6,6 +6,7 @@ import type { WorkspaceLineage } from '../../../../shared/types' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope' +import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../../shared/execution-host' import { getAttachedWorktreesForFolderWorkspace } from './folder-workspace-attached-worktrees' function makeFolder(id = 'folder-1'): FolderWorkspace { @@ -166,4 +167,110 @@ describe('getAttachedWorktreesForFolderWorkspace', () => { [nested.id] ) }) + + it('includes an exact inline-only legacy descendant under an attached root', () => { + const parent = makeWorktree({ + id: 'repo-1::/parent', + instanceId: 'parent' + }) + const nested = makeWorktree({ + id: 'repo-1::/nested', + instanceId: 'nested' + }) + const inlineNested = { + ...nested, + lineage: makeWorktreeLineage(nested, parent) + } as Worktree + + const result = getAttachedWorktreesForFolderWorkspace({ + activeWorkspaceKey: folderWorkspaceKey('folder-1'), + activeWorktreeId: null, + folderWorkspaces: [makeFolder()], + workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) }, + worktreeLineageById: {}, + worktreesByRepo: { 'repo-1': [parent, inlineNested] } + }) + + expect(result.lineageChildrenByParentId.get(parent.id)?.map((worktree) => worktree.id)).toEqual( + [nested.id] + ) + }) + + it('keeps a stale side-map entry authoritative over valid inline lineage', () => { + const parent = makeWorktree({ id: 'repo-1::/parent', instanceId: 'parent' }) + const nested = makeWorktree({ id: 'repo-1::/nested', instanceId: 'nested' }) + const inlineNested = { + ...nested, + lineage: makeWorktreeLineage(nested, parent) + } as Worktree + + const result = getAttachedWorktreesForFolderWorkspace({ + activeWorkspaceKey: folderWorkspaceKey('folder-1'), + activeWorktreeId: null, + folderWorkspaces: [makeFolder()], + workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) }, + worktreeLineageById: { + [nested.id]: { + ...makeWorktreeLineage(nested, parent), + parentWorktreeInstanceId: 'stale-parent' + } + }, + worktreesByRepo: { 'repo-1': [parent, inlineNested] } + }) + + expect(result.lineageChildrenByParentId.size).toBe(0) + }) + + it('rejects nested descendants across host or project boundaries', () => { + const parent = makeWorktree({ + id: 'repo-1::/parent', + instanceId: 'parent', + hostId: LOCAL_EXECUTION_HOST_ID, + projectId: 'project-1' + }) + const hostChild = makeWorktree({ + id: 'repo-1::/host-child', + instanceId: 'host-child', + hostId: toSshExecutionHostId('other') + }) + const projectChild = makeWorktree({ + id: 'repo-1::/project-child', + instanceId: 'project-child', + projectId: 'project-2' + }) + + const result = getAttachedWorktreesForFolderWorkspace({ + activeWorkspaceKey: folderWorkspaceKey('folder-1'), + activeWorktreeId: null, + folderWorkspaces: [makeFolder()], + workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) }, + worktreeLineageById: { + [hostChild.id]: makeWorktreeLineage(hostChild, parent), + [projectChild.id]: makeWorktreeLineage(projectChild, parent) + }, + worktreesByRepo: { 'repo-1': [parent, hostChild, projectChild] } + }) + + expect(result.lineageChildrenByParentId.size).toBe(0) + }) + + it('does not attach cyclic legacy descendants', () => { + const parent = makeWorktree({ id: 'repo-1::/parent', instanceId: 'parent' }) + const nested = makeWorktree({ id: 'repo-1::/nested', instanceId: 'nested' }) + + const result = getAttachedWorktreesForFolderWorkspace({ + activeWorkspaceKey: folderWorkspaceKey('folder-1'), + activeWorktreeId: null, + folderWorkspaces: [makeFolder()], + workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) }, + worktreeLineageById: { + [parent.id]: makeWorktreeLineage(parent, nested), + [nested.id]: makeWorktreeLineage(nested, parent) + }, + worktreesByRepo: { 'repo-1': [parent, nested] } + }) + + expect(result.lineageChildrenByParentId.size).toBe(0) + expect(result.rootChildWorktrees.map((worktree) => worktree.id)).toEqual([parent.id]) + }) }) diff --git a/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.ts b/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.ts index 97999e97cac..ec65782b488 100644 --- a/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.ts +++ b/src/renderer/src/components/right-sidebar/folder-workspace-attached-worktrees.ts @@ -6,6 +6,7 @@ import type { WorkspaceLineage } from '../../../../shared/types' import { compareWorktreeDisplayName } from '@/lib/worktree-display-name-order' +import { getProjectedWorktreeLineageChildrenByParentId } from '../sidebar/worktree-lineage-projection' export type AttachedWorktreeResolution = { folderWorkspace: FolderWorkspace | null @@ -90,39 +91,30 @@ export function getLineageChildrenByParentId( worktreeById: Map, rootWorktreeIds: ReadonlySet ): Map { - const descendantsByParentId = new Map() + const projectedChildrenByParentId = getProjectedWorktreeLineageChildrenByParentId( + lineageById, + worktreeById + ) const includedIds = new Set(rootWorktreeIds) - let added = true - - while (added) { - added = false - for (const lineage of Object.values(lineageById)) { - const parent = worktreeById.get(lineage.parentWorktreeId) - const child = worktreeById.get(lineage.worktreeId) - if (!isValidLineageChild(parent, child, lineage, includedIds)) { + const queue = [...rootWorktreeIds] + for (let index = 0; index < queue.length; index += 1) { + for (const child of projectedChildrenByParentId.get(queue[index]) ?? []) { + if (child.isArchived || includedIds.has(child.id)) { continue } includedIds.add(child.id) - added = true + queue.push(child.id) } } - for (const worktreeId of includedIds) { - const child = worktreeById.get(worktreeId) - if (!child) { - continue + const descendantsByParentId = new Map() + for (const parentId of includedIds) { + const children = (projectedChildrenByParentId.get(parentId) ?? []).filter( + (child) => includedIds.has(child.id) && !child.isArchived + ) + if (children.length > 0) { + descendantsByParentId.set(parentId, children) } - const lineage = lineageById[child.id] - if (!lineage || !includedIds.has(lineage.parentWorktreeId)) { - continue - } - const parent = worktreeById.get(lineage.parentWorktreeId) - if (!isCurrentLineagePair(parent, child, lineage)) { - continue - } - const children = descendantsByParentId.get(parent.id) ?? [] - children.push(child) - descendantsByParentId.set(parent.id, children) } for (const children of descendantsByParentId.values()) { @@ -160,39 +152,6 @@ function getLineageChildWorktree( return worktree } -function isValidLineageChild( - parent: Worktree | undefined, - child: Worktree | undefined, - lineage: WorktreeLineage, - includedIds: ReadonlySet -): child is Worktree { - if ( - !parent || - !child || - parent.isArchived || - child.isArchived || - !includedIds.has(parent.id) || - includedIds.has(child.id) - ) { - return false - } - return isCurrentLineagePair(parent, child, lineage) -} - -function isCurrentLineagePair( - parent: Worktree | undefined, - child: Worktree, - lineage: WorktreeLineage -): parent is Worktree { - return Boolean( - parent && - !parent.isArchived && - !child.isArchived && - child.instanceId === lineage.worktreeInstanceId && - parent.instanceId === lineage.parentWorktreeInstanceId - ) -} - function sortWorktreesByRecentActivity(left: Worktree, right: Worktree): number { return ( getWorktreeActivityTime(right) - getWorktreeActivityTime(left) || diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts b/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts index f7de53c9634..f036894f852 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.test.ts @@ -9,9 +9,11 @@ import { shouldContinueDeleteSiblingPositionRestore, getWorktreeParentPickerAnchor, getWorktreeParentPickerLabel, + hasWorktreeParentLink, isWorktreeParentPickerDisabled, selectMenuScopedMap } from './WorktreeContextMenu' +import type { Worktree, WorktreeLineage } from '../../../../shared/types' describe('selectMenuScopedMap (delete-teardown re-render guard)', () => { // Why: the closed menu wrapper must stay inert to delete teardown's high-churn @@ -139,6 +141,26 @@ describe('shouldContinueDeleteSiblingPositionRestore', () => { }) describe('parent picker context menu affordance', () => { + it('offers unlink for valid inline-only legacy lineage after stable-update hydration', () => { + const parent = { id: 'repo::parent', instanceId: 'parent-instance' } + const lineage: WorktreeLineage = { + worktreeId: 'repo::child', + worktreeInstanceId: 'child-instance', + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId, + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 1 + } + const child = { + id: lineage.worktreeId, + instanceId: lineage.worktreeInstanceId, + lineage + } as Worktree & { lineage: WorktreeLineage } + + expect(hasWorktreeParentLink(child, {}, {})).toBe(true) + }) + it('uses set/change labels based on valid parent presence', () => { expect(getWorktreeParentPickerLabel(null)).toBe('Set Parent Worktree...') expect(getWorktreeParentPickerLabel('parent-1')).toBe('Change Parent Worktree...') diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index 6b1e940e068..5a67e5450ec 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -41,7 +41,11 @@ import { runSleepWorktrees } from './sleep-worktree-flow' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT } from '@/hooks/useVirtualizedScrollAnchor' -import { getLineageRenderInfo } from './worktree-list-groups' +import { + getCyclicProjectedWorktreeLineageIds, + getLineageRenderInfo, + getProjectedWorktreeLineage +} from './worktree-lineage-projection' import { getWorkspaceStatus, getWorkspaceStatusVisualMeta } from './workspace-status' import { WorktreeOpenInSubMenu } from './WorktreeOpenInMenu' import { ProjectGroupNameDialog } from './ProjectGroupNameDialog' @@ -82,6 +86,7 @@ const EMPTY_BROWSER_TABS_BY_WORKTREE: AppState['browserTabsByWorktree'] = {} const EMPTY_DELETE_STATE_BY_WORKTREE_ID: AppState['deleteStateByWorktreeId'] = {} const EMPTY_WORKTREE_LINEAGE_BY_ID: AppState['worktreeLineageById'] = {} const EMPTY_WORKSPACE_LINEAGE_BY_CHILD_KEY: AppState['workspaceLineageByChildKey'] = {} +const EMPTY_CYCLIC_LINEAGE_IDS: ReadonlySet = new Set() // Why: the gating decision for the menu-only store subscriptions. When the menu is // closed we MUST return the same `empty` reference every render so Zustand's Object.is @@ -92,6 +97,17 @@ export function selectMenuScopedMap(menuOpen: boolean, live: T, empty: T): T return menuOpen ? live : empty } +export function hasWorktreeParentLink( + worktree: Worktree, + lineageById: AppState['worktreeLineageById'], + workspaceLineageByChildKey: AppState['workspaceLineageByChildKey'] +): boolean { + return Boolean( + getProjectedWorktreeLineage(worktree, lineageById) || + workspaceLineageByChildKey[worktreeWorkspaceKey(worktree.id)] + ) +} + function shouldUseNativeContextMenu(target: EventTarget | null): boolean { const maybeElement = target as { closest?: (selector: string) => Element | null @@ -376,29 +392,41 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ isMultiContext && batchDeleteWorktrees.length > 0 ? `Delete ${batchDeleteWorktrees.length} Workspace${batchDeleteWorktrees.length === 1 ? '' : 's'}` : 'Delete Selected' - const lineage = worktreeLineageById[worktree.id] - const workspaceLineage = workspaceLineageByChildKey[worktreeWorkspaceKey(worktree.id)] + const hasParentLink = hasWorktreeParentLink( + worktree, + worktreeLineageById, + workspaceLineageByChildKey + ) + const cyclicLineageIds = useMemo( + () => + menuOpen + ? getCyclicProjectedWorktreeLineageIds(worktreeLineageById, worktreeMap) + : EMPTY_CYCLIC_LINEAGE_IDS, + [menuOpen, worktreeLineageById, worktreeMap] + ) // Why: path-derived worktree IDs can be reused. The menu must honor the same // instance check as grouped rows before offering navigation to a parent. const lineageInfo = useMemo( - () => getLineageRenderInfo(worktree, worktreeLineageById, worktreeMap), - [worktree, worktreeLineageById, worktreeMap] + () => getLineageRenderInfo(worktree, worktreeLineageById, worktreeMap, cyclicLineageIds), + [cyclicLineageIds, worktree, worktreeLineageById, worktreeMap] ) const validParentWorktreeId = lineageInfo.state === 'valid' ? lineageInfo.parent.id : null - const hasAnyContextLineage = activeContextWorktrees.some( - (item) => - worktreeLineageById[item.id] || workspaceLineageByChildKey[worktreeWorkspaceKey(item.id)] + const hasAnyContextLineage = activeContextWorktrees.some((item) => + hasWorktreeParentLink(item, worktreeLineageById, workspaceLineageByChildKey) ) const eligibleParentCount = useMemo( () => - getEligibleWorktreeParents({ - child: worktree, - worktrees: allWorktrees, - lineageById: worktreeLineageById, - worktreeMap, - repoMap - }).length, - [allWorktrees, repoMap, worktree, worktreeLineageById, worktreeMap] + menuOpen + ? getEligibleWorktreeParents({ + child: worktree, + worktrees: allWorktrees, + lineageById: worktreeLineageById, + worktreeMap, + repoMap, + cyclicLineageIds + }).length + : 0, + [allWorktrees, cyclicLineageIds, menuOpen, repoMap, worktree, worktreeLineageById, worktreeMap] ) const setMenuOpenState = useCallback( @@ -814,7 +842,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ {getWorktreeParentPickerLabel(validParentWorktreeId)} - {(validParentWorktreeId || lineage || workspaceLineage) && ( + {(validParentWorktreeId || hasParentLink) && ( <> {validParentWorktreeId && ( @@ -825,7 +853,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ )} )} - {(lineage || workspaceLineage) && ( + {hasParentLink && ( {translate( diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index fcc71abb98c..90372e7cf1d 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -122,6 +122,10 @@ import { setVisibleWorktreeIds, sidebarHasActiveFilters } from './visible-worktrees' +import { + getCyclicProjectedWorktreeLineageIds, + getWorktreeLineageAncestors +} from './worktree-lineage-projection' import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state' import { getEmptyProjectPlaceholderRepoIds } from './empty-project-placeholder-repos' import { @@ -249,7 +253,7 @@ import { suppressNewExternalWorktreeInbox, type NewExternalWorktreesInboxActionState } from './new-external-worktrees-inbox-actions' -import { getEligibleWorktreeParents } from './worktree-parent-candidates' +import { isEligibleWorktreeParent } from './worktree-parent-candidates' import { buildImportedWorktreesCardCandidates, getHiddenImportedWorktrees @@ -1395,6 +1399,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const setRenamingWorktreeId = useAppStore((s) => s.setRenamingWorktreeId) const assignWorktreeParent = useAppStore((s) => s.assignWorktreeParent) const updateWorktreeLineage = useAppStore((s) => s.updateWorktreeLineage) + const cyclicLineageIds = useMemo( + () => getCyclicProjectedWorktreeLineageIds(worktreeLineageById, worktreeMap), + [worktreeLineageById, worktreeMap] + ) const worktreeDragSessionRef = useRef(null) const worktreePointerDragRef = useRef(null) const worktreePointerAutoscrollFrameIdRef = useRef(null) @@ -2085,25 +2093,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp toggleGroup(hostGroupKey) } - const seen = new Set() - let current: Worktree | undefined = targetWorktree - while (current && !seen.has(current.id)) { - seen.add(current.id) - const lineage = worktreeLineageById[current.id] - const parent = lineage ? worktreeMap.get(lineage.parentWorktreeId) : undefined - if ( - !lineage || - !parent || - current.instanceId !== lineage.worktreeInstanceId || - parent.instanceId !== lineage.parentWorktreeInstanceId - ) { - break - } + for (const parent of getWorktreeLineageAncestors( + targetWorktree, + worktreeLineageById, + worktreeMap + )) { const lineageGroupKey = getLineageGroupKey(parent.id) if (collapsedGroups.has(lineageGroupKey)) { toggleGroup(lineageGroupKey) } - current = parent } const groupKeys = @@ -2695,17 +2693,22 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp if (!child) { return false } - return getEligibleWorktreeParents({ - child, - worktrees, - lineageById: worktreeLineageById, - worktreeMap, - repoMap - }).some((candidate) => candidate.id === parentId) + const candidateParent = worktreeMap.get(parentId) + return Boolean( + candidateParent && + isEligibleWorktreeParent({ + child, + candidateParent, + lineageById: worktreeLineageById, + worktreeMap, + repoMap, + cyclicLineageIds + }) + ) }) return canAssignAll ? target : { ...target, lineageParentId: null } }, - [repoMap, worktreeLineageById, worktreeMap, worktrees] + [cyclicLineageIds, repoMap, worktreeLineageById, worktreeMap] ) const commitWorktreeLineageParentDrop = useCallback( @@ -2742,7 +2745,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp const ids = getReorderedWorktreeIdsToUnnest({ draggedIds: args.draggedIds, sourceGroupIds: sourceGroup.worktreeIds, - lineageById: worktreeLineageById + lineageById: worktreeLineageById, + worktreeMap, + cyclicLineageIds }) if (ids.length === 0) { return @@ -2760,7 +2765,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp } ) }, - [updateWorktreeLineage, worktreeDragGroups, worktreeLineageById] + [cyclicLineageIds, updateWorktreeLineage, worktreeDragGroups, worktreeLineageById, worktreeMap] ) const flushWorktreePointerDrag = useCallback(() => { @@ -5496,16 +5501,9 @@ const WorktreeList = React.memo(function WorktreeList({ workspaceHostScope, visibleWorkspaceHostIds, defaultHostId: getSettingsFocusedExecutionHostId(settings), - worktreeLineageById + worktreeLineageById, + forcedVisibleWorktreeIds: agentSendTargetWorktreeId ? [agentSendTargetWorktreeId] : undefined }) - if ( - agentSendTargetWorktreeId && - !ids.includes(agentSendTargetWorktreeId) && - worktreeMap.has(agentSendTargetWorktreeId) - ) { - // Why: send-target mode is a temporary picker; surface the target card without rewriting the user's filters. - ids.push(agentSendTargetWorktreeId) - } return ids.map((id) => worktreeMap.get(id)).filter((w): w is Worktree => w != null) }, [ agentSendTargetWorktreeId, @@ -5573,22 +5571,12 @@ const WorktreeList = React.memo(function WorktreeList({ } } - const seen = new Set() - let current: Worktree | undefined = targetWorktree - while (current && !seen.has(current.id)) { - seen.add(current.id) - const lineage = worktreeLineageById[current.id] - const parent = lineage ? worktreeMap.get(lineage.parentWorktreeId) : undefined - if ( - !lineage || - !parent || - current.instanceId !== lineage.worktreeInstanceId || - parent.instanceId !== lineage.parentWorktreeInstanceId - ) { - break - } + for (const parent of getWorktreeLineageAncestors( + targetWorktree, + worktreeLineageById, + worktreeMap + )) { next.delete(getLineageGroupKey(parent.id)) - current = parent } return next }, [ diff --git a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts index 0b823f62c8c..dc971a0603e 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.test.ts @@ -171,6 +171,109 @@ describe('getFolderWorkspaceCardPrDisplay', () => { expect(display).toMatchObject({ number: 4, status: 'success' }) }) + it('includes a nested PR from exact inline-only legacy lineage', () => { + const parent = makeWorktree({ id: 'parent', instanceId: 'parent' }) + const nested = makeWorktree({ id: 'nested', instanceId: 'nested', linkedPR: 4 }) + const inlineNested = { ...nested, lineage: makeWorktreeLineage(nested, parent) } as Worktree + + const display = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: 'folder-1', + workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) }, + worktreeLineageById: {}, + worktreeMap: new Map([ + [parent.id, parent], + [inlineNested.id, inlineNested] + ]), + repoMap: new Map([[repo.id, repo]]), + hostedReviewCache: null, + prCache: { 'repo-1::nested': makePrEntry(4, 'success') } + }) + + expect(display).toMatchObject({ number: 4, status: 'success' }) + }) + + it('keeps a stale side-map entry authoritative over valid inline lineage', () => { + const parent = makeWorktree({ id: 'parent', instanceId: 'parent' }) + const nested = makeWorktree({ id: 'nested', instanceId: 'nested', linkedPR: 4 }) + const inlineNested = { ...nested, lineage: makeWorktreeLineage(nested, parent) } as Worktree + + const display = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: 'folder-1', + workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) }, + worktreeLineageById: { + [nested.id]: { + ...makeWorktreeLineage(nested, parent), + parentWorktreeInstanceId: 'stale-parent' + } + }, + worktreeMap: new Map([ + [parent.id, parent], + [inlineNested.id, inlineNested] + ]), + repoMap: new Map([[repo.id, repo]]), + hostedReviewCache: null, + prCache: { 'repo-1::nested': makePrEntry(4, 'success') } + }) + + expect(display).toBeNull() + }) + + it.each([ + ['repository', { repoId: 'repo-2' }, {}], + ['known host', { hostId: 'ssh:remote' as const }, { hostId: 'local' as const }], + ['known project', { projectId: 'project-b' }, { projectId: 'project-a' }] + ])('excludes nested PRs across a %s boundary', (_boundary, childOverrides, parentOverrides) => { + const parent = makeWorktree({ id: 'parent', instanceId: 'parent', ...parentOverrides }) + const nested = makeWorktree({ + id: 'nested', + instanceId: 'nested', + linkedPR: 4, + ...childOverrides + }) + const nestedRepo = { ...repo, id: nested.repoId } + + const display = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: 'folder-1', + workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) }, + worktreeLineageById: { [nested.id]: makeWorktreeLineage(nested, parent) }, + worktreeMap: new Map([ + [parent.id, parent], + [nested.id, nested] + ]), + repoMap: new Map([ + [repo.id, repo], + [nestedRepo.id, nestedRepo] + ]), + hostedReviewCache: null, + prCache: { [`${nested.repoId}::nested`]: makePrEntry(4, 'success') } + }) + + expect(display).toBeNull() + }) + + it('excludes nested PRs from cyclic projected lineage', () => { + const parent = makeWorktree({ id: 'parent', instanceId: 'parent' }) + const nested = makeWorktree({ id: 'nested', instanceId: 'nested', linkedPR: 4 }) + + const display = getFolderWorkspaceCardPrDisplay({ + folderWorkspaceId: 'folder-1', + workspaceLineageByChildKey: { [parent.id]: makeWorkspaceLineage(parent) }, + worktreeLineageById: { + [parent.id]: makeWorktreeLineage(parent, nested), + [nested.id]: makeWorktreeLineage(nested, parent) + }, + worktreeMap: new Map([ + [parent.id, parent], + [nested.id, nested] + ]), + repoMap: new Map([[repo.id, repo]]), + hostedReviewCache: null, + prCache: { 'repo-1::nested': makePrEntry(4, 'success') } + }) + + expect(display).toBeNull() + }) + it('uses branch-discovered PR cache for unlinked attached worktrees', () => { const worktree = makeWorktree({ id: 'branch-discovered', linkedPR: null }) diff --git a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts index bbd67117685..3f71970ec33 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-card-pr-display.ts @@ -6,6 +6,7 @@ import { type ParentPrChecksRow } from '@/components/right-sidebar/parent-pr-checks-rows' import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' +import { getProjectedWorktreeLineageChildrenByParentId } from './worktree-lineage-projection' type FolderWorkspaceCardPrDisplayArgs = { folderWorkspaceId: string @@ -77,21 +78,18 @@ function getAttachedWorktreesForFolderWorkspaceCard({ .filter((worktree): worktree is Worktree => worktree !== null) const included = new Map(directChildren.map((worktree) => [worktree.id, worktree])) - let added = true - - while (added) { - added = false - for (const lineage of Object.values(worktreeLineageById ?? {})) { - if (included.has(lineage.worktreeId) || !included.has(lineage.parentWorktreeId)) { - continue - } - const parent = worktreeMap.get(lineage.parentWorktreeId) - const child = worktreeMap.get(lineage.worktreeId) - if (!isCurrentLineagePair(parent, child, lineage)) { + const childrenByParentId = getProjectedWorktreeLineageChildrenByParentId( + worktreeLineageById ?? {}, + worktreeMap + ) + const queue = [...directChildren] + for (let index = 0; index < queue.length; index += 1) { + for (const child of childrenByParentId.get(queue[index].id) ?? []) { + if (child.isArchived || included.has(child.id)) { continue } included.set(child.id, child) - added = true + queue.push(child) } } @@ -130,21 +128,6 @@ function getWorkspaceLineageChild( return worktree } -function isCurrentLineagePair( - parent: Worktree | undefined, - child: Worktree | undefined, - lineage: WorktreeLineage -): child is Worktree { - return Boolean( - parent && - child && - !parent.isArchived && - !child.isArchived && - child.instanceId === lineage.worktreeInstanceId && - parent.instanceId === lineage.parentWorktreeInstanceId - ) -} - function compareReviewDisplays(left: WorktreeCardPrDisplay, right: WorktreeCardPrDisplay): number { return getReviewDisplayPriority(left) - getReviewDisplayPriority(right) } diff --git a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts index 6aaa6d44bd4..841c7e35bed 100644 --- a/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts +++ b/src/renderer/src/components/sidebar/use-visible-workspace-kanban-worktree-ids.ts @@ -61,9 +61,10 @@ export function useVisibleWorkspaceKanbanWorktreeIds({ workspaceHostScope, visibleWorkspaceHostIds, defaultHostId: getSettingsFocusedExecutionHostId(settings), + worktreeLineageById: {}, // Why: the board has no nested lineage presentation. Ancestor injection // would make filtered-out parents appear as ordinary cards. - worktreeLineageById: {} + injectLineageAncestors: false }) ) }, [ diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index 939ca46a1e9..0525568ec4b 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -478,6 +478,109 @@ describe('computeVisibleWorktreeIds', () => { expect(result).toEqual([parent.id, child.id]) }) + it('includes a filtered parent from resolved inline lineage when hydration has no side-map entry', () => { + const parent = makeWorktree('parent') + const child = makeWorktree('child') + const lineage = makeWorktreeLineage(child, parent) + const resolvedChild = { ...child, lineage } + + const result = computeVisibleWorktreeIds( + { repo1: [parent, resolvedChild] }, + [child.id, parent.id], + visibleOptions({ + showSleepingWorkspaces: false, + tabsByWorktree: { [child.id]: [makeTab('t-child', child.id, 'p-child')] }, + ptyIdsByTabId: { 't-child': ['p-child'] } + }) + ) + + expect(result).toEqual([parent.id, child.id]) + }) + + it('keeps inline parents out of non-nested board results across parent filters', () => { + const child = makeWorktree('child') + const run = ( + parent: ReturnType, + options: Partial + ): string[] => { + const resolvedChild = { ...child, lineage: makeWorktreeLineage(child, parent) } + return computeVisibleWorktreeIds( + { repo1: [parent, resolvedChild] }, + [parent.id, child.id], + visibleOptions({ ...options, injectLineageAncestors: false }) + ) + } + + const sleepingParent = makeWorktree('sleeping-parent') + expect( + run(sleepingParent, { + showSleepingWorkspaces: false, + tabsByWorktree: { [child.id]: [makeTab('t-child', child.id, 'p-child')] }, + ptyIdsByTabId: { 't-child': ['p-child'] } + }) + ).toEqual([child.id]) + + const defaultBranchParent = makeWorktree('default-parent') + defaultBranchParent.isMainWorktree = true + expect(run(defaultBranchParent, { hideDefaultBranchWorkspace: true })).toEqual([child.id]) + + const automationParent = makeWorktree('automation-parent') + automationParent.automationProvenance = { + kind: 'created-by-automation', + automationId: 'automation-1', + automationNameSnapshot: 'Review', + automationRunId: 'run-1', + automationRunTitleSnapshot: 'Review run', + createdAt: 1, + executionTargetType: 'local', + executionTargetId: 'local', + projectId: 'repo1', + repoId: 'repo1', + hostId: 'local' + } + expect(run(automationParent, { hideAutomationGeneratedWorkspaces: true })).toEqual([child.id]) + }) + + it('includes inline lineage ancestors when send-target mode forces a filtered child visible', () => { + const parent = makeWorktree('parent') + const child = makeWorktree('child') + const lineage = makeWorktreeLineage(child, parent) + const resolvedChild = { ...child, lineage } + + const result = computeVisibleWorktreeIds( + { repo1: [parent, resolvedChild] }, + [parent.id, child.id], + visibleOptions({ + showSleepingWorkspaces: false, + forcedVisibleWorktreeIds: [child.id] + }) + ) + + expect(result).toEqual([parent.id, child.id]) + }) + + it('keeps the hydrated side-map authoritative over disagreeing inline lineage', () => { + const inlineParent = makeWorktree('inline-parent') + const hydratedParent = makeWorktree('hydrated-parent') + const child = makeWorktree('child') + const inlineLineage = makeWorktreeLineage(child, inlineParent) + const hydratedLineage = makeWorktreeLineage(child, hydratedParent) + const resolvedChild = { ...child, lineage: inlineLineage } + + const result = computeVisibleWorktreeIds( + { repo1: [inlineParent, hydratedParent, resolvedChild] }, + [child.id, inlineParent.id, hydratedParent.id], + visibleOptions({ + showSleepingWorkspaces: false, + tabsByWorktree: { [child.id]: [makeTab('t-child', child.id, 'p-child')] }, + ptyIdsByTabId: { 't-child': ['p-child'] }, + worktreeLineageById: { [child.id]: hydratedLineage } + }) + ) + + expect(result).toEqual([hydratedParent.id, child.id]) + }) + it('does not resurrect stale lineage parents', () => { const parent = makeWorktree('parent') const child = makeWorktree('child') @@ -537,7 +640,7 @@ describe('computeVisibleWorktreeIds', () => { expect(result).toEqual([parent.id, child.id]) }) - it('includes cross-repo parents when repo filtering leaves their valid child visible', () => { + it('does not include a cross-repo parent when repo filtering leaves the child visible', () => { const parent = makeWorktree('parent', 'repo1') const child = makeWorktree('child', 'repo2') const lineage = makeWorktreeLineage(child, parent) @@ -551,7 +654,44 @@ describe('computeVisibleWorktreeIds', () => { }) ) - expect(result).toEqual([parent.id, child.id]) + expect(result).toEqual([child.id]) + }) + + it('does not include a known cross-host parent after host filtering', () => { + const parent = Object.assign(makeWorktree('parent'), { hostId: 'ssh:remote' as const }) + const child = Object.assign(makeWorktree('child'), { hostId: 'local' as const }) + const lineage = makeWorktreeLineage(child, parent) + + const result = computeVisibleWorktreeIds( + { repo1: [parent, child] }, + [child.id, parent.id], + visibleOptions({ + visibleWorkspaceHostIds: ['local'], + worktreeLineageById: { [child.id]: lineage } + }) + ) + + expect(result).toEqual([child.id]) + }) + + it('does not include a known cross-project parent hidden by another filter', () => { + const parent = Object.assign(makeWorktree('parent'), { + projectId: 'project-b', + isMainWorktree: true + }) + const child = Object.assign(makeWorktree('child'), { projectId: 'project-a' }) + const lineage = makeWorktreeLineage(child, parent) + + const result = computeVisibleWorktreeIds( + { repo1: [parent, child] }, + [child.id, parent.id], + visibleOptions({ + hideDefaultBranchWorkspace: true, + worktreeLineageById: { [child.id]: lineage } + }) + ) + + expect(result).toEqual([child.id]) }) }) diff --git a/src/renderer/src/components/sidebar/visible-worktrees.ts b/src/renderer/src/components/sidebar/visible-worktrees.ts index 8809e7f7835..4287c5caf3a 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.ts @@ -11,6 +11,10 @@ import { type ExecutionHostId, type ExecutionHostScope } from '../../../../shared/execution-host' +import { + getCyclicProjectedWorktreeLineageIds, + getLineageRenderInfo +} from './worktree-lineage-projection' /** * Whether a worktree represents the repo's default-branch row that the @@ -124,6 +128,8 @@ export function computeVisibleWorktreeIds( visibleWorkspaceHostIds?: readonly ExecutionHostId[] | null defaultHostId: ExecutionHostId worktreeLineageById: Record + injectLineageAncestors?: boolean + forcedVisibleWorktreeIds?: readonly string[] } ): string[] { let all: Worktree[] = getAllWorktreesFromState({ worktreesByRepo }) @@ -177,6 +183,17 @@ export function computeVisibleWorktreeIds( ) } + if (opts.forcedVisibleWorktreeIds && opts.forcedVisibleWorktreeIds.length > 0) { + const includedIds = new Set(all.map((worktree) => worktree.id)) + for (const worktreeId of opts.forcedVisibleWorktreeIds) { + const worktree = lineageAncestorById.get(worktreeId) + if (worktree && !includedIds.has(worktreeId)) { + includedIds.add(worktreeId) + all.push(worktree) + } + } + } + // Apply cached sort order. Items not yet in the cache (e.g. brand-new // worktrees before the next sortEpoch bump) are appended at the end. const orderIndex = new Map(sortedIds.map((id, i) => [id, i])) @@ -186,11 +203,10 @@ export function computeVisibleWorktreeIds( return ai - bi }) - return addVisibleLineageAncestors( - all.map((w) => w.id), - lineageAncestorById, - opts.worktreeLineageById - ) + const visibleIds = all.map((w) => w.id) + return opts.injectLineageAncestors === false + ? visibleIds + : addVisibleLineageAncestors(visibleIds, lineageAncestorById, opts.worktreeLineageById) } function addVisibleLineageAncestors( @@ -201,6 +217,7 @@ function addVisibleLineageAncestors( const result: string[] = [] const included = new Set() const visiting = new Set() + const cyclicLineageIds = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeById) const addWithAncestors = (id: string): void => { if (included.has(id) || visiting.has(id)) { @@ -211,16 +228,11 @@ function addVisibleLineageAncestors( return } visiting.add(id) - const lineage = lineageById[id] - const parent = lineage ? worktreeById.get(lineage.parentWorktreeId) : undefined - if ( - parent && - worktree.instanceId === lineage.worktreeInstanceId && - parent.instanceId === lineage.parentWorktreeInstanceId - ) { + const lineage = getLineageRenderInfo(worktree, lineageById, worktreeById, cyclicLineageIds) + if (lineage.state === 'valid') { // Why: sidebar lineage is structural. If a filtered child is visible, // its valid parent must be rendered too so the hierarchy remains legible. - addWithAncestors(parent.id) + addWithAncestors(lineage.parent.id) } visiting.delete(id) if (!included.has(id)) { diff --git a/src/renderer/src/components/sidebar/workspace-delete-lineage.test.ts b/src/renderer/src/components/sidebar/workspace-delete-lineage.test.ts index 888b82cc845..b5679409cc5 100644 --- a/src/renderer/src/components/sidebar/workspace-delete-lineage.test.ts +++ b/src/renderer/src/components/sidebar/workspace-delete-lineage.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../../shared/execution-host' import type { Worktree, WorktreeLineage } from '../../../../shared/types' import { getWorkspaceDeleteLineage } from './workspace-delete-lineage' @@ -70,4 +71,68 @@ describe('getWorkspaceDeleteLineage', () => { expect(lineage.descendants).toEqual([]) expect(lineage.deleteAllTargets).toEqual([parent]) }) + + it('orders an exact inline-only legacy descendant before its parent', () => { + const parent = makeWorktree('parent', '/workspaces/parent') + const child = makeWorktree('child', '/workspaces/parent/child') + const inlineChild = { ...child, lineage: makeLineage(child, parent) } as Worktree + + const lineage = getWorkspaceDeleteLineage(parent, [parent, inlineChild], {}) + + expect(lineage.descendants.map((worktree) => worktree.id)).toEqual([child.id]) + expect(lineage.deleteAllTargets.map((worktree) => worktree.id)).toEqual([child.id, parent.id]) + }) + + it('keeps a stale side-map child authoritative over valid inline lineage', () => { + const parent = makeWorktree('parent', '/workspaces/parent') + const child = makeWorktree('child', '/workspaces/parent/child') + const inlineChild = { ...child, lineage: makeLineage(child, parent) } as Worktree + + const lineage = getWorkspaceDeleteLineage(parent, [parent, inlineChild], { + [child.id]: { + ...makeLineage(child, parent), + parentWorktreeInstanceId: 'stale-parent-instance' + } + }) + + expect(lineage.descendants).toEqual([]) + expect(lineage.deleteAllTargets).toEqual([parent]) + }) + + it('rejects cross-repo, cross-host, and cross-project descendants', () => { + const parent: Worktree = { + ...makeWorktree('parent', '/workspaces/parent'), + hostId: LOCAL_EXECUTION_HOST_ID, + projectId: 'project-1' + } + const children: Worktree[] = [ + { ...makeWorktree('repo-child', '/workspaces/repo-child'), repoId: 'repo-2' }, + { + ...makeWorktree('host-child', '/workspaces/host-child'), + hostId: toSshExecutionHostId('other') + }, + { ...makeWorktree('project-child', '/workspaces/project-child'), projectId: 'project-2' } + ] + const lineageById = Object.fromEntries( + children.map((child) => [child.id, makeLineage(child, parent)]) + ) + + const lineage = getWorkspaceDeleteLineage(parent, [parent, ...children], lineageById) + + expect(lineage.descendants).toEqual([]) + expect(lineage.deleteAllTargets).toEqual([parent]) + }) + + it('does not traverse cyclic projected lineage', () => { + const parent = makeWorktree('parent', '/workspaces/parent') + const child = makeWorktree('child', '/workspaces/parent/child') + + const lineage = getWorkspaceDeleteLineage(parent, [parent, child], { + [parent.id]: makeLineage(parent, child), + [child.id]: makeLineage(child, parent) + }) + + expect(lineage.descendants).toEqual([]) + expect(lineage.deleteAllTargets).toEqual([parent]) + }) }) diff --git a/src/renderer/src/components/sidebar/workspace-delete-lineage.ts b/src/renderer/src/components/sidebar/workspace-delete-lineage.ts index a65748e6f32..ca6c78c1b67 100644 --- a/src/renderer/src/components/sidebar/workspace-delete-lineage.ts +++ b/src/renderer/src/components/sidebar/workspace-delete-lineage.ts @@ -1,41 +1,21 @@ import type { Worktree, WorktreeLineage } from '../../../../shared/types' +import { getProjectedWorktreeLineageChildrenByParentId } from './worktree-lineage-projection' type WorkspaceDeleteLineage = { descendants: Worktree[] deleteAllTargets: Worktree[] } -function isValidLineageLink( - child: Worktree, - parent: Worktree | undefined, - lineage: WorktreeLineage | undefined -): parent is Worktree { - return Boolean( - lineage && - parent && - child.instanceId === lineage.worktreeInstanceId && - parent.instanceId === lineage.parentWorktreeInstanceId - ) -} - export function getWorkspaceDeleteLineage( parent: Worktree, worktrees: readonly Worktree[], lineageById: Record ): WorkspaceDeleteLineage { const worktreeById = new Map(worktrees.map((worktree) => [worktree.id, worktree])) - const childrenByParentId = new Map() - - for (const worktree of worktrees) { - const lineage = lineageById[worktree.id] - const lineageParent = lineage ? worktreeById.get(lineage.parentWorktreeId) : undefined - if (!isValidLineageLink(worktree, lineageParent, lineage)) { - continue - } - const children = childrenByParentId.get(lineageParent.id) ?? [] - children.push(worktree) - childrenByParentId.set(lineageParent.id, children) - } + const childrenByParentId = getProjectedWorktreeLineageChildrenByParentId( + lineageById, + worktreeById + ) const descendants: Worktree[] = [] const childFirstTargets: Worktree[] = [] diff --git a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts index 0f70912df1c..9241dd2cf11 100644 --- a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts +++ b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest' +import type { Worktree, WorktreeLineage } from '../../../../shared/types' +import { getCyclicProjectedWorktreeLineageIds } from './worktree-lineage-projection' import { getReorderedWorktreeIdsToUnnest, getWorktreeLineageDropTargetId, @@ -45,32 +47,121 @@ describe('getWorktreeLineageDropTargetId', () => { describe('getReorderedWorktreeIdsToUnnest', () => { it('clears parents only for directly dragged nested cards', () => { + const parent = makeWorktree('parent') + const child = makeWorktree('child') + const root = makeWorktree('root') + const grandchild = makeWorktree('grandchild') + const lineageById = { + [child.id]: makeLineage(child, parent), + [grandchild.id]: makeLineage(grandchild, child) + } + const worktreeMap = new Map([parent, child, root, grandchild].map((item) => [item.id, item])) + expect( getReorderedWorktreeIdsToUnnest({ draggedIds: ['child', 'child', 'root', 'grandchild'], sourceGroupIds: ['child', 'root', 'grandchild'], - lineageById: { - child: true, - grandchild: true - } + lineageById, + worktreeMap, + cyclicLineageIds: getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) }) ).toEqual(['child', 'grandchild']) }) it('does not clear selected nested cards outside the reordered source group', () => { + const parent = makeWorktree('parent') + const sourceChild = makeWorktree('source-child') + const otherChild = makeWorktree('other-child') + const lineageById = { + [sourceChild.id]: makeLineage(sourceChild, parent), + [otherChild.id]: makeLineage(otherChild, parent) + } + const worktreeMap = new Map([parent, sourceChild, otherChild].map((item) => [item.id, item])) + expect( getReorderedWorktreeIdsToUnnest({ draggedIds: ['source-child', 'other-child'], sourceGroupIds: ['source-child'], - lineageById: { - 'source-child': true, - 'other-child': true - } + lineageById, + worktreeMap, + cyclicLineageIds: getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) }) ).toEqual(['source-child']) }) + + it('clears an exact inline-only legacy parent', () => { + const parent = makeWorktree('parent') + const child = makeWorktree('child') + const inlineChild = { ...child, lineage: makeLineage(child, parent) } as Worktree + const worktreeMap = new Map([parent, inlineChild].map((item) => [item.id, item])) + + expect( + getReorderedWorktreeIdsToUnnest({ + draggedIds: [child.id], + sourceGroupIds: [child.id], + lineageById: {}, + worktreeMap, + cyclicLineageIds: getCyclicProjectedWorktreeLineageIds({}, worktreeMap) + }) + ).toEqual([child.id]) + }) + + it('does not fall back to inline lineage when the side-map has a stale child entry', () => { + const parent = makeWorktree('parent') + const child = makeWorktree('child') + const inlineChild = { ...child, lineage: makeLineage(child, parent) } as Worktree + const lineageById = { + [child.id]: { ...makeLineage(child, parent), parentWorktreeInstanceId: 'stale-parent' } + } + const worktreeMap = new Map([parent, inlineChild].map((item) => [item.id, item])) + + expect( + getReorderedWorktreeIdsToUnnest({ + draggedIds: [child.id], + sourceGroupIds: [child.id], + lineageById, + worktreeMap, + cyclicLineageIds: getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) + }) + ).toEqual([]) + }) }) +function makeWorktree(id: string): Worktree { + return { + id, + instanceId: `${id}-instance`, + repoId: 'repo-1', + path: `/worktrees/${id}`, + head: 'abc123', + branch: id, + isBare: false, + isMainWorktree: false, + displayName: id, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1 + } +} + +function makeLineage(child: Worktree, parent: Worktree): WorktreeLineage { + return { + worktreeId: child.id, + worktreeInstanceId: child.instanceId ?? '', + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId ?? '', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + } +} + function makeTarget(args: { worktreeId: string top: number diff --git a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts index dc011d33a77..f171ce5d2a6 100644 --- a/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts +++ b/src/renderer/src/components/sidebar/worktree-lineage-drag-drop.ts @@ -1,3 +1,6 @@ +import type { Worktree, WorktreeLineage } from '../../../../shared/types' +import { getLineageRenderInfo } from './worktree-lineage-projection' + const WORKTREE_CARD_CONTENT_TARGET_SELECTOR = '[data-worktree-card-hover-trigger]' const WORKTREE_DRAG_ROW_SELECTOR = '[data-worktree-drag-id]' @@ -52,13 +55,22 @@ export function getWorktreeLineageDropTargetId(args: { export function getReorderedWorktreeIdsToUnnest(args: { draggedIds: readonly string[] sourceGroupIds: readonly string[] - lineageById: Readonly> + lineageById: Readonly> + worktreeMap: ReadonlyMap + cyclicLineageIds: ReadonlySet }): string[] { const ids: string[] = [] const seen = new Set() const sourceGroupIdSet = new Set(args.sourceGroupIds) for (const id of args.draggedIds) { - if (seen.has(id) || !sourceGroupIdSet.has(id) || !args.lineageById[id]) { + const worktree = args.worktreeMap.get(id) + if ( + seen.has(id) || + !sourceGroupIdSet.has(id) || + !worktree || + getLineageRenderInfo(worktree, args.lineageById, args.worktreeMap, args.cyclicLineageIds) + .state !== 'valid' + ) { continue } seen.add(id) diff --git a/src/renderer/src/components/sidebar/worktree-lineage-projection.ts b/src/renderer/src/components/sidebar/worktree-lineage-projection.ts new file mode 100644 index 00000000000..38c8584c064 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-lineage-projection.ts @@ -0,0 +1,100 @@ +import { + getCyclicWorktreeLineageChildIds, + isValidResolvedWorktreeLineageEdge +} from '../../../../shared/resolved-worktree-lineage' +import type { Worktree, WorktreeLineage } from '../../../../shared/types' + +export type LineageRenderInfo = + | { state: 'none' } + | { state: 'valid'; lineage: WorktreeLineage; parent: Worktree } + | { state: 'missing'; lineage: WorktreeLineage } + +type WorktreeWithResolvedLineage = Worktree & { lineage?: WorktreeLineage | null } + +export function getProjectedWorktreeLineage( + worktree: Worktree, + lineageById: Readonly> +): WorktreeLineage | null | undefined { + if (Object.prototype.hasOwnProperty.call(lineageById, worktree.id)) { + return lineageById[worktree.id] + } + return (worktree as WorktreeWithResolvedLineage).lineage +} + +export function getCyclicProjectedWorktreeLineageIds( + lineageById: Readonly>, + worktreeMap: ReadonlyMap +): Set { + const validLineageByChildId = new Map() + for (const worktree of worktreeMap.values()) { + const lineage = getProjectedWorktreeLineage(worktree, lineageById) + if (!lineage) { + continue + } + const parent = worktreeMap.get(lineage.parentWorktreeId) + if (parent && isValidResolvedWorktreeLineageEdge(worktree, parent, lineage)) { + validLineageByChildId.set(worktree.id, lineage) + } + } + return getCyclicWorktreeLineageChildIds(validLineageByChildId) +} + +export function getLineageRenderInfo( + worktree: Worktree, + lineageById: Readonly>, + worktreeMap: ReadonlyMap, + cyclicLineageIds: ReadonlySet +): LineageRenderInfo { + const lineage = getProjectedWorktreeLineage(worktree, lineageById) + if (!lineage) { + return { state: 'none' } + } + const parent = worktreeMap.get(lineage.parentWorktreeId) + if ( + cyclicLineageIds.has(worktree.id) || + !parent || + !isValidResolvedWorktreeLineageEdge(worktree, parent, lineage) + ) { + return { state: 'missing', lineage } + } + return { state: 'valid', lineage, parent } +} + +export function getProjectedWorktreeLineageChildrenByParentId( + lineageById: Readonly>, + worktreeMap: ReadonlyMap +): Map { + const cyclicLineageIds = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) + const childrenByParentId = new Map() + for (const worktree of worktreeMap.values()) { + const lineage = getLineageRenderInfo(worktree, lineageById, worktreeMap, cyclicLineageIds) + if (lineage.state !== 'valid') { + continue + } + const children = childrenByParentId.get(lineage.parent.id) ?? [] + children.push(worktree) + childrenByParentId.set(lineage.parent.id, children) + } + return childrenByParentId +} + +export function getWorktreeLineageAncestors( + worktree: Worktree, + lineageById: Readonly>, + worktreeMap: ReadonlyMap +): Worktree[] { + const cyclicLineageIds = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) + const ancestors: Worktree[] = [] + const seen = new Set() + let current: Worktree | undefined = worktree + while (current && !seen.has(current.id)) { + seen.add(current.id) + const lineage = getLineageRenderInfo(current, lineageById, worktreeMap, cyclicLineageIds) + if (lineage.state !== 'valid') { + break + } + ancestors.push(lineage.parent) + current = lineage.parent + } + return ancestors +} diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index 6261443c3e6..c3405400dd2 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -19,6 +19,7 @@ import { REPO_HEADER_ACTION_BUTTON_CLASS, REPO_HEADER_ACTION_REVEAL_CLASS } from './repo-header-action-button-class' +import { getWorktreeLineageAncestors } from './worktree-lineage-projection' import type { DetectedWorktree, Project, @@ -3309,6 +3310,12 @@ describe('project groups', () => { }) describe('buildRows workspace lineage nesting', () => { + type ResolvedLineageWorktree = Worktree & { + lineage: WorktreeLineage | null + workspaceLineage?: null + parentWorktreeId?: string | null + } + const parent: Worktree = { ...worktree, id: 'wt-parent', @@ -3399,6 +3406,255 @@ describe('buildRows workspace lineage nesting', () => { }) }) + it('nests stable-update resolved legacy lineage when generalized lineage is absent', () => { + const parentId = + '32a0226d-9f33-42e8-8b7b-24867dea06d4::/Users/jinwoo/orca/workspaces/orca/assigned-issues' + const childId = + '32a0226d-9f33-42e8-8b7b-24867dea06d4::/Users/jinwoo/orca/workspaces/orca/issue-9276-nested-ssh-runtime-routing' + const secondChildId = + '32a0226d-9f33-42e8-8b7b-24867dea06d4::/Users/jinwoo/orca/workspaces/orca/issue-9744-terminal-close-lifecycle' + const resolvedParent: ResolvedLineageWorktree = { + ...parent, + id: parentId, + instanceId: 'b0ffd635-91cd-424f-b804-80d4bb277a4c', + lineage: null, + workspaceLineage: null + } + const resolvedLineage: WorktreeLineage = { + ...lineage, + worktreeId: childId, + worktreeInstanceId: '1ceb9823-aa98-4f79-8eaa-af0b3a3d551b', + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'b0ffd635-91cd-424f-b804-80d4bb277a4c', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' } + } + const resolvedChild: ResolvedLineageWorktree = { + ...child, + id: childId, + instanceId: '1ceb9823-aa98-4f79-8eaa-af0b3a3d551b', + lineage: resolvedLineage, + workspaceLineage: null + } + const secondResolvedLineage: WorktreeLineage = { + ...resolvedLineage, + worktreeId: secondChildId, + worktreeInstanceId: '87e2ef9a-99d3-48e3-9a53-3d1a979b5417' + } + const secondResolvedChild: ResolvedLineageWorktree = { + ...child, + id: secondChildId, + instanceId: '87e2ef9a-99d3-48e3-9a53-3d1a979b5417', + lineage: secondResolvedLineage, + workspaceLineage: null + } + + const rows = buildRows( + 'none', + [secondResolvedChild, resolvedChild, resolvedParent], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [resolvedParent.id, resolvedParent], + [resolvedChild.id, resolvedChild], + [secondResolvedChild.id, secondResolvedChild] + ]), + true + ) + + const items = rows.filter((row) => row.type === 'item') + expect(items.map((row) => [row.worktree.id, row.depth])).toEqual([ + [parentId, 0], + [secondChildId, 1], + [childId, 1] + ]) + expect(items[0]).toMatchObject({ lineageChildCount: 2, lineageCollapsed: false }) + }) + + it('rejects stale resolved lineage after a parent instance is replaced', () => { + const resolvedChild: ResolvedLineageWorktree = { + ...child, + lineage: { ...lineage, parentWorktreeInstanceId: 'replaced-parent-instance' } + } + const rows = buildRows( + 'none', + [resolvedChild, parent], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [parent.id, parent], + [resolvedChild.id, resolvedChild] + ]), + true + ) + + expect(rows.filter((row) => row.type === 'item').map((row) => row.depth)).toEqual([0, 0]) + }) + + it('keeps mixed cyclic lineage participants visible as roots', () => { + const parentLineage: WorktreeLineage = { + ...lineage, + worktreeId: parent.id, + worktreeInstanceId: parent.instanceId!, + parentWorktreeId: child.id, + parentWorktreeInstanceId: child.instanceId! + } + const rows = buildRows( + 'none', + [grandchild, child, parent], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + { [child.id]: lineage, [parent.id]: parentLineage }, + new Map([ + [parent.id, parent], + [child.id, child], + [grandchild.id, grandchild] + ]), + true + ) + + expect( + rows.filter((row) => row.type === 'item').map((row) => [row.worktree.id, row.depth]) + ).toEqual([ + [grandchild.id, 0], + [child.id, 0], + [parent.id, 0] + ]) + }) + + it('resolves inline-only ancestor chains for reveal and temporary picker expansion', () => { + const resolvedChild: ResolvedLineageWorktree = { ...child, lineage } + const resolvedGrandchild: ResolvedLineageWorktree = { + ...grandchild, + lineage: grandchildLineage + } + const worktreeMap = new Map([ + [parent.id, parent], + [resolvedChild.id, resolvedChild], + [resolvedGrandchild.id, resolvedGrandchild] + ]) + + expect( + getWorktreeLineageAncestors(resolvedGrandchild, {}, worktreeMap).map( + (worktree) => worktree.id + ) + ).toEqual([child.id, parent.id]) + }) + + it('keeps a resolved child at the root when its parent is missing', () => { + const resolvedChild: ResolvedLineageWorktree = { ...child, lineage } + const rows = buildRows( + 'none', + [resolvedChild], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([[child.id, resolvedChild]]), + true + ) + + expect(rows.find((row) => row.type === 'item')).toMatchObject({ depth: 0 }) + }) + + it.each([ + ['repo', { repoId: 'other-repo' }], + ['host', { hostId: 'ssh:other-host' as const }], + ['project', { projectId: 'github:other/project' }] + ])('does not nest resolved lineage across a known %s boundary', (_label, boundary) => { + const boundedParent = { + ...parent, + repoId: 'repo-1', + hostId: 'local' as const, + projectId: 'github:stablyai/orca', + ...boundary + } + const boundedChild: ResolvedLineageWorktree = { + ...child, + repoId: 'repo-1', + hostId: 'local' as const, + projectId: 'github:stablyai/orca', + lineage + } + const rows = buildRows( + 'none', + [boundedChild, boundedParent], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([ + [boundedParent.id, boundedParent], + [boundedChild.id, boundedChild] + ]), + true + ) + + expect(rows.filter((row) => row.type === 'item').map((row) => row.depth)).toEqual([0, 0]) + }) + + it('keeps the hydrated lineage side-map authoritative when inline metadata disagrees', () => { + const otherParent = { + ...parent, + id: 'wt-other-parent', + instanceId: 'other-parent-instance' + } + const hydratedLineage = { + ...lineage, + parentWorktreeId: otherParent.id, + parentWorktreeInstanceId: otherParent.instanceId! + } + const resolvedChild: ResolvedLineageWorktree = { + ...child, + parentWorktreeId: parent.id, + lineage + } + const rows = buildRows( + 'none', + [resolvedChild, parent, otherParent], + repoMap, + null, + new Set(), + undefined, + undefined, + undefined, + { [child.id]: hydratedLineage }, + new Map([ + [parent.id, parent], + [otherParent.id, otherParent], + [child.id, resolvedChild] + ]), + true + ) + + expect( + rows.filter((row) => row.type === 'item').map((row) => [row.worktree.id, row.depth]) + ).toEqual([ + [parent.id, 0], + [otherParent.id, 0], + [child.id, 1] + ]) + }) + it('supports nested lineage chains beyond one level', () => { const rows = buildRows( 'none', @@ -3504,7 +3760,8 @@ describe('buildRows workspace lineage nesting', () => { new Map([ [parent.id, parent], [child.id, child] - ]) + ]), + new Set() ) expect(info).toMatchObject({ state: 'missing' }) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 41fcdaf9c3b..cc637d61865 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -43,6 +43,12 @@ import { } from '../../../../shared/execution-host' import { parseWslUncPath } from '../../../../shared/wsl-paths' import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' +import { + getCyclicProjectedWorktreeLineageIds, + getLineageRenderInfo +} from './worktree-lineage-projection' + +export { getLineageRenderInfo } from './worktree-lineage-projection' export { branchName } @@ -373,30 +379,6 @@ export function getLineageGroupKey(worktreeId: string): string { return `${LINEAGE_GROUP_PREFIX}${worktreeId}` } -export type LineageRenderInfo = - | { state: 'none' } - | { state: 'valid'; lineage: WorktreeLineage; parent: Worktree } - | { state: 'missing'; lineage: WorktreeLineage } - -export function getLineageRenderInfo( - worktree: Worktree, - lineageById: Record, - worktreeMap: Map -): LineageRenderInfo { - const lineage = lineageById[worktree.id] - if (!lineage) { - return { state: 'none' } - } - const parent = worktreeMap.get(lineage.parentWorktreeId) - if ( - !parent || - worktree.instanceId !== lineage.worktreeInstanceId || - parent.instanceId !== lineage.parentWorktreeInstanceId - ) { - return { state: 'missing', lineage } - } - return { state: 'valid', lineage, parent } -} export function getPRGroupKey( worktree: Worktree, repoMap: Map, @@ -603,9 +585,17 @@ function appendWorktreeRows( groupDepth: number sectionKey: string hostContextLabelByRepoId?: ReadonlyMap + cyclicLineageIds: ReadonlySet } ): void { - const { nestLineage, collapsedGroups, groupDepth, sectionKey, hostContextLabelByRepoId } = options + const { + nestLineage, + collapsedGroups, + groupDepth, + sectionKey, + hostContextLabelByRepoId, + cyclicLineageIds + } = options if (!nestLineage) { for (const worktree of worktrees) { result.push( @@ -629,7 +619,7 @@ function appendWorktreeRows( const childrenByParentId = new Map() const childIds = new Set() for (const worktree of worktrees) { - const lineage = getLineageRenderInfo(worktree, lineageById, worktreeMap) + const lineage = getLineageRenderInfo(worktree, lineageById, worktreeMap, cyclicLineageIds) if (lineage.state !== 'valid' || !visibleIds.has(lineage.parent.id)) { continue } @@ -999,6 +989,9 @@ export function buildRows( ): Row[] { const result: Row[] = [] const projectIndex = buildProjectGroupingIndex(projectGrouping) + const cyclicLineageIds = nestLineage + ? getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) + : new Set() const pendingByRepo = new Map() for (const creation of pendingCreations) { @@ -1058,7 +1051,8 @@ export function buildRows( nestLineage, collapsedGroups, groupDepth: 0, - sectionKey: ALL_GROUP_KEY + sectionKey: ALL_GROUP_KEY, + cyclicLineageIds }) } } @@ -1302,7 +1296,8 @@ export function buildRows( collapsedGroups, groupDepth: projectGroupDepth, sectionKey: key, - hostContextLabelByRepoId + hostContextLabelByRepoId, + cyclicLineageIds }) } else { appendWorktreeRows(result, items, repoMap, lineageById, worktreeMap, { @@ -1310,7 +1305,8 @@ export function buildRows( collapsedGroups, groupDepth: projectGroupDepth, sectionKey: key, - hostContextLabelByRepoId + hostContextLabelByRepoId, + cyclicLineageIds }) } } diff --git a/src/renderer/src/components/sidebar/worktree-parent-candidates.ts b/src/renderer/src/components/sidebar/worktree-parent-candidates.ts index 6509743ba09..981ea7e3a7d 100644 --- a/src/renderer/src/components/sidebar/worktree-parent-candidates.ts +++ b/src/renderer/src/components/sidebar/worktree-parent-candidates.ts @@ -1,6 +1,7 @@ import { getWorktreeExecutionHostId } from '../../../../shared/execution-host' import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types' import { canAssignWorktreeParent } from './worktree-parent-eligibility' +import { getCyclicProjectedWorktreeLineageIds } from './worktree-lineage-projection' type ParentCandidateArgs = { child: Worktree @@ -8,6 +9,7 @@ type ParentCandidateArgs = { lineageById: Record worktreeMap: Map repoMap: Map> + cyclicLineageIds?: ReadonlySet } function getWorktreeOwnerHostId( @@ -23,20 +25,51 @@ export function getEligibleWorktreeParents({ worktrees, lineageById, worktreeMap, - repoMap + repoMap, + cyclicLineageIds: precomputedCyclicLineageIds }: ParentCandidateArgs): Worktree[] { const childHostId = getWorktreeOwnerHostId(child, repoMap) - return worktrees.filter( - (candidate) => - candidate.repoId === child.repoId && - childHostId !== null && - getWorktreeOwnerHostId(candidate, repoMap) === childHostId && - !candidate.isArchived && - canAssignWorktreeParent({ - child, - candidateParent: candidate, - lineageById, - worktreeMap - }) + const cyclicLineageIds = + precomputedCyclicLineageIds ?? getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) + return worktrees.filter((candidate) => + isEligibleWorktreeParent({ + child, + candidateParent: candidate, + lineageById, + worktreeMap, + repoMap, + cyclicLineageIds, + childHostId + }) + ) +} + +export function isEligibleWorktreeParent({ + child, + candidateParent, + lineageById, + worktreeMap, + repoMap, + cyclicLineageIds, + childHostId = getWorktreeOwnerHostId(child, repoMap) +}: Omit & { + candidateParent: Worktree + childHostId?: string | null +}): boolean { + return ( + candidateParent.repoId === child.repoId && + childHostId !== null && + getWorktreeOwnerHostId(candidateParent, repoMap) === childHostId && + (child.projectId === undefined || + candidateParent.projectId === undefined || + child.projectId === candidateParent.projectId) && + !candidateParent.isArchived && + canAssignWorktreeParent({ + child, + candidateParent, + lineageById, + worktreeMap, + cyclicLineageIds + }) ) } diff --git a/src/renderer/src/components/sidebar/worktree-parent-eligibility.test.ts b/src/renderer/src/components/sidebar/worktree-parent-eligibility.test.ts index 12339cf61be..7dfe0360f02 100644 --- a/src/renderer/src/components/sidebar/worktree-parent-eligibility.test.ts +++ b/src/renderer/src/components/sidebar/worktree-parent-eligibility.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { join } from 'node:path' import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types' import { canAssignWorktreeParent } from './worktree-parent-eligibility' -import { getEligibleWorktreeParents } from './worktree-parent-candidates' +import { getEligibleWorktreeParents, isEligibleWorktreeParent } from './worktree-parent-candidates' function makeWorktree(id: string, repoId = 'repo'): Worktree { return { @@ -209,6 +209,34 @@ describe('canAssignWorktreeParent', () => { ).toEqual([sameHost.id]) }) + it('excludes a candidate across a known project boundary for picker and direct drop checks', () => { + const child = { ...makeWorktree('child'), projectId: 'project-a' } + const sameProject = { ...makeWorktree('same-project'), projectId: 'project-a' } + const otherProject = { ...makeWorktree('other-project'), projectId: 'project-b' } + const worktrees = [child, sameProject, otherProject] + const worktreeMap = makeMap(worktrees) + const repoMap = makeRepoMap() + + expect( + getEligibleWorktreeParents({ + child, + worktrees, + lineageById: {}, + worktreeMap, + repoMap + }).map((worktree) => worktree.id) + ).toEqual([sameProject.id]) + expect( + isEligibleWorktreeParent({ + child, + candidateParent: otherProject, + lineageById: {}, + worktreeMap, + repoMap + }) + ).toBe(false) + }) + it('excludes archived worktrees from picker candidates', () => { const child = makeWorktree('child') const archived = makeWorktree('archived') diff --git a/src/renderer/src/components/sidebar/worktree-parent-eligibility.ts b/src/renderer/src/components/sidebar/worktree-parent-eligibility.ts index 07575aee9fd..fe2c4261064 100644 --- a/src/renderer/src/components/sidebar/worktree-parent-eligibility.ts +++ b/src/renderer/src/components/sidebar/worktree-parent-eligibility.ts @@ -1,24 +1,31 @@ import type { Worktree, WorktreeLineage } from '../../../../shared/types' -import { getLineageRenderInfo } from './worktree-list-groups' +import { + getCyclicProjectedWorktreeLineageIds, + getLineageRenderInfo +} from './worktree-lineage-projection' type ParentEligibilityArgs = { child: Worktree candidateParent: Worktree lineageById: Record worktreeMap: Map + cyclicLineageIds?: ReadonlySet } export function canAssignWorktreeParent({ child, candidateParent, lineageById, - worktreeMap + worktreeMap, + cyclicLineageIds: precomputedCyclicLineageIds }: ParentEligibilityArgs): boolean { if (child.id === candidateParent.id) { return false } - const childLineage = getLineageRenderInfo(child, lineageById, worktreeMap) + const cyclicLineageIds = + precomputedCyclicLineageIds ?? getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) + const childLineage = getLineageRenderInfo(child, lineageById, worktreeMap, cyclicLineageIds) if (childLineage.state === 'valid' && childLineage.parent.id === candidateParent.id) { return false } @@ -26,14 +33,14 @@ export function canAssignWorktreeParent({ let current: Worktree | undefined = candidateParent const visited = new Set() while (current) { - if (visited.has(current.id)) { + if (visited.has(current.id) || cyclicLineageIds.has(current.id)) { return false } visited.add(current.id) if (current.id === child.id) { return false } - const lineageInfo = getLineageRenderInfo(current, lineageById, worktreeMap) + const lineageInfo = getLineageRenderInfo(current, lineageById, worktreeMap, cyclicLineageIds) // Why: stale instance links are broken edges for renderer filtering; the // backend remains the authoritative final cycle guard. current = lineageInfo.state === 'valid' ? lineageInfo.parent : undefined diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 2bc6fa59b7d..57be252a49f 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -1668,6 +1668,42 @@ describe('worktree lineage state', () => { expect(store.getState().sortEpoch).toBe(4) }) + it('clears inline local lineage immediately when an inline-only child is unnested', async () => { + const store = createTestStore() + const lineage = makeLineage() + const parent = { + ...makeWorktree({ + id: lineage.parentWorktreeId, + instanceId: lineage.parentWorktreeInstanceId, + repoId: 'repo1' + }), + childWorktreeIds: [lineage.worktreeId], + lineage: null + } + const child = { + ...makeWorktree({ + id: lineage.worktreeId, + instanceId: lineage.worktreeInstanceId, + repoId: 'repo1' + }), + parentWorktreeId: lineage.parentWorktreeId, + childWorktreeIds: [], + lineage + } + mockApi.worktrees.updateLineage.mockResolvedValue(null) + store.setState({ + worktreesByRepo: { repo1: [parent, child] }, + worktreeLineageById: {} + } as Partial) + + await store.getState().updateWorktreeLineage(child.id, { noParent: true }) + + expect(store.getState().worktreesByRepo.repo1).toMatchObject([ + { id: parent.id, childWorktreeIds: [] }, + { id: child.id, parentWorktreeId: null, lineage: null } + ]) + }) + it('syncs workspace lineage when a child is manually reparented', async () => { const store = createTestStore() const lineage = makeLineage({ @@ -5680,6 +5716,64 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { addedAt: 0 } + it('preserves resolved inline legacy lineage when side-map hydration is absent', async () => { + const store = createTestStore() + const parent = makeWorktree({ + id: 'repoA::/a/parent', + instanceId: 'parent-instance', + repoId: 'repoA', + path: '/a/parent' + }) + const child = makeWorktree({ + id: 'repoA::/a/child', + instanceId: 'child-instance', + repoId: 'repoA', + path: '/a/child' + }) + const lineage = makeLineage({ + worktreeId: child.id, + worktreeInstanceId: child.instanceId!, + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId! + }) + const resolvedParent = { + ...parent, + parentWorktreeId: null, + childWorktreeIds: [child.id], + lineage: null, + workspaceLineage: null + } + const resolvedChild = { + ...child, + parentWorktreeId: parent.id, + childWorktreeIds: [], + lineage, + workspaceLineage: null + } + mockApi.worktrees.listDetected.mockResolvedValueOnce( + makeDetectedResult('repoA', [resolvedParent, resolvedChild]) + ) + store.setState({ + repos: [repoA], + hasHydratedWorktreePurge: true, + worktreeLineageById: {} + } as Partial) + + await store.getState().fetchAllWorktrees() + + expect(store.getState().worktreeLineageById).toEqual({}) + expect(store.getState().worktreesByRepo.repoA).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: child.id, + parentWorktreeId: parent.id, + lineage, + workspaceLineage: null + }) + ]) + ) + }) + it('defers the purge when a sibling repo fetch fails (F1 regression)', async () => { const store = createTestStore() const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' }) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 31b3542e1ae..689fd546282 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1062,6 +1062,51 @@ async function setWorktreeLineageForRuntime( } } +function projectLocalWorktreeLineageUpdate( + worktreesByRepo: Record, + worktreeId: string, + lineage: WorktreeLineage | null +): Record { + let nextByRepo = worktreesByRepo + for (const [repoId, worktrees] of Object.entries(worktreesByRepo)) { + let repoChanged = false + const projected = worktrees.map((worktree) => { + const current = worktree as WorktreeWithLineage + const hadChild = current.childWorktreeIds?.includes(worktreeId) ?? false + const isParent = + lineage?.parentWorktreeId === worktree.id && + lineage.parentWorktreeInstanceId === worktree.instanceId + let childWorktreeIds = current.childWorktreeIds + if (hadChild) { + childWorktreeIds = childWorktreeIds?.filter((id) => id !== worktreeId) + } + if (isParent && !childWorktreeIds?.includes(worktreeId)) { + childWorktreeIds = [...(childWorktreeIds ?? []), worktreeId] + } + if (worktree.id === worktreeId) { + repoChanged = true + return { + ...worktree, + parentWorktreeId: lineage?.parentWorktreeId ?? null, + lineage + } + } + if (hadChild || isParent) { + repoChanged = true + return { ...worktree, childWorktreeIds } + } + return worktree + }) + if (repoChanged) { + if (nextByRepo === worktreesByRepo) { + nextByRepo = { ...worktreesByRepo } + } + nextByRepo[repoId] = projected + } + } + return nextByRepo +} + function applyWorktreeLineageUpdate( set: Parameters>[0], worktreeId: string, @@ -1074,6 +1119,18 @@ function applyWorktreeLineageUpdate( } else { delete next[worktreeId] } + const worktreesByRepo = + result.target.kind === 'local' + ? projectLocalWorktreeLineageUpdate(s.worktreesByRepo, worktreeId, result.lineage) + : result.updatedRemoteWorktree + ? replaceWorktreeInRepoLists( + s.worktreesByRepo, + withRepoHostOwnership( + result.updatedRemoteWorktree, + repoHostId(s, getRepoIdFromWorktreeId(result.updatedRemoteWorktree.id)) + ) + ) + : s.worktreesByRepo return { worktreeLineageById: next, workspaceLineageByChildKey: projectWorktreeLineageToWorkspaceLineage( @@ -1081,16 +1138,7 @@ function applyWorktreeLineageUpdate( result.lineage, s.workspaceLineageByChildKey ), - worktreesByRepo: - result.target.kind === 'local' || !result.updatedRemoteWorktree - ? s.worktreesByRepo - : replaceWorktreeInRepoLists( - s.worktreesByRepo, - withRepoHostOwnership( - result.updatedRemoteWorktree, - repoHostId(s, getRepoIdFromWorktreeId(result.updatedRemoteWorktree.id)) - ) - ), + worktreesByRepo, sortEpoch: s.sortEpoch + 1 } }) diff --git a/src/shared/resolved-worktree-lineage.test.ts b/src/shared/resolved-worktree-lineage.test.ts new file mode 100644 index 00000000000..fc42c1ed608 --- /dev/null +++ b/src/shared/resolved-worktree-lineage.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest' +import { join } from 'node:path' +import type { Worktree, WorktreeLineage } from './types' +import { projectResolvedWorktreeLineage } from './resolved-worktree-lineage' + +function worktree(id: string, instanceId: string, overrides: Partial = {}): Worktree { + return { + id, + instanceId, + repoId: 'repo', + path: join('workspace', id), + head: 'abc123', + branch: `refs/heads/${id}`, + isBare: false, + isMainWorktree: false, + displayName: id, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +function lineage(overrides: Partial = {}): WorktreeLineage { + return { + worktreeId: 'child', + worktreeInstanceId: 'child-instance', + parentWorktreeId: 'parent', + parentWorktreeInstanceId: 'parent-instance', + origin: 'cli', + capture: { source: 'explicit-cli-flag', confidence: 'explicit' }, + createdAt: 1, + ...overrides + } +} + +describe('projectResolvedWorktreeLineage', () => { + const parent = worktree('parent', 'parent-instance') + const child = worktree('child', 'child-instance') + + it('projects exact instance-aware parent and child metadata', () => { + const projected = projectResolvedWorktreeLineage([child, parent], { child: lineage() }) + + expect(projected).toMatchObject([ + { id: 'child', parentWorktreeId: 'parent', childWorktreeIds: [], lineage: lineage() }, + { id: 'parent', parentWorktreeId: null, childWorktreeIds: ['child'], lineage: null } + ]) + }) + + it.each([ + ['stale child instance', lineage({ worktreeInstanceId: 'old-child' })], + ['stale parent instance', lineage({ parentWorktreeInstanceId: 'old-parent' })], + ['mismatched child record', lineage({ worktreeId: 'other-child' })] + ])('rejects %s', (_label, candidate) => { + const projected = projectResolvedWorktreeLineage([child, parent], { child: candidate }) + + expect(projected).toMatchObject([ + { id: 'child', parentWorktreeId: null, lineage: null }, + { id: 'parent', childWorktreeIds: [] } + ]) + }) + + it.each([ + ['repo', { repoId: 'other-repo' }, {}], + ['known host', { hostId: 'local' as const }, { hostId: 'ssh:remote' as const }], + ['known project', { projectId: 'github:stablyai/orca' }, { projectId: 'github:other/project' }] + ])('rejects a %s boundary mismatch', (_label, childOverrides, parentOverrides) => { + const boundedChild = worktree('child', 'child-instance', childOverrides) + const boundedParent = worktree('parent', 'parent-instance', parentOverrides) + + const projected = projectResolvedWorktreeLineage([boundedChild, boundedParent], { + child: lineage() + }) + + expect(projected).toMatchObject([ + { id: 'child', parentWorktreeId: null, lineage: null }, + { id: 'parent', childWorktreeIds: [] } + ]) + }) + + it('accepts legacy records when only one side has host or project identity', () => { + const legacyChild = worktree('child', 'child-instance', { + hostId: 'local', + projectId: 'github:stablyai/orca' + }) + + const projected = projectResolvedWorktreeLineage([legacyChild, parent], { + child: lineage() + }) + + expect(projected).toMatchObject([ + { id: 'child', parentWorktreeId: 'parent', lineage: lineage() }, + { id: 'parent', childWorktreeIds: ['child'] } + ]) + }) + + it('rejects self-parent lineage', () => { + const projected = projectResolvedWorktreeLineage([child], { + child: lineage({ + parentWorktreeId: child.id, + parentWorktreeInstanceId: child.instanceId! + }) + }) + + expect(projected[0]).toMatchObject({ + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null + }) + }) + + it('rejects every edge in a multi-node cycle without hiding valid descendants', () => { + const grandchild = worktree('grandchild', 'grandchild-instance') + const parentToChild = lineage({ + worktreeId: parent.id, + worktreeInstanceId: parent.instanceId!, + parentWorktreeId: child.id, + parentWorktreeInstanceId: child.instanceId! + }) + const grandchildToParent = lineage({ + worktreeId: grandchild.id, + worktreeInstanceId: grandchild.instanceId! + }) + + const projected = projectResolvedWorktreeLineage([child, parent, grandchild], { + child: lineage(), + parent: parentToChild, + grandchild: grandchildToParent + }) + + expect(projected).toMatchObject([ + { id: 'child', parentWorktreeId: null, childWorktreeIds: [], lineage: null }, + { + id: 'parent', + parentWorktreeId: null, + childWorktreeIds: ['grandchild'], + lineage: null + }, + { id: 'grandchild', parentWorktreeId: 'parent', lineage: grandchildToParent } + ]) + }) + + it('rejects a missing parent without mutating the raw lineage record', () => { + const rawLineage = lineage() + const projected = projectResolvedWorktreeLineage([child], { child: rawLineage }) + + expect(projected[0]).toMatchObject({ parentWorktreeId: null, lineage: null }) + expect(rawLineage.parentWorktreeId).toBe('parent') + }) + + it('replaces disagreeing parent and child projections from the validated lineage record', () => { + const projected = projectResolvedWorktreeLineage( + [ + { ...child, parentWorktreeId: 'stale-parent', childWorktreeIds: ['stale-child'] }, + { ...parent, parentWorktreeId: 'stale-parent', childWorktreeIds: [] } + ] as (Worktree & { parentWorktreeId: string; childWorktreeIds: string[] })[], + { child: lineage() } + ) + + expect(projected).toMatchObject([ + { id: 'child', parentWorktreeId: 'parent', childWorktreeIds: [] }, + { id: 'parent', parentWorktreeId: null, childWorktreeIds: ['child'] } + ]) + }) +}) diff --git a/src/shared/resolved-worktree-lineage.ts b/src/shared/resolved-worktree-lineage.ts new file mode 100644 index 00000000000..4967d8450b4 --- /dev/null +++ b/src/shared/resolved-worktree-lineage.ts @@ -0,0 +1,108 @@ +import type { Worktree, WorktreeLineage } from './types' + +export type WorktreeWithResolvedLineage = T & { + parentWorktreeId: string | null + childWorktreeIds: string[] + lineage: WorktreeLineage | null +} + +export function sharesResolvedWorktreeLineageBoundary(child: Worktree, parent: Worktree): boolean { + return ( + child.repoId === parent.repoId && + (child.hostId === undefined || parent.hostId === undefined || child.hostId === parent.hostId) && + (child.projectId === undefined || + parent.projectId === undefined || + child.projectId === parent.projectId) + ) +} + +export function isValidResolvedWorktreeLineageEdge( + child: Worktree, + parent: Worktree, + lineage: WorktreeLineage +): boolean { + return ( + child.id !== parent.id && + lineage.worktreeId === child.id && + lineage.parentWorktreeId === parent.id && + sharesResolvedWorktreeLineageBoundary(child, parent) && + child.instanceId === lineage.worktreeInstanceId && + parent.instanceId === lineage.parentWorktreeInstanceId + ) +} + +export function getCyclicWorktreeLineageChildIds( + lineageByChildId: ReadonlyMap +): Set { + const processed = new Set() + const cyclic = new Set() + + for (const childId of lineageByChildId.keys()) { + if (processed.has(childId)) { + continue + } + const path: string[] = [] + const pathIndexById = new Map() + let currentId: string | undefined = childId + while (currentId && lineageByChildId.has(currentId) && !processed.has(currentId)) { + const cycleStart = pathIndexById.get(currentId) + if (cycleStart !== undefined) { + for (let index = cycleStart; index < path.length; index += 1) { + cyclic.add(path[index]) + } + break + } + pathIndexById.set(currentId, path.length) + path.push(currentId) + currentId = lineageByChildId.get(currentId)?.parentWorktreeId + } + for (const id of path) { + processed.add(id) + } + } + + return cyclic +} + +export function projectResolvedWorktreeLineage( + worktrees: readonly T[], + lineageById: Readonly> +): WorktreeWithResolvedLineage[] { + const worktreeById = new Map(worktrees.map((worktree) => [worktree.id, worktree])) + const validLineageByChildId = new Map() + const childIdsByParentId = new Map() + + for (const child of worktrees) { + const childId = child.id + const lineage = lineageById[childId] + if (!lineage) { + continue + } + const parent = worktreeById.get(lineage.parentWorktreeId) + if (!parent || !isValidResolvedWorktreeLineageEdge(child, parent, lineage)) { + continue + } + validLineageByChildId.set(childId, lineage) + } + + const cyclicChildIds = getCyclicWorktreeLineageChildIds(validLineageByChildId) + for (const childId of cyclicChildIds) { + validLineageByChildId.delete(childId) + } + + for (const [childId, lineage] of validLineageByChildId) { + const children = childIdsByParentId.get(lineage.parentWorktreeId) ?? [] + children.push(childId) + childIdsByParentId.set(lineage.parentWorktreeId, children) + } + + return worktrees.map((worktree) => { + const lineage = validLineageByChildId.get(worktree.id) ?? null + return { + ...worktree, + parentWorktreeId: lineage?.parentWorktreeId ?? null, + childWorktreeIds: childIdsByParentId.get(worktree.id) ?? [], + lineage + } + }) +} diff --git a/tests/e2e/worktree-lineage-state.ts b/tests/e2e/worktree-lineage-state.ts index 81082cddfc8..928eba66964 100644 --- a/tests/e2e/worktree-lineage-state.ts +++ b/tests/e2e/worktree-lineage-state.ts @@ -5,8 +5,11 @@ export type LineageScenario = { childId: string } -export async function seedLineageScenario(page: Page): Promise { - return page.evaluate(() => { +export async function seedLineageScenario( + page: Page, + options: { inlineOnly?: boolean } = {} +): Promise { + return page.evaluate(({ inlineOnly }) => { const store = window.__store if (!store) { throw new Error('window.__store is not available') @@ -35,38 +38,50 @@ export async function seedLineageScenario(page: Page): Promise if (!parent.instanceId || !child.instanceId) { throw new Error('Worktree lineage E2E needs instance-stamped worktrees') } + const lineage = { + worktreeId: child.id, + worktreeInstanceId: child.instanceId, + parentWorktreeId: parent.id, + parentWorktreeInstanceId: parent.instanceId, + origin: 'manual' as const, + capture: { source: 'manual-action' as const, confidence: 'explicit' as const }, + createdAt: Date.now() + } store.setState((current) => ({ worktreesByRepo: Object.fromEntries( Object.entries(current.worktreesByRepo).map(([repoId, repoWorktrees]) => [ repoId, repoWorktrees.map((worktree) => { if (worktree.id === parent.id) { - return { ...worktree, displayName: 'E2E lineage parent', sortOrder: 0 } + return { + ...worktree, + displayName: 'E2E lineage parent', + sortOrder: 0, + ...(inlineOnly + ? { parentWorktreeId: null, childWorktreeIds: [child.id], lineage: null } + : {}) + } } if (worktree.id === child.id) { - return { ...worktree, displayName: 'E2E lineage child', sortOrder: 1 } + return { + ...worktree, + displayName: 'E2E lineage child', + sortOrder: 1, + ...(inlineOnly + ? { parentWorktreeId: parent.id, childWorktreeIds: [], lineage } + : {}) + } } return worktree }) ]) ), - worktreeLineageById: { - ...current.worktreeLineageById, - [child.id]: { - worktreeId: child.id, - worktreeInstanceId: child.instanceId, - parentWorktreeId: parent.id, - parentWorktreeInstanceId: parent.instanceId, - origin: 'manual', - capture: { source: 'manual-action', confidence: 'explicit' }, - createdAt: Date.now() - } - } + worktreeLineageById: inlineOnly ? {} : { ...current.worktreeLineageById, [child.id]: lineage } })) store.getState().setActiveWorktree(parent.id) return { parentId: parent.id, childId: child.id } - }) + }, options) } export async function seedWorkspaceAgentStatus( diff --git a/tests/e2e/worktree-lineage.spec.ts b/tests/e2e/worktree-lineage.spec.ts index 44ba2cf9424..65bcda63f7b 100644 --- a/tests/e2e/worktree-lineage.spec.ts +++ b/tests/e2e/worktree-lineage.spec.ts @@ -1,4 +1,6 @@ import type { Page } from '@stablyai/playwright-test' +import { mkdirSync } from 'node:fs' +import { resolve } from 'node:path' import { test, expect } from './helpers/orca-app' import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { @@ -13,6 +15,20 @@ function worktreeOption(page: Page, worktreeId: string) { return worktreeRow(page, worktreeId) } +async function captureSidebarEvidence(page: Page, name: string): Promise { + if (process.env.ORCA_CAPTURE_EVIDENCE !== '1') { + return + } + const outputDir = resolve(process.cwd(), 'pr-evidence') + mkdirSync(outputDir, { recursive: true }) + await page + .locator('[data-worktree-sidebar]') + .first() + .screenshot({ + path: resolve(outputDir, name) + }) +} + test.describe('Worktree Lineage', () => { test.describe.configure({ mode: 'serial' }) @@ -87,6 +103,27 @@ test.describe('Worktree Lineage', () => { await expect(childRow).toBeVisible() }) + test('renders legacy-only inline lineage when side-map hydration is absent', async ({ + orcaPage + }) => { + const { parentId, childId } = await seedLineageScenario(orcaPage, { inlineOnly: true }) + const parentRow = worktreeOption(orcaPage, parentId) + const childRow = worktreeOption(orcaPage, childId) + + await expect(parentRow.getByRole('button', { name: 'Hide 1 child workspace' })).toBeVisible() + await expect(childRow).toBeVisible() + await expect + .poll(async () => { + const [parentBox, childBox] = await Promise.all([ + parentRow.boundingBox(), + childRow.boundingBox() + ]) + return parentBox && childBox ? childBox.y > parentBox.y : false + }) + .toBe(true) + await captureSidebarEvidence(orcaPage, 'legacy-inline-lineage-nested.png') + }) + test('injects filtered parents structurally without showing a parent badge', async ({ orcaPage }) => { From b606117eedfa654030bac3bdc6f5f2a814ca794a Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 22 Jul 2026 19:03:42 -0400 Subject: [PATCH 09/10] Fix paired remote terminal parse backpressure (#9683) * fix(terminal): defer remote output ACKs until parse * test(terminal): document synchronous credit claims --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../terminal-pane/pty-connection.test.ts | 18 ++- .../terminal-pane/pty-connection.ts | 50 ++++++-- .../terminal-pane/terminal-pty-ack-gate.ts | 41 +----- .../terminal-delivery-credit.test.ts | 44 +++++++ .../pane-manager/terminal-delivery-credit.ts | 49 ++++++++ .../remote-runtime-terminal-multiplexer.ts | 22 ++-- ...untime-terminal-parse-backpressure.test.ts | 118 ++++++++++++++++++ 7 files changed, 289 insertions(+), 53 deletions(-) create mode 100644 src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-delivery-credit.ts create mode 100644 src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 64704880bc3..7e70b5474b4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -13463,6 +13463,8 @@ describe('connectPanePty', () => { it('holds newer live bytes until a later replay frame has fully parsed', async () => { const { connectPanePty } = await import('./pty-connection') + const { deliverTerminalDataWithDeferredCredit } = + await import('@/lib/pane-manager/terminal-delivery-credit') enableActiveRuntimeEnvironment() const transport = createMockTransport('remote:env-1@@terminal-live-order') const callbacksRef: { @@ -13484,8 +13486,12 @@ describe('connectPanePty', () => { await flushAsyncTicks(8) expect(writes).toEqual(['\x1b[2J\x1b[3J\x1b[H']) - callbacksRef.data?.('NEWER-LIVE\r\n') + const acknowledgeLiveFrame = vi.fn() + deliverTerminalDataWithDeferredCredit(acknowledgeLiveFrame, () => { + callbacksRef.data?.('NEWER-LIVE\r\n') + }) expect(writes).not.toContain('NEWER-LIVE\r\n') + expect(acknowledgeLiveFrame).not.toHaveBeenCalled() for (let index = 0; index < 12 && parseCallbacks.length > 0; index += 1) { parseCallbacks.shift()?.() await flushAsyncTicks(4) @@ -13498,7 +13504,17 @@ describe('connectPanePty', () => { expect(replayIndex).toBeGreaterThan(0) expect(resetIndex).toBeGreaterThan(replayIndex) expect(liveIndex).toBeGreaterThan(resetIndex) + expect(acknowledgeLiveFrame).toHaveBeenCalledOnce() + + callbacksRef.replay?.('stalled replay') + await flushAsyncTicks(8) + const acknowledgeDisposedFrame = vi.fn() + deliverTerminalDataWithDeferredCredit(acknowledgeDisposedFrame, () => { + callbacksRef.data?.('LIVE-BEHIND-STALLED-REPLAY') + }) + expect(acknowledgeDisposedFrame).not.toHaveBeenCalled() binding.dispose() + expect(acknowledgeDisposedFrame).toHaveBeenCalledOnce() }) it('drops a queued relay replay instead of retagging it for a replacement PTY', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 821a9688d17..0a8505cde68 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -23,7 +23,10 @@ import { isStatefulRendererReplyCsiQuery, isStatelessRendererReplyCsiQuery } from '../../../../shared/terminal-reply-query-extraction' -import { takeCurrentPtyDeliveryAckCredit } from './terminal-pty-ack-gate' +import { + deliverTerminalDataWithDeferredCredit, + takeCurrentTerminalDeliveryCredit +} from '@/lib/pane-manager/terminal-delivery-credit' import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor' import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' import type { PtyBufferSnapshot, PtyConnectResult } from './pty-transport' @@ -3312,7 +3315,13 @@ export function connectPanePty( let lastTerminalInputAt = Number.NEGATIVE_INFINITY let hasReceivedPtyOutput = false let deferredReattachLiveData: - | { data: string; ptyId: string | null; streamGeneration: number; meta?: PtyDataMeta }[] + | { + data: string + ptyId: string | null + streamGeneration: number + meta?: PtyDataMeta + ackCredit?: () => void + }[] | null = null let deferredReattachLiveDataChars = 0 let reattachLiveDataDeferralDepth = 0 @@ -5787,7 +5796,7 @@ export function connectPanePty( foreground: foregroundOutput, beforeWrite: beforeTerminalOutputWrite, // Why: claim the delivery's parse-deferred ACK credit (null outside a delivery); the FIRST scheduler write carries it all and fires when bytes are consumed. - ackCredit: takeCurrentPtyDeliveryAckCredit() ?? undefined, + ackCredit: takeCurrentTerminalDeliveryCredit() ?? undefined, onBackgroundBacklogDropped: markHiddenOutputRestoreNeeded, latencySensitive: !foreground || parseHiddenStartupOutput @@ -6881,20 +6890,26 @@ export function connectPanePty( } if (deferredReattachLiveData !== null) { // Why: a replacement stream must not inherit bytes or a gap marker from the replay owner it superseded. - deferredReattachLiveData = deferredReattachLiveData.filter( - (chunk) => chunk.streamGeneration === streamGeneration - ) + deferredReattachLiveData = deferredReattachLiveData.filter((chunk) => { + const keep = chunk.streamGeneration === streamGeneration + if (!keep) { + chunk.ackCredit?.() + } + return keep + }) deferredReattachLiveDataChars = deferredReattachLiveData.reduce( (total, chunk) => total + chunk.data.length, 0 ) const oversized = data.length > MAX_DEFERRED_REATTACH_LIVE_CHARS const deferredData = oversized ? data.slice(-MAX_DEFERRED_REATTACH_LIVE_CHARS) : data + const ackCredit = takeCurrentTerminalDeliveryCredit() deferredReattachLiveData.push({ data: deferredData, ptyId: transport.getPtyId(), streamGeneration, - ...(meta ? { meta } : {}) + ...(meta ? { meta } : {}), + ...(ackCredit ? { ackCredit } : {}) }) deferredReattachLiveDataChars += deferredData.length // Why: one huge IPC frame would bypass the queue's memory bound; mark a stream gap so snapshot recovery replaces it, not a partial ANSI frame. @@ -6906,6 +6921,7 @@ export function connectPanePty( ) { const removed = deferredReattachLiveData.shift() deferredReattachLiveDataChars -= removed?.data.length ?? 0 + removed?.ackCredit?.() dropped = true } if (dropped && deferredReattachLiveData[0]) { @@ -7111,6 +7127,9 @@ export function connectPanePty( const currentOwner = deferredReattachLiveDataOwners.get(currentGeneration) deferredReattachLiveDataOwners = new Map() if (disposed || !chunks) { + for (const chunk of chunks ?? []) { + chunk.ackCredit?.() + } return } // Why: paint the authoritative replay first, then admit deferred live chunks so the replay clear can't erase newer output. @@ -7121,9 +7140,16 @@ export function connectPanePty( chunk.streamGeneration !== currentGeneration || currentOwner?.failed === true ) { + chunk.ackCredit?.() continue } - dataCallback(chunk.data, chunk.meta, chunk.streamGeneration) + if (chunk.ackCredit) { + deliverTerminalDataWithDeferredCredit(chunk.ackCredit, () => { + dataCallback(chunk.data, chunk.meta, chunk.streamGeneration) + }) + } else { + dataCallback(chunk.data, chunk.meta, chunk.streamGeneration) + } deliveredDeferredChunks += 1 } if (deliveredDeferredChunks > 0) { @@ -8157,6 +8183,14 @@ export function connectPanePty( reconcileIfSessionMissing, dispose() { disposed = true + // Why: a stalled xterm replay may never reach its finally; release live-frame credit when this renderer no longer owns the stream. + for (const chunk of deferredReattachLiveData ?? []) { + chunk.ackCredit?.() + } + deferredReattachLiveData = null + deferredReattachLiveDataChars = 0 + reattachLiveDataDeferralDepth = 0 + deferredReattachLiveDataOwners = new Map() cancelPendingSafeFitContinuations(pane) pendingHiddenSnapshotFit = null pendingReattachFit = null diff --git a/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts b/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts index 31733aebc47..8340dfa1b48 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts @@ -1,4 +1,8 @@ import { e2eConfig } from '@/lib/e2e-config' +import { + deliverTerminalDataWithDeferredCredit, + takeCurrentTerminalDeliveryCredit +} from '@/lib/pane-manager/terminal-delivery-credit' type E2eTerminalPtyAckGateSnapshot = { gatedPtyCount: number @@ -89,25 +93,6 @@ export function ackPtyData(ptyId: string, chars: number): void { // true parse backpressure and main's producer flow control pauses the shell // instead of dropping. -type DeferredPtyAckCredit = { - ptyId: string - chars: number - claimed: boolean - credited: boolean -} - -let currentDeliveryCredit: DeferredPtyAckCredit | null = null - -function creditDeferredPtyAck(credit: DeferredPtyAckCredit): void { - // Why fire-once: split queue chunks and discard paths may both touch the - // same delivery; the invariant is exactly one credit per delivered chunk. - if (credit.credited) { - return - } - credit.credited = true - ackPtyData(credit.ptyId, credit.chars) -} - /** Runs one pty:data delivery with a parse-deferred ACK credit. If the * handler hands bytes to the output scheduler, the claimed credit fires when * the scheduler consumes (writes or discards) them; any credit left @@ -118,28 +103,14 @@ export function deliverPtyDataWithDeferredAck( chars: number, deliver: () => void ): void { - const credit: DeferredPtyAckCredit = { ptyId, chars, claimed: false, credited: false } - currentDeliveryCredit = credit - try { - deliver() - } finally { - currentDeliveryCredit = null - if (!credit.claimed) { - creditDeferredPtyAck(credit) - } - } + deliverTerminalDataWithDeferredCredit(() => ackPtyData(ptyId, chars), deliver) } /** Claims the in-progress delivery's credit for the output scheduler. Returns * a fire-once callback, or null when outside a delivery or already claimed * (only the FIRST scheduler write of a delivery carries the credit). */ export function takeCurrentPtyDeliveryAckCredit(): (() => void) | null { - const credit = currentDeliveryCredit - if (!credit || credit.claimed) { - return null - } - credit.claimed = true - return () => creditDeferredPtyAck(credit) + return takeCurrentTerminalDeliveryCredit() } export function getProcessedPtyCharTotals(): Record { diff --git a/src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts b/src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts new file mode 100644 index 00000000000..01e20fb3fbd --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('terminal delivery credit', () => { + beforeEach(() => { + vi.resetModules() + }) + + it('restores an outer delivery after a nested delivery returns', async () => { + const { deliverTerminalDataWithDeferredCredit, takeCurrentTerminalDeliveryCredit } = + await import('./terminal-delivery-credit') + const completeOuter = vi.fn() + const completeInner = vi.fn() + let outerCredit: (() => void) | null = null + let innerCredit: (() => void) | null = null + + deliverTerminalDataWithDeferredCredit(completeOuter, () => { + deliverTerminalDataWithDeferredCredit(completeInner, () => { + innerCredit = takeCurrentTerminalDeliveryCredit() + }) + outerCredit = takeCurrentTerminalDeliveryCredit() + }) + + expect(completeOuter).not.toHaveBeenCalled() + expect(completeInner).not.toHaveBeenCalled() + innerCredit!() + outerCredit!() + expect(completeInner).toHaveBeenCalledOnce() + expect(completeOuter).toHaveBeenCalledOnce() + }) + + it('auto-settles before a deferred consumer can claim the delivery', async () => { + const { deliverTerminalDataWithDeferredCredit, takeCurrentTerminalDeliveryCredit } = + await import('./terminal-delivery-credit') + const complete = vi.fn() + let claimLater: (() => (() => void) | null) | null = null + + deliverTerminalDataWithDeferredCredit(complete, () => { + claimLater = takeCurrentTerminalDeliveryCredit + }) + + expect(complete).toHaveBeenCalledOnce() + expect(claimLater!()).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-delivery-credit.ts b/src/renderer/src/lib/pane-manager/terminal-delivery-credit.ts new file mode 100644 index 00000000000..d467cedda23 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-delivery-credit.ts @@ -0,0 +1,49 @@ +type TerminalDeliveryCredit = { + complete: () => void + claimed: boolean + credited: boolean +} + +// Why: consumers must claim during deliver(); after it returns this synchronous slot is restored and unclaimed credit settles. +let currentDeliveryCredit: TerminalDeliveryCredit | null = null + +function completeTerminalDeliveryCredit(credit: TerminalDeliveryCredit): void { + // Why: queue splitting and discard paths can both settle one delivery. + if (credit.credited) { + return + } + credit.credited = true + credit.complete() +} + +/** Defers producer credit until the output scheduler consumes or discards the delivery. */ +export function deliverTerminalDataWithDeferredCredit( + complete: () => void, + deliver: () => void +): void { + const credit: TerminalDeliveryCredit = { + complete, + claimed: false, + credited: false + } + const previousCredit = currentDeliveryCredit + currentDeliveryCredit = credit + try { + deliver() + } finally { + currentDeliveryCredit = previousCredit + if (!credit.claimed) { + completeTerminalDeliveryCredit(credit) + } + } +} + +/** Claims the current delivery for parse-deferred settlement by the output scheduler. */ +export function takeCurrentTerminalDeliveryCredit(): (() => void) | null { + const credit = currentDeliveryCredit + if (!credit || credit.claimed) { + return null + } + credit.claimed = true + return () => completeTerminalDeliveryCredit(credit) +} diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index 4823c119430..12e4ea7f8b3 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -11,6 +11,7 @@ import { encodeTerminalStreamText } from '../../../shared/terminal-stream-protocol' import { e2eConfig } from '@/lib/e2e-config' +import { deliverTerminalDataWithDeferredCredit } from '@/lib/pane-manager/terminal-delivery-credit' import { unwrapRuntimeRpcResult } from './runtime-rpc-client' type RuntimeEnvironmentSubscriptionHandle = { @@ -472,7 +473,7 @@ class RemoteRuntimeTerminalMultiplexer { ? (span!.data as string) : '' : decodeTerminalStreamText(frame.payload) - try { + const deliverOutput = (): void => { if (!validSpan) { // Why: rendering malformed span JSON would expose protocol framing // as terminal text and lose its raw sequence accounting. @@ -501,15 +502,18 @@ class RemoteRuntimeTerminalMultiplexer { rawLength, ...(frame.opcode === TerminalStreamOpcode.OutputSpan ? { transformed: true } : {}) }) - } finally { - if (stream.acknowledgeOutput) { - if (shouldHoldE2eRemoteTerminalAck(stream.terminal)) { - stream.heldAckBytes += frame.payload.byteLength - } else { - this.acknowledgeOutput(stream, frame.payload.byteLength) - } - } } + if (!stream.acknowledgeOutput) { + deliverOutput() + return + } + deliverTerminalDataWithDeferredCredit(() => { + if (shouldHoldE2eRemoteTerminalAck(stream.terminal)) { + stream.heldAckBytes += frame.payload.byteLength + } else { + this.acknowledgeOutput(stream, frame.payload.byteLength) + } + }, deliverOutput) return } if (frame.opcode === TerminalStreamOpcode.SnapshotStart) { diff --git a/src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts b/src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts new file mode 100644 index 00000000000..645e99f4d7f --- /dev/null +++ b/src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' + +describe('remote terminal renderer backpressure', () => { + const sendBinary = vi.fn() + let callbacks: { + onResponse: (response: unknown) => void + onBinary: (bytes: Uint8Array) => void + } | null = null + + beforeEach(() => { + vi.resetModules() + sendBinary.mockReset() + callbacks = null + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + subscribe: vi.fn(async (_args, nextCallbacks) => { + callbacks = nextCallbacks + queueMicrotask(() => { + callbacks?.onResponse({ ok: true, result: { type: 'ready' } }) + }) + return { unsubscribe: vi.fn(), sendBinary } + }) + } + } + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('withholds server credit until xterm consumes the output frame', async () => { + const { getRemoteRuntimeTerminalMultiplexer } = + await import('./remote-runtime-terminal-multiplexer') + const { takeCurrentTerminalDeliveryCredit } = + await import('../lib/pane-manager/terminal-delivery-credit') + const { writeTerminalOutput } = + await import('../lib/pane-manager/pane-terminal-output-scheduler') + const parsedCallbacks: (() => void)[] = [] + const terminal = { + write: vi.fn((_data: string, parsed?: () => void) => { + if (parsed) { + parsedCallbacks.push(parsed) + } + }) + } + const stream = await getRemoteRuntimeTerminalMultiplexer('windows-test').subscribeTerminal({ + terminal: 'term-codex', + client: { id: 'mac-viewer', type: 'desktop' }, + callbacks: { + onData: (data) => { + writeTerminalOutput(terminal, data, { + foreground: true, + ackCredit: takeCurrentTerminalDeliveryCredit() ?? undefined + }) + }, + onSnapshot: vi.fn() + } + }) + + callbacks?.onBinary( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotStart, + streamId: stream.streamId, + seq: 1, + payload: encodeTerminalStreamJson({ kind: 'scrollback', seq: 0 }) + }) + ) + callbacks?.onBinary( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotEnd, + streamId: stream.streamId, + seq: 2, + payload: new Uint8Array() + }) + ) + sendBinary.mockClear() + + const text = '\x1b[?1049h\x1b[?2026h\x1b[2J\x1b[H\x1b[31m-red 🙂 界\x1b[0m\x1b[?2026l' + const output = encodeTerminalStreamText(text) + callbacks?.onBinary( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId: stream.streamId, + seq: text.length, + payload: output + }) + ) + + expect(terminal.write).toHaveBeenCalledWith(text, expect.any(Function)) + expect(parsedCallbacks).toHaveLength(1) + expect(sentAckBytes()).toEqual([]) + + parsedCallbacks.shift()?.() + expect(sentAckBytes()).toEqual([output.byteLength]) + stream.close() + }) + + function sentAckBytes(): number[] { + return sendBinary.mock.calls.flatMap(([bytes]) => { + const frame = decodeTerminalStreamFrame(bytes) + if (frame?.opcode !== TerminalStreamOpcode.Ack) { + return [] + } + const payload = decodeTerminalStreamJson<{ bytes?: number }>(frame.payload) + return typeof payload?.bytes === 'number' ? [payload.bytes] : [] + }) + } +}) From 1d2cd33c83f32cd51364bda5155721e7fd062b50 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:07:51 -0700 Subject: [PATCH 10/10] fix(deps): resolve Dependabot security alerts (#10006) --- mobile/pnpm-lock.yaml | 133 ++--- package.json | 12 +- pnpm-lock.yaml | 1315 ++++++++++++++--------------------------- 3 files changed, 517 insertions(+), 943 deletions(-) diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index d5b49ba03f3..6ca7573778f 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -1494,6 +1494,12 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1942,56 +1948,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.52.0': resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.52.0': resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.52.0': resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.52.0': resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.52.0': resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.52.0': resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.52.0': resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxfmt/binding-openharmony-arm64@0.52.0': resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==} @@ -2064,56 +2062,48 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.71.0': resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.71.0': resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.71.0': resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.71.0': resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.71.0': resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.71.0': resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@oxlint/binding-linux-x64-musl@1.71.0': resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@oxlint/binding-openharmony-arm64@1.71.0': resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==} @@ -2575,42 +2565,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.1.3': resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.1.3': resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.1.3': resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.1.3': resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.1.3': resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.1.3': resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} @@ -3167,11 +3151,11 @@ packages: resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==} engines: {node: '>= 5.10.0'} - brace-expansion@1.1.15: - resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -4175,8 +4159,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + flatted@3.4.3: + resolution: {integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==} flow-enums-runtime@0.0.6: resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} @@ -4910,28 +4894,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -5910,8 +5890,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.8.4: - resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} + shell-quote@1.10.0: + resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==} engines: {node: '>= 0.4'} side-channel-list@1.0.1: @@ -6585,6 +6565,18 @@ packages: utf-8-validate: optional: true + ws@7.5.13: + resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -8098,6 +8090,11 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': dependencies: eslint: 9.39.4 @@ -9674,7 +9671,7 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) @@ -9694,7 +9691,7 @@ snapshots: dependencies: '@typescript-eslint/scope-manager': 8.59.2 '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.59.2 debug: 4.4.3 eslint: 9.39.4 @@ -9711,15 +9708,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.59.2(typescript@6.0.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) - '@typescript-eslint/types': 8.59.2 - debug: 4.4.3 - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/scope-manager@8.59.2': dependencies: '@typescript-eslint/types': 8.59.2 @@ -9729,10 +9717,6 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)': - dependencies: - typescript: 6.0.3 - '@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.59.2 @@ -9762,21 +9746,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)': - dependencies: - '@typescript-eslint/project-service': 8.59.2(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) - '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/visitor-keys': 8.59.2 - debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.7.4 - tinyglobby: 0.2.17 - ts-api-utils: 2.5.0(typescript@6.0.3) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) @@ -10236,12 +10205,12 @@ snapshots: dependencies: big-integer: 1.6.52 - brace-expansion@1.1.15: + brace-expansion@1.1.16: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.6: + brace-expansion@5.0.7: dependencies: balanced-match: 4.0.4 @@ -10839,11 +10808,11 @@ snapshots: eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) eslint: 9.39.4 eslint-config-prettier: 9.1.2(eslint@9.39.4) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4) eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3) eslint-plugin-node: 11.1.0(eslint@9.39.4) eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8) @@ -10867,7 +10836,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): dependencies: debug: 3.2.7 optionalDependencies: @@ -10890,7 +10859,7 @@ snapshots: eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -10901,7 +10870,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -10998,7 +10967,7 @@ snapshots: eslint@9.39.4: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.4) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 @@ -11519,10 +11488,10 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.4.2 + flatted: 3.4.3 keyv: 4.5.4 - flatted@3.4.2: {} + flatted@3.4.3: {} flow-enums-runtime@0.0.6: {} @@ -12933,7 +12902,7 @@ snapshots: serialize-error: 2.1.0 source-map: 0.5.7 throat: 5.0.0 - ws: 7.5.11 + ws: 7.5.13 yargs: 17.7.3 transitivePeerDependencies: - bufferutil @@ -12967,11 +12936,11 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.7 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.15 + brace-expansion: 1.1.16 minimist@1.2.8: {} @@ -13342,7 +13311,7 @@ snapshots: react-devtools-core@6.1.5: dependencies: - shell-quote: 1.8.4 + shell-quote: 1.10.0 ws: 7.5.11 transitivePeerDependencies: - bufferutil @@ -13769,7 +13738,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.8.4: {} + shell-quote@1.10.0: {} side-channel-list@1.0.1: dependencies: @@ -14071,10 +14040,6 @@ snapshots: dependencies: typescript: 5.9.3 - ts-api-utils@2.5.0(typescript@6.0.3): - dependencies: - typescript: 6.0.3 - ts-declaration-location@1.0.7(typescript@5.9.3): dependencies: picomatch: 4.0.4 @@ -14405,6 +14370,8 @@ snapshots: ws@7.5.11: {} + ws@7.5.13: {} + ws@8.21.0: {} xcode@3.0.1: diff --git a/package.json b/package.json index 918c0f00e6e..1c02ffd66c8 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "@xterm/addon-serialize": "0.15.0-beta.287", "@xterm/headless": "6.1.0-beta.287", "agent-browser": "~0.27.0", - "electron-updater": "^6.8.3", + "electron-updater": "^6.8.9", "i18next": "^26.3.1", "jsonc-parser": "^3.3.1", "node-pty": "^1.1.0", @@ -176,9 +176,10 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "dompurify": "^3.4.11", + "dompurify": "^3.4.12", "electron": "^43.1.0", - "electron-builder": "^26.8.1", + "electron-builder": "^26.15.3", + "electron-builder-squirrel-windows": "^26.15.3", "electron-vite": "^5.0.0", "emoji-picker-react": "^4.19.1", "happy-dom": "^20.9.0", @@ -213,7 +214,7 @@ "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "remark-parse": "^11.0.0", - "shadcn": "^4.7.0", + "shadcn": "^4.13.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.4", @@ -251,7 +252,8 @@ "packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a", "pnpm": { "overrides": { - "monaco-editor>dompurify": "3.4.11" + "@modelcontextprotocol/sdk>@hono/node-server": "2.0.10", + "monaco-editor>dompurify": "3.4.12" }, "supportedArchitectures": { "os": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ed99b766af..4e8adb6af64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,8 @@ settings: excludeLinksFromLockfile: false overrides: - monaco-editor>dompurify: 3.4.11 + '@modelcontextprotocol/sdk>@hono/node-server': 2.0.10 + monaco-editor>dompurify: 3.4.12 patchedDependencies: '@xterm/addon-ligatures@0.11.0-beta.287': @@ -39,7 +40,7 @@ importers: version: 1.7.6 '@linear/sdk': specifier: ^82.1.0 - version: 82.1.0(graphql@16.13.2) + version: 82.1.0(graphql@16.14.2) '@parcel/watcher': specifier: ^2.5.6 version: 2.5.6 @@ -53,8 +54,8 @@ importers: specifier: ~0.27.0 version: 0.27.0 electron-updater: - specifier: ^6.8.3 - version: 6.8.3 + specifier: ^6.8.9 + version: 6.8.9 i18next: specifier: ^26.3.1 version: 26.3.1(typescript@7.0.2) @@ -103,7 +104,7 @@ importers: version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) '@electron-toolkit/tsconfig': specifier: ^2.0.0 - version: 2.0.0(@types/node@25.6.0) + version: 2.0.0(@types/node@25.9.5) '@electron/rebuild': specifier: ^4.2.0 version: 4.2.0 @@ -121,7 +122,7 @@ importers: version: 2.1.14(@playwright/test@1.59.1)(zod@4.4.3) '@tailwindcss/vite': specifier: ^4.2.4 - version: 4.2.4(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 4.2.4(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) '@tanstack/react-virtual': specifier: ^3.13.24 version: 3.13.24(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -184,7 +185,7 @@ importers: version: 3.22.5 '@types/node': specifier: ^25.6.0 - version: 25.6.0 + version: 25.9.5 '@types/qrcode': specifier: ^1.5.6 version: 1.5.6 @@ -202,7 +203,7 @@ importers: version: 8.18.1 '@vitejs/plugin-react': specifier: ^5.2.0 - version: 5.2.0(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 5.2.0(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) '@xterm/addon-fit': specifier: 0.12.0-beta.287 version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)) @@ -234,17 +235,20 @@ importers: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) dompurify: - specifier: ^3.4.11 - version: 3.4.11 + specifier: ^3.4.12 + version: 3.4.12 electron: specifier: ^43.1.0 version: 43.1.0 electron-builder: - specifier: ^26.8.1 - version: 26.8.1(electron-builder-squirrel-windows@26.8.1) + specifier: ^26.15.3 + version: 26.15.3(electron-builder-squirrel-windows@26.15.3) + electron-builder-squirrel-windows: + specifier: ^26.15.3 + version: 26.15.3(dmg-builder@26.15.3) electron-vite: specifier: ^5.0.0 - version: 5.0.0(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 5.0.0(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) emoji-picker-react: specifier: ^4.19.1 version: 4.19.1(react@19.2.7) @@ -345,8 +349,8 @@ importers: specifier: ^11.0.0 version: 11.0.0 shadcn: - specifier: ^4.7.0 - version: 4.7.0(@types/node@25.6.0)(typescript@7.0.2) + specifier: ^4.13.1 + version: 4.13.1(typescript@7.0.2) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -370,10 +374,10 @@ importers: version: 11.0.5 vite: specifier: ^7.3.6 - version: 7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + version: 7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) vitest: specifier: ^4.1.5 - version: 4.1.5(@types/node@25.6.0)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.6.0)(typescript@7.0.2))(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 4.1.5(@types/node@25.9.5)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) vscode-oniguruma: specifier: ^2.0.1 version: 2.0.1 @@ -405,9 +409,6 @@ importers: packages: - 7zip-bin@5.2.0: - resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==} - '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} @@ -571,10 +572,6 @@ packages: '@chevrotain/types@11.1.2': resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} - '@develar/schema-utils@2.6.5': - resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} - engines: {node: '>= 8.9.0'} - '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -998,9 +995,9 @@ packages: peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - '@hono/node-server@1.19.14': - resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} - engines: {node: '>=18.14.1'} + '@hono/node-server@2.0.10': + resolution: {integrity: sha512-ZcnNVhKTmyDJeg0UlnZjvM73JBsTAuhrH/J4fjwGOw59PwOW51r4J+p6CsKZWXdKSme4MFqU62CZMOsdDrU4CA==} + engines: {node: '>=20'} peerDependencies: hono: ^4 @@ -1010,35 +1007,35 @@ packages: '@iconify/utils@3.1.1': resolution: {integrity: sha512-MwzoDtw9rO1x+qfgLTV/IVXsHDBqeYZoMIQC8SfxfYSlaSUG+oWiAcoiB1yajAda6mqblm4/1/w2E8tRu7a7Tw==} - '@inquirer/ansi@2.0.5': - resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/ansi@2.0.7': + resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/confirm@6.0.12': - resolution: {integrity: sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/confirm@6.1.1': + resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/core@11.1.9': - resolution: {integrity: sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/core@11.2.1': + resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/figures@2.0.5': - resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/figures@2.0.7': + resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/type@4.0.5': - resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/type@4.0.7': + resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: @@ -1100,8 +1097,8 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@mswjs/interceptors@0.41.8': - resolution: {integrity: sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A==} + '@mswjs/interceptors@0.41.9': + resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} engines: {node: '>=18'} '@napi-rs/canvas-android-arm64@0.1.100': @@ -1187,10 +1184,18 @@ packages: resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@1.4.0': + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 16'} + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1577,6 +1582,20 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} + '@peculiar/asn1-schema@2.8.0': + resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==} + + '@peculiar/json-schema@1.1.12': + resolution: {integrity: sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==} + engines: {node: '>=8.0.0'} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/webcrypto@1.7.1': + resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} + engines: {node: '>=14.18.0'} + '@playwright/test@1.59.1': resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} engines: {node: '>=18'} @@ -1591,9 +1610,6 @@ packages: '@radix-ui/number@1.1.2': resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} - '@radix-ui/primitive@1.1.3': - resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} - '@radix-ui/primitive@1.1.5': resolution: {integrity: sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==} @@ -1714,15 +1730,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-compose-refs@1.1.2': - resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-compose-refs@1.1.3': resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==} peerDependencies: @@ -1745,15 +1752,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-context@1.1.2': - resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-context@1.2.0': resolution: {integrity: sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==} peerDependencies: @@ -1763,19 +1761,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-dialog@1.1.15': - resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-dialog@1.1.19': resolution: {integrity: sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==} peerDependencies: @@ -1798,19 +1783,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-dismissable-layer@1.1.11': - resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-dismissable-layer@1.1.15': resolution: {integrity: sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==} peerDependencies: @@ -1837,15 +1809,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-focus-guards@1.1.3': - resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-focus-guards@1.1.4': resolution: {integrity: sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==} peerDependencies: @@ -1868,19 +1831,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-focus-scope@1.1.7': - resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-form@0.1.12': resolution: {integrity: sha512-JTX94E4LDL91rzLg7X0mHPdxr0A8JEdVwZEmeOwZJSMDHCGW5DFtSlTSJozUyUs807IQmnvbfzKZFVCK5DmkqQ==} peerDependencies: @@ -1907,15 +1857,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-id@1.1.1': - resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-id@1.1.2': resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==} peerDependencies: @@ -2042,32 +1983,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-portal@1.1.9': - resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-presence@1.1.5': - resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-presence@1.1.7': resolution: {integrity: sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==} peerDependencies: @@ -2081,32 +1996,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-primitive@2.1.3': - resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-primitive@2.1.4': - resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - '@radix-ui/react-primitive@2.1.7': resolution: {integrity: sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==} peerDependencies: @@ -2211,24 +2100,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-slot@1.2.3': - resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-slot@1.2.4': - resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-slot@1.3.0': resolution: {integrity: sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==} peerDependencies: @@ -2329,15 +2200,6 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-use-callback-ref@1.1.1': - resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-use-callback-ref@1.1.2': resolution: {integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==} peerDependencies: @@ -2347,15 +2209,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-controllable-state@1.2.2': - resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-use-controllable-state@1.2.3': resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} peerDependencies: @@ -2365,15 +2218,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-effect-event@0.0.2': - resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-use-effect-event@0.0.3': resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} peerDependencies: @@ -2383,15 +2227,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-escape-keydown@1.1.1': - resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-use-escape-keydown@1.1.3': resolution: {integrity: sha512-3wEkMiPHXha/2VadZ68rYBcmYnPINVGl4Y3gtcM7fKRjANk0OscK+cdqBgUWdozb7YJxsh0vefM7vgAMHXOjqg==} peerDependencies: @@ -2410,15 +2245,6 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-layout-effect@1.1.1': - resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@radix-ui/react-use-layout-effect@1.1.2': resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==} peerDependencies: @@ -3176,11 +3002,8 @@ packages: '@types/node@24.12.4': resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} - '@types/node@25.6.0': - resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} - - '@types/plist@3.0.5': - resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + '@types/node@25.9.5': + resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} '@types/qrcode@1.5.6': resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} @@ -3223,9 +3046,6 @@ packages: '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} - '@types/verror@1.10.11': - resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} - '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} @@ -3401,10 +3221,6 @@ packages: resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} - '@xmldom/xmldom@0.9.10': - resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} - engines: {node: '>=14.6'} - '@xterm/addon-fit@0.12.0-beta.287': resolution: {integrity: sha512-2MDj+J4x67bjOS/SuBPxSYEWH38NbX6ENV18RbKVOhfRYCX3yERnuHBOrgH9hYdY8rCtsLQmuVgF6cmvCJiV2w==} peerDependencies: @@ -3476,14 +3292,6 @@ packages: ajv: optional: true - ajv-keywords@3.5.2: - resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} - peerDependencies: - ajv: ^6.9.1 - - ajv@6.15.0: - resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -3511,15 +3319,12 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} - app-builder-bin@5.0.0-alpha.12: - resolution: {integrity: sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==} - - app-builder-lib@26.8.1: - resolution: {integrity: sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw==} + app-builder-lib@26.15.3: + resolution: {integrity: sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==} engines: {node: '>=14.0.0'} peerDependencies: - dmg-builder: 26.8.1 - electron-builder-squirrel-windows: 26.8.1 + dmg-builder: 26.15.3 + electron-builder-squirrel-windows: 26.15.3 argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -3534,9 +3339,9 @@ packages: asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} - assert-plus@1.0.0: - resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} - engines: {node: '>=0.8'} + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} @@ -3546,10 +3351,6 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} - astral-regex@2.0.0: - resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} - engines: {node: '>=8'} - async-exit-hook@2.0.1: resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} engines: {node: '>=0.12.0'} @@ -3564,6 +3365,9 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -3590,22 +3394,25 @@ packages: peerDependencies: react: '>=17.0.1' - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} 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==} + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} - brace-expansion@2.1.0: - resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -3620,19 +3427,17 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - buildcheck@0.0.7: resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} engines: {node: '>=10.0.0'} - builder-util-runtime@9.5.1: - resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==} + builder-util-runtime@9.7.0: + resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==} engines: {node: '>=12.0.0'} - builder-util@26.8.1: - resolution: {integrity: sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==} + builder-util@26.15.3: + resolution: {integrity: sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==} + engines: {node: '>=14.0.0'} bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} @@ -3642,6 +3447,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + bytestreamjs@2.0.1: + resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==} + engines: {node: '>=6.0.0'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -3730,10 +3539,6 @@ packages: resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} engines: {node: '>=18.20'} - cli-truncate@2.1.0: - resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} - engines: {node: '>=8'} - cli-truncate@5.2.0: resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} engines: {node: '>=20'} @@ -3843,8 +3648,8 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} - core-util-is@1.0.2: - resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} @@ -3869,9 +3674,6 @@ packages: resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} engines: {node: '>=10.0.0'} - crc@3.8.0: - resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} - cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} @@ -4046,10 +3848,6 @@ packages: dagre-d3-es@7.0.14: resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} - data-uri-to-buffer@4.0.1: - resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} - engines: {node: '>= 12'} - dayjs@1.11.20: resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} @@ -4147,14 +3945,8 @@ packages: dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} - dmg-builder@26.8.1: - resolution: {integrity: sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==} - - dmg-license@1.0.11: - resolution: {integrity: sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==} - engines: {node: '>=8'} - os: [darwin] - hasBin: true + dmg-builder@26.15.3: + resolution: {integrity: sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==} dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -4162,8 +3954,8 @@ packages: dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} - dompurify@3.4.11: - resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + dompurify@3.4.12: + resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==} dotenv-expand@11.0.7: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} @@ -4181,6 +3973,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + eciesjs@0.4.18: resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==} engines: {bun: '>=1', deno: '>=2', node: '>=16'} @@ -4193,22 +3988,22 @@ packages: engines: {node: '>=0.10.0'} hasBin: true - electron-builder-squirrel-windows@26.8.1: - resolution: {integrity: sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==} + electron-builder-squirrel-windows@26.15.3: + resolution: {integrity: sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==} - electron-builder@26.8.1: - resolution: {integrity: sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw==} + electron-builder@26.15.3: + resolution: {integrity: sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==} engines: {node: '>=14.0.0'} hasBin: true - electron-publish@26.8.1: - resolution: {integrity: sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w==} + electron-publish@26.15.3: + resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==} electron-to-chromium@1.5.351: resolution: {integrity: sha512-9D7Iqx8RImSvCnOsj86rCH6eQjZFQoM04Jn6HnZVM0Nu/G58/gmKYQ1d12MZTbjQbQSTGI8nwEy07ErsA2slLA==} - electron-updater@6.8.3: - resolution: {integrity: sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==} + electron-updater@6.8.9: + resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==} electron-vite@5.0.0: resolution: {integrity: sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==} @@ -4399,10 +4194,6 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - extsprintf@1.4.1: - resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} - engines: {'0': node >=0.6.0} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -4414,20 +4205,17 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-uri@3.1.4: + resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} - fast-wrap-ansi@0.2.0: - resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -4444,10 +4232,6 @@ packages: picomatch: optional: true - fetch-blob@3.2.0: - resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} - engines: {node: ^12.20 || >= 14.13} - figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -4478,10 +4262,6 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} - formdata-polyfill@4.0.10: - resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} - engines: {node: '>=12.20.0'} - forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -4494,6 +4274,10 @@ packages: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} + fs-extra@11.3.1: + resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} + engines: {node: '>=14.14'} + fs-extra@11.3.6: resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} engines: {node: '>=14.14'} @@ -4599,8 +4383,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphql@16.13.2: - resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} + graphql@16.14.2: + resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} hachure-fill@0.5.2: @@ -4681,8 +4465,8 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} - hono@4.12.25: - resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} + hono@4.12.31: + resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==} engines: {node: '>=16.9.0'} hosted-git-info@4.1.0: @@ -4741,11 +4525,6 @@ packages: typescript: optional: true - iconv-corefoundation@1.1.7: - resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} - engines: {node: ^8.11.2 || >=10} - os: [darwin] - iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -4754,9 +4533,6 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -4895,6 +4671,9 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isbinaryfile@4.0.10: resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} engines: {node: '>= 8.0.0'} @@ -4932,8 +4711,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.2.0: - resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true jsesc@3.1.0: @@ -4947,9 +4726,6 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -5453,9 +5229,6 @@ packages: resolution: {integrity: sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==} engines: {node: '>=22.12.0'} - node-addon-api@1.7.2: - resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==} - node-addon-api@4.3.0: resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==} @@ -5465,20 +5238,14 @@ packages: node-api-version@0.2.1: resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} - 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==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - node-gyp@12.3.0: resolution: {integrity: sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg==} engines: {node: ^20.17.0 || >=22.9.0} hasBin: true + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + node-pty@1.1.0: resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} @@ -5698,6 +5465,10 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + pkijs@3.4.0: + resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} + engines: {node: '>=16.0.0'} + playwright-core@1.59.1: resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} engines: {node: '>=18'} @@ -5712,10 +5483,6 @@ packages: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} - plist@3.1.1: - resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} - engines: {node: '>=10.4.0'} - pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} @@ -5768,6 +5535,9 @@ packages: resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} engines: {node: ^20.17.0 || >=22.9.0} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} @@ -5829,9 +5599,12 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.5: + resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} + engines: {node: '>=16.0.0'} qrcode@1.5.4: resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} @@ -5957,6 +5730,9 @@ packages: resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} hasBin: true + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + recast@0.23.11: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} @@ -6085,6 +5861,9 @@ packages: rw@1.3.3: resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -6139,14 +5918,15 @@ packages: set-blocking@2.0.0: resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} - set-cookie-parser@3.1.0: - resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + set-cookie-parser@3.1.2: + resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shadcn@4.7.0: - resolution: {integrity: sha512-70fwnesNrY1GgeD7Kdzn+3SsYeyfibm8immsA5L68+OusoPTvYF01oWExl8/latKpMpvVXcbgdbbE6VFBJQ38w==} + shadcn@4.13.1: + resolution: {integrity: sha512-pSNPND8mVWGBytdd8l4Cksg7MyRZOsv28HpvNCtFtLwZoxLSGM6v3NMUpmpbOaIoreopS/UOpQvnCyttOHVLAQ==} + engines: {node: '>=20.18.1'} hasBin: true shebang-command@2.0.0: @@ -6218,10 +5998,6 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - slice-ansi@3.0.0: - resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} - engines: {node: '>=8'} - slice-ansi@7.1.2: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} @@ -6230,10 +6006,6 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} - smart-buffer@4.2.0: - resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} - engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - smol-toml@1.6.1: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} @@ -6309,6 +6081,9 @@ packages: resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} engines: {node: '>=20'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -6371,8 +6146,8 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} - tar@7.5.16: - resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==} + tar@7.5.20: + resolution: {integrity: sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==} engines: {node: '>=18'} temp-file@3.4.0: @@ -6410,11 +6185,11 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} - tldts-core@7.0.30: - resolution: {integrity: sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==} + tldts-core@7.4.9: + resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} - tldts@7.0.30: - resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} + tldts@7.4.9: + resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} hasBin: true tmp-promise@3.0.3: @@ -6432,8 +6207,8 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} - tough-cookie@6.0.1: - resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} engines: {node: '>=16'} trim-lines@3.0.1: @@ -6472,8 +6247,8 @@ packages: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} - type-fest@5.6.0: - resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} engines: {node: '>=20'} type-is@2.1.0: @@ -6499,8 +6274,8 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici-types@7.19.2: - resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} undici@6.27.0: resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} @@ -6553,15 +6328,15 @@ packages: until-async@3.0.2: resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + unzipper@0.12.5: + resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -6605,10 +6380,6 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - verror@1.10.1: - resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} - engines: {node: '>=0.6.0'} - vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -6715,9 +6486,8 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} - web-streams-polyfill@3.3.3: - resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} - engines: {node: '>= 8'} + webcrypto-core@1.9.2: + resolution: {integrity: sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==} whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} @@ -6824,8 +6594,8 @@ packages: resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} engines: {node: '>=8'} - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} engines: {node: '>=12'} yocto-queue@0.1.0: @@ -6874,8 +6644,6 @@ packages: snapshots: - 7zip-bin@5.2.0: {} - '@adobe/css-tools@4.5.0': {} '@antfu/install-pkg@1.1.0': @@ -7090,11 +6858,6 @@ snapshots: '@chevrotain/types@11.1.2': {} - '@develar/schema-utils@2.6.5': - dependencies: - ajv: 6.15.0 - ajv-keywords: 3.5.2(ajv@6.15.0) - '@dnd-kit/accessibility@3.1.1(react@19.2.7)': dependencies: react: 19.2.7 @@ -7143,9 +6906,9 @@ snapshots: dependencies: electron: 43.1.0 - '@electron-toolkit/tsconfig@2.0.0(@types/node@25.6.0)': + '@electron-toolkit/tsconfig@2.0.0(@types/node@25.9.5)': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 '@electron-toolkit/utils@4.0.0(electron@43.1.0)': dependencies: @@ -7205,7 +6968,7 @@ snapshots: fs-extra: 10.1.0 isbinaryfile: 4.0.10 minimist: 1.2.8 - plist: 3.1.1 + plist: 3.1.0 transitivePeerDependencies: - supports-color @@ -7228,7 +6991,7 @@ snapshots: dir-compare: 4.2.0 fs-extra: 11.3.6 minimatch: 9.0.9 - plist: 3.1.1 + plist: 3.1.0 transitivePeerDependencies: - supports-color @@ -7416,13 +7179,13 @@ snapshots: '@floating-ui/utils@0.2.11': {} - '@graphql-typed-document-node/core@3.2.0(graphql@16.13.2)': + '@graphql-typed-document-node/core@3.2.0(graphql@16.14.2)': dependencies: - graphql: 16.13.2 + graphql: 16.14.2 - '@hono/node-server@1.19.14(hono@4.12.25)': + '@hono/node-server@2.0.10(hono@4.12.31)': dependencies: - hono: 4.12.25 + hono: 4.12.31 '@iconify/types@2.0.0': {} @@ -7432,32 +7195,37 @@ snapshots: '@iconify/types': 2.0.0 mlly: 1.8.2 - '@inquirer/ansi@2.0.5': {} + '@inquirer/ansi@2.0.7': + optional: true - '@inquirer/confirm@6.0.12(@types/node@25.6.0)': + '@inquirer/confirm@6.1.1(@types/node@25.9.5)': dependencies: - '@inquirer/core': 11.1.9(@types/node@25.6.0) - '@inquirer/type': 4.0.5(@types/node@25.6.0) + '@inquirer/core': 11.2.1(@types/node@25.9.5) + '@inquirer/type': 4.0.7(@types/node@25.9.5) optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 + optional: true - '@inquirer/core@11.1.9(@types/node@25.6.0)': + '@inquirer/core@11.2.1(@types/node@25.9.5)': dependencies: - '@inquirer/ansi': 2.0.5 - '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.6.0) + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@25.9.5) cli-width: 4.1.0 - fast-wrap-ansi: 0.2.0 + fast-wrap-ansi: 0.2.2 mute-stream: 3.0.0 signal-exit: 4.1.0 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 + optional: true - '@inquirer/figures@2.0.5': {} + '@inquirer/figures@2.0.7': + optional: true - '@inquirer/type@4.0.5(@types/node@25.6.0)': + '@inquirer/type@4.0.7(@types/node@25.9.5)': optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 + optional: true '@isaacs/fs-minipass@4.0.1': dependencies: @@ -7482,9 +7250,9 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@linear/sdk@82.1.0(graphql@16.13.2)': + '@linear/sdk@82.1.0(graphql@16.14.2)': dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.13.2) + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.2) transitivePeerDependencies: - graphql @@ -7507,7 +7275,7 @@ snapshots: '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.14(hono@4.12.25) + '@hono/node-server': 2.0.10(hono@4.12.31) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -7517,7 +7285,7 @@ snapshots: eventsource-parser: 3.0.8 express: 5.2.1 express-rate-limit: 8.5.2(express@5.2.1) - hono: 4.12.25 + hono: 4.12.31 jose: 6.2.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -7538,7 +7306,7 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - '@mswjs/interceptors@0.41.8': + '@mswjs/interceptors@0.41.9': dependencies: '@open-draft/deferred-promise': 2.2.0 '@open-draft/logger': 0.3.0 @@ -7546,6 +7314,7 @@ snapshots: is-node-process: 1.2.0 outvariant: 1.4.3 strict-event-emitter: 0.5.1 + optional: true '@napi-rs/canvas-android-arm64@0.1.100': optional: true @@ -7601,8 +7370,12 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@noble/hashes@1.4.0': {} + '@noble/hashes@1.8.0': {} + '@noble/hashes@2.2.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7615,16 +7388,20 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@open-draft/deferred-promise@2.2.0': {} + '@open-draft/deferred-promise@2.2.0': + optional: true - '@open-draft/deferred-promise@3.0.0': {} + '@open-draft/deferred-promise@3.0.0': + optional: true '@open-draft/logger@0.3.0': dependencies: is-node-process: 1.2.0 outvariant: 1.4.3 + optional: true - '@open-draft/until@2.1.0': {} + '@open-draft/until@2.1.0': + optional: true '@oxfmt/binding-android-arm-eabi@0.52.0': optional: true @@ -7818,6 +7595,28 @@ snapshots: '@parcel/watcher-win32-ia32': 2.5.6 '@parcel/watcher-win32-x64': 2.5.6 + '@peculiar/asn1-schema@2.8.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/json-schema@1.1.12': + dependencies: + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/webcrypto@1.7.1': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/json-schema': 1.1.12 + '@peculiar/utils': 2.0.3 + tslib: 2.8.1 + webcrypto-core: 1.9.2 + '@playwright/test@1.59.1': dependencies: playwright: 1.59.1 @@ -7830,8 +7629,6 @@ snapshots: '@radix-ui/number@1.1.2': {} - '@radix-ui/primitive@1.1.3': {} - '@radix-ui/primitive@1.1.5': {} '@radix-ui/react-accessible-icon@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': @@ -7948,12 +7745,6 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.17)(react@19.2.7)': - dependencies: - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 @@ -7973,40 +7764,12 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-context@1.1.2(@types/react@19.2.17)(react@19.2.7)': - dependencies: - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - '@radix-ui/react-context@1.2.0(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.17)(react@19.2.7) - aria-hidden: 1.2.6 - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) - optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-dialog@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.5 @@ -8035,19 +7798,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-dismissable-layer@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.5 @@ -8076,12 +7826,6 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.17)(react@19.2.7)': - dependencies: - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - '@radix-ui/react-focus-guards@1.1.4(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 @@ -8099,17 +7843,6 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-form@0.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.5 @@ -8141,13 +7874,6 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-id@1.1.1(@types/react@19.2.17)(react@19.2.7)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - '@radix-ui/react-id@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -8317,26 +8043,6 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) - - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-presence@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -8346,24 +8052,6 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) - - '@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/react-slot': 1.2.4(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - react-dom: 19.2.7(react@19.2.7) - optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-primitive@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) @@ -8495,20 +8183,6 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-slot@1.2.3(@types/react@19.2.17)(react@19.2.7)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - - '@radix-ui/react-slot@1.2.4(@types/react@19.2.17)(react@19.2.7)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - '@radix-ui/react-slot@1.3.0(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) @@ -8628,26 +8302,12 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.17)(react@19.2.7)': - dependencies: - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - '@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.17)(react@19.2.7)': - dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) @@ -8656,13 +8316,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.17)(react@19.2.7)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -8670,13 +8323,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.17)(react@19.2.7)': - dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.17)(react@19.2.7) - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - '@radix-ui/react-use-escape-keydown@1.1.3(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) @@ -8690,12 +8336,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.17)(react@19.2.7)': - dependencies: - react: 19.2.7 - optionalDependencies: - '@types/react': 19.2.17 - '@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 @@ -8921,12 +8561,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 - '@tailwindcss/vite@4.2.4(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': + '@tailwindcss/vite@4.2.4(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': dependencies: '@tailwindcss/node': 4.2.4 '@tailwindcss/oxide': 4.2.4 tailwindcss: 4.2.4 - vite: 7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) '@tanstack/react-virtual@3.13.24(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: @@ -9237,7 +8877,7 @@ snapshots: dependencies: '@types/http-cache-semantics': 4.2.0 '@types/keyv': 3.1.4 - '@types/node': 25.6.0 + '@types/node': 25.9.5 '@types/responselike': 1.0.3 '@types/chai@5.2.3': @@ -9378,7 +9018,7 @@ snapshots: '@types/fs-extra@9.0.13': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 '@types/geojson@7946.0.16': {} @@ -9392,7 +9032,7 @@ snapshots: '@types/keyv@3.1.4': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 '@types/mdast@4.0.4': dependencies: @@ -9408,19 +9048,13 @@ snapshots: dependencies: undici-types: 7.16.0 - '@types/node@25.6.0': + '@types/node@25.9.5': dependencies: - undici-types: 7.19.2 - - '@types/plist@3.0.5': - dependencies: - '@types/node': 25.6.0 - xmlbuilder: 15.1.1 - optional: true + undici-types: 7.24.6 '@types/qrcode@1.5.6': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: @@ -9432,19 +9066,21 @@ snapshots: '@types/responselike@1.0.3': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 '@types/retry@0.12.0': {} '@types/set-cookie-parser@2.4.10': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 + optional: true '@types/ssh2@1.15.5': dependencies: '@types/node': 18.19.130 - '@types/statuses@2.0.6': {} + '@types/statuses@2.0.6': + optional: true '@types/trusted-types@2.0.7': optional: true @@ -9457,14 +9093,11 @@ snapshots: '@types/validate-npm-package-name@4.0.2': {} - '@types/verror@1.10.11': - optional: true - '@types/whatwg-mimetype@3.0.2': {} '@types/ws@8.18.1': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 '@typescript-eslint/types@8.60.0': {} @@ -9535,7 +9168,7 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': + '@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7) @@ -9543,7 +9176,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) transitivePeerDependencies: - supports-color @@ -9556,14 +9189,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(msw@2.14.3(@types/node@25.6.0)(typescript@7.0.2))(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': + '@vitest/mocker@4.1.5(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.14.3(@types/node@25.6.0)(typescript@7.0.2) - vite: 7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + msw: 2.14.3(@types/node@25.9.5)(typescript@7.0.2) + vite: 7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) '@vitest/pretty-format@4.1.5': dependencies: @@ -9591,8 +9224,6 @@ snapshots: '@xmldom/xmldom@0.8.13': {} - '@xmldom/xmldom@0.9.10': {} - '@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))': dependencies: '@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c) @@ -9644,21 +9275,10 @@ snapshots: optionalDependencies: ajv: 8.20.0 - ajv-keywords@3.5.2(ajv@6.15.0): - dependencies: - ajv: 6.15.0 - - ajv@6.15.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 + fast-uri: 3.1.4 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -9678,11 +9298,8 @@ snapshots: ansi-styles@6.2.3: {} - app-builder-bin@5.0.0-alpha.12: {} - - app-builder-lib@26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1): + app-builder-lib@26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3): dependencies: - '@develar/schema-utils': 2.6.5 '@electron/asar': 3.4.1 '@electron/fuses': 1.8.0 '@electron/get': 3.1.0 @@ -9691,34 +9308,40 @@ snapshots: '@electron/rebuild': 4.2.0 '@electron/universal': 2.0.3 '@malept/flatpak-bundler': 0.4.0 + '@noble/hashes': 2.2.0 + '@peculiar/webcrypto': 1.7.1 '@types/fs-extra': 9.0.13 + ajv: 8.20.0 + asn1js: 3.0.10 async-exit-hook: 2.0.1 - builder-util: 26.8.1 - builder-util-runtime: 9.5.1 + builder-util: 26.15.3 + builder-util-runtime: 9.7.0 chromium-pickle-js: 0.2.0 ci-info: 4.3.1 debug: 4.4.3 - dmg-builder: 26.8.1(electron-builder-squirrel-windows@26.8.1) + dmg-builder: 26.15.3(electron-builder-squirrel-windows@26.15.3) dotenv: 16.6.1 dotenv-expand: 11.0.7 ejs: 3.1.10 - electron-builder-squirrel-windows: 26.8.1(dmg-builder@26.8.1) - electron-publish: 26.8.1 + electron-builder-squirrel-windows: 26.15.3(dmg-builder@26.15.3) + electron-publish: 26.15.3 fs-extra: 10.1.0 hosted-git-info: 4.1.0 isbinaryfile: 5.0.7 jiti: 2.7.0 - js-yaml: 4.2.0 + js-yaml: 4.3.0 json5: 2.2.3 lazy-val: 1.0.5 minimatch: 10.2.5 + pkijs: 3.4.0 plist: 3.1.0 proper-lockfile: 4.1.2 resedit: 1.7.2 semver: 7.7.4 - tar: 7.5.16 + tar: 7.5.20 temp-file: 3.4.0 tiny-async-pool: 1.3.0 + unzipper: 0.12.5 which: 5.0.0 transitivePeerDependencies: - supports-color @@ -9737,8 +9360,11 @@ snapshots: dependencies: safer-buffer: 2.1.2 - assert-plus@1.0.0: - optional: true + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 assertion-error@2.0.1: {} @@ -9746,9 +9372,6 @@ snapshots: dependencies: tslib: 2.8.1 - astral-regex@2.0.0: - optional: true - async-exit-hook@2.0.1: {} async@3.2.6: {} @@ -9757,6 +9380,8 @@ snapshots: at-least-node@1.0.0: {} + aws4@1.13.2: {} + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -9775,10 +9400,12 @@ snapshots: dependencies: react: 19.2.7 - body-parser@2.2.2: + bluebird@3.7.2: {} + + body-parser@2.3.0: dependencies: bytes: 3.1.2 - content-type: 1.0.5 + content-type: 2.0.0 debug: 4.4.3 http-errors: 2.0.1 iconv-lite: 0.7.2 @@ -9792,16 +9419,16 @@ snapshots: boolean@3.2.0: optional: true - brace-expansion@1.1.14: + brace-expansion@1.1.16: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.1.0: + brace-expansion@2.1.2: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.6: + brace-expansion@5.0.7: dependencies: balanced-match: 4.0.4 @@ -9819,35 +9446,27 @@ snapshots: buffer-from@1.1.2: {} - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - optional: true - buildcheck@0.0.7: optional: true - builder-util-runtime@9.5.1: + builder-util-runtime@9.7.0: dependencies: debug: 4.4.3 sax: 1.6.0 transitivePeerDependencies: - supports-color - builder-util@26.8.1: + builder-util@26.15.3: dependencies: - 7zip-bin: 5.2.0 '@types/debug': 4.1.13 - app-builder-bin: 5.0.0-alpha.12 - builder-util-runtime: 9.5.1 + builder-util-runtime: 9.7.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 fs-extra: 10.1.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 - js-yaml: 4.2.0 + js-yaml: 4.3.0 sanitize-filename: 1.6.4 source-map-support: 0.5.21 stat-mode: 1.0.0 @@ -9862,6 +9481,8 @@ snapshots: bytes@3.1.2: {} + bytestreamjs@2.0.1: {} + cac@6.7.14: {} cacheable-lookup@5.0.4: {} @@ -9931,18 +9552,13 @@ snapshots: cli-spinners@3.4.0: {} - cli-truncate@2.1.0: - dependencies: - slice-ansi: 3.0.0 - string-width: 4.2.3 - optional: true - cli-truncate@5.2.0: dependencies: slice-ansi: 8.0.0 string-width: 8.2.1 - cli-width@4.1.0: {} + cli-width@4.1.0: + optional: true cliui@6.0.0: dependencies: @@ -9964,10 +9580,10 @@ snapshots: cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-id': 1.1.1(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dialog': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) transitivePeerDependencies: @@ -10021,11 +9637,11 @@ snapshots: cookie@0.7.2: {} - cookie@1.1.1: {} - - core-util-is@1.0.2: + cookie@1.1.1: optional: true + core-util-is@1.0.3: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -10043,7 +9659,7 @@ snapshots: dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 - js-yaml: 4.2.0 + js-yaml: 4.3.0 parse-json: 5.2.0 optionalDependencies: typescript: 7.0.2 @@ -10054,11 +9670,6 @@ snapshots: nan: 2.26.2 optional: true - crc@3.8.0: - dependencies: - buffer: 5.7.1 - optional: true - cross-dirname@0.1.0: optional: true @@ -10258,8 +9869,6 @@ snapshots: d3: 7.9.0 lodash-es: 4.18.1 - data-uri-to-buffer@4.0.1: {} - dayjs@1.11.20: {} debug@4.4.3: @@ -10335,36 +9944,21 @@ snapshots: minimatch: 3.1.5 p-limit: 3.1.0 - dmg-builder@26.8.1(electron-builder-squirrel-windows@26.8.1): + dmg-builder@26.15.3(electron-builder-squirrel-windows@26.15.3): dependencies: - app-builder-lib: 26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1) - builder-util: 26.8.1 + app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + builder-util: 26.15.3 fs-extra: 10.1.0 - iconv-lite: 0.6.3 - js-yaml: 4.2.0 - optionalDependencies: - dmg-license: 1.0.11 + js-yaml: 4.3.0 transitivePeerDependencies: - electron-builder-squirrel-windows - supports-color - dmg-license@1.0.11: - dependencies: - '@types/plist': 3.0.5 - '@types/verror': 1.10.11 - ajv: 6.15.0 - crc: 3.8.0 - iconv-corefoundation: 1.1.7 - plist: 3.1.1 - smart-buffer: 4.2.0 - verror: 1.10.1 - optional: true - dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} - dompurify@3.4.11: + dompurify@3.4.12: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -10382,6 +9976,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + eciesjs@0.4.18: dependencies: '@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0) @@ -10395,36 +9993,37 @@ snapshots: dependencies: jake: 10.9.4 - electron-builder-squirrel-windows@26.8.1(dmg-builder@26.8.1): + electron-builder-squirrel-windows@26.15.3(dmg-builder@26.15.3): dependencies: - app-builder-lib: 26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1) - builder-util: 26.8.1 + app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + builder-util: 26.15.3 electron-winstaller: 5.4.0 transitivePeerDependencies: - dmg-builder - supports-color - electron-builder@26.8.1(electron-builder-squirrel-windows@26.8.1): + electron-builder@26.15.3(electron-builder-squirrel-windows@26.15.3): dependencies: - app-builder-lib: 26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1) - builder-util: 26.8.1 - builder-util-runtime: 9.5.1 + app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + builder-util: 26.15.3 + builder-util-runtime: 9.7.0 chalk: 4.1.2 ci-info: 4.4.0 - dmg-builder: 26.8.1(electron-builder-squirrel-windows@26.8.1) + dmg-builder: 26.15.3(electron-builder-squirrel-windows@26.15.3) fs-extra: 10.1.0 lazy-val: 1.0.5 simple-update-notifier: 2.0.0 - yargs: 17.7.2 + yargs: 17.7.3 transitivePeerDependencies: - electron-builder-squirrel-windows - supports-color - electron-publish@26.8.1: + electron-publish@26.15.3: dependencies: '@types/fs-extra': 9.0.13 - builder-util: 26.8.1 - builder-util-runtime: 9.5.1 + aws4: 1.13.2 + builder-util: 26.15.3 + builder-util-runtime: 9.7.0 chalk: 4.1.2 form-data: 4.0.6 fs-extra: 10.1.0 @@ -10435,11 +10034,11 @@ snapshots: electron-to-chromium@1.5.351: {} - electron-updater@6.8.3: + electron-updater@6.8.9: dependencies: - builder-util-runtime: 9.5.1 + builder-util-runtime: 9.7.0 fs-extra: 10.1.0 - js-yaml: 4.2.0 + js-yaml: 4.3.0 lazy-val: 1.0.5 lodash.escaperegexp: 4.1.2 lodash.isequal: 4.5.0 @@ -10448,7 +10047,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-vite@5.0.0(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)): + electron-vite@5.0.0(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)): dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7) @@ -10456,7 +10055,7 @@ snapshots: esbuild: 0.25.12 magic-string: 0.30.21 picocolors: 1.1.1 - vite: 7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) transitivePeerDependencies: - supports-color @@ -10677,7 +10276,7 @@ snapshots: express@5.2.1: dependencies: accepts: 2.0.0 - body-parser: 2.2.2 + body-parser: 2.3.0 content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 @@ -10709,9 +10308,6 @@ snapshots: extend@3.0.2: {} - extsprintf@1.4.1: - optional: true - fast-deep-equal@3.1.3: {} fast-equals@5.4.0: {} @@ -10724,19 +10320,20 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 - fast-json-stable-stringify@2.1.0: {} - - fast-string-truncated-width@3.0.3: {} + fast-string-truncated-width@3.0.3: + optional: true fast-string-width@3.0.2: dependencies: fast-string-truncated-width: 3.0.3 + optional: true - fast-uri@3.1.2: {} + fast-uri@3.1.4: {} - fast-wrap-ansi@0.2.0: + fast-wrap-ansi@0.2.2: dependencies: fast-string-width: 3.0.2 + optional: true fastq@1.20.1: dependencies: @@ -10750,11 +10347,6 @@ snapshots: optionalDependencies: picomatch: 4.0.4 - fetch-blob@3.2.0: - dependencies: - node-domexception: 1.0.0 - web-streams-polyfill: 3.3.3 - figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -10795,10 +10387,6 @@ snapshots: format@0.2.2: {} - formdata-polyfill@4.0.10: - dependencies: - fetch-blob: 3.2.0 - forwarded@0.2.0: {} fresh@2.0.0: {} @@ -10809,6 +10397,12 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fs-extra@11.3.1: + 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 @@ -10934,13 +10528,13 @@ snapshots: graceful-fs@4.2.11: {} - graphql@16.13.2: {} + graphql@16.14.2: {} hachure-fill@0.5.2: {} happy-dom@20.9.0: dependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 entities: 7.0.1 @@ -11090,11 +10684,12 @@ snapshots: headers-polyfill@5.0.1: dependencies: '@types/set-cookie-parser': 2.4.10 - set-cookie-parser: 3.1.0 + set-cookie-parser: 3.1.2 + optional: true highlight.js@11.11.1: {} - hono@4.12.25: {} + hono@4.12.31: {} hosted-git-info@4.1.0: dependencies: @@ -11149,12 +10744,6 @@ snapshots: optionalDependencies: typescript: 7.0.2 - iconv-corefoundation@1.1.7: - dependencies: - cli-truncate: 2.1.0 - node-addon-api: 1.7.2 - optional: true - iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -11163,9 +10752,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - ieee754@1.2.1: - optional: true - ignore@5.3.2: {} ignore@7.0.5: {} @@ -11233,7 +10819,8 @@ snapshots: is-interactive@2.0.0: {} - is-node-process@1.2.0: {} + is-node-process@1.2.0: + optional: true is-number@7.0.0: {} @@ -11257,6 +10844,8 @@ snapshots: dependencies: is-inside-container: 1.0.0 + isarray@1.0.0: {} + isbinaryfile@4.0.10: {} isbinaryfile@5.0.7: {} @@ -11281,7 +10870,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.2.0: + js-yaml@4.3.0: dependencies: argparse: 2.0.1 @@ -11291,8 +10880,6 @@ snapshots: json-parse-even-better-errors@2.3.1: {} - json-schema-traverse@0.4.1: {} - json-schema-traverse@1.0.0: {} json-schema-typed@8.0.2: {} @@ -11683,7 +11270,7 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 dayjs: 1.11.20 - dompurify: 3.4.11 + dompurify: 3.4.12 es-toolkit: 1.46.1 katex: 0.16.45 khroma: 2.1.0 @@ -11932,19 +11519,19 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.7 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.14 + brace-expansion: 1.1.16 minimatch@5.1.9: dependencies: - brace-expansion: 2.1.0 + brace-expansion: 2.1.2 minimatch@9.0.9: dependencies: - brace-expansion: 2.1.0 + brace-expansion: 2.1.2 minimist@1.2.8: {} @@ -11967,19 +11554,19 @@ snapshots: monaco-editor@0.55.1: dependencies: - dompurify: 3.4.11 + dompurify: 3.4.12 marked: 14.0.0 ms@2.1.3: {} - msw@2.14.3(@types/node@25.6.0)(typescript@7.0.2): + msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2): dependencies: - '@inquirer/confirm': 6.0.12(@types/node@25.6.0) - '@mswjs/interceptors': 0.41.8 + '@inquirer/confirm': 6.1.1(@types/node@25.9.5) + '@mswjs/interceptors': 0.41.9 '@open-draft/deferred-promise': 3.0.0 '@types/statuses': 2.0.6 cookie: 1.1.1 - graphql: 16.13.2 + graphql: 16.14.2 headers-polyfill: 5.0.1 is-node-process: 1.2.0 outvariant: 1.4.3 @@ -11988,16 +11575,18 @@ snapshots: rettime: 0.11.11 statuses: 2.0.2 strict-event-emitter: 0.5.1 - tough-cookie: 6.0.1 - type-fest: 5.6.0 + tough-cookie: 6.0.2 + type-fest: 5.8.0 until-async: 3.0.2 - yargs: 17.7.2 + yargs: 17.7.3 optionalDependencies: typescript: 7.0.2 transitivePeerDependencies: - '@types/node' + optional: true - mute-stream@3.0.0: {} + mute-stream@3.0.0: + optional: true nan@2.26.2: optional: true @@ -12010,9 +11599,6 @@ snapshots: dependencies: semver: 7.8.1 - node-addon-api@1.7.2: - optional: true - node-addon-api@4.3.0: optional: true @@ -12022,14 +11608,6 @@ snapshots: dependencies: semver: 7.8.1 - node-domexception@1.0.0: {} - - node-fetch@3.3.2: - dependencies: - data-uri-to-buffer: 4.0.1 - fetch-blob: 3.2.0 - formdata-polyfill: 4.0.10 - node-gyp@12.3.0: dependencies: env-paths: 2.2.1 @@ -12038,11 +11616,13 @@ snapshots: nopt: 9.0.0 proc-log: 6.1.0 semver: 7.8.1 - tar: 7.5.16 + tar: 7.5.20 tinyglobby: 0.2.16 undici: 6.27.0 which: 6.0.1 + node-int64@0.4.0: {} + node-pty@1.1.0(patch_hash=407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282): dependencies: node-addon-api: 7.1.1 @@ -12127,7 +11707,8 @@ snapshots: orderedmap@2.1.1: {} - outvariant@1.4.3: {} + outvariant@1.4.3: + optional: true oxfmt@0.52.0: dependencies: @@ -12255,7 +11836,8 @@ snapshots: path-key@4.0.0: {} - path-to-regexp@6.3.0: {} + path-to-regexp@6.3.0: + optional: true path-to-regexp@8.4.2: {} @@ -12281,6 +11863,15 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 + pkijs@3.4.0: + dependencies: + '@noble/hashes': 1.4.0 + asn1js: 3.0.10 + bytestreamjs: 2.0.1 + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + playwright-core@1.59.1: {} playwright@1.59.1: @@ -12295,12 +11886,6 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 - plist@3.1.1: - dependencies: - '@xmldom/xmldom': 0.9.10 - base64-js: 1.5.1 - xmlbuilder: 15.1.1 - pngjs@5.0.0: {} pngjs@7.0.0: {} @@ -12346,6 +11931,8 @@ snapshots: proc-log@6.1.0: {} + process-nextick-args@2.0.1: {} + progress@2.0.3: {} promise-retry@2.0.1: @@ -12445,7 +12032,11 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 - punycode@2.3.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.5: {} qrcode@1.5.4: dependencies: @@ -12618,6 +12209,16 @@ snapshots: transitivePeerDependencies: - supports-color + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + recast@0.23.11: dependencies: ast-types: 0.16.1 @@ -12753,7 +12354,8 @@ snapshots: retry@0.13.1: {} - rettime@0.11.11: {} + rettime@0.11.11: + optional: true reusify@1.1.0: {} @@ -12833,6 +12435,8 @@ snapshots: rw@1.3.3: {} + safe-buffer@5.1.2: {} + safer-buffer@2.1.2: {} sanitize-filename@1.6.4: @@ -12892,11 +12496,12 @@ snapshots: set-blocking@2.0.0: {} - set-cookie-parser@3.1.0: {} + set-cookie-parser@3.1.2: + optional: true setprototypeof@1.2.0: {} - shadcn@4.7.0(@types/node@25.6.0)(typescript@7.0.2): + shadcn@4.13.1(typescript@7.0.2): dependencies: '@babel/core': 7.29.7 '@babel/parser': 7.29.7 @@ -12915,10 +12520,7 @@ snapshots: fast-glob: 3.3.3 fs-extra: 11.3.6 fuzzysort: 3.1.0 - https-proxy-agent: 7.0.6 kleur: 4.1.5 - msw: 2.14.3(@types/node@25.6.0)(typescript@7.0.2) - node-fetch: 3.3.2 open: 11.0.0 ora: 8.2.0 postcss: 8.5.14 @@ -12929,12 +12531,12 @@ snapshots: tailwind-merge: 3.5.0 ts-morph: 26.0.0 tsconfig-paths: 4.2.0 + undici: 7.28.0 validate-npm-package-name: 7.0.2 zod: 3.25.76 zod-to-json-schema: 3.25.2(zod@3.25.76) transitivePeerDependencies: - '@cfworker/json-schema' - - '@types/node' - babel-plugin-macros - supports-color - typescript @@ -13002,13 +12604,6 @@ snapshots: sisteransi@1.0.5: {} - slice-ansi@3.0.0: - dependencies: - ansi-styles: 4.3.0 - astral-regex: 2.0.0 - is-fullwidth-code-point: 3.0.0 - optional: true - slice-ansi@7.1.2: dependencies: ansi-styles: 6.2.3 @@ -13019,9 +12614,6 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 - smart-buffer@4.2.0: - optional: true - smol-toml@1.6.1: {} sonner@2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7): @@ -13065,7 +12657,8 @@ snapshots: stdin-discarder@0.3.2: {} - strict-event-emitter@0.5.1: {} + strict-event-emitter@0.5.1: + optional: true string-argv@0.3.2: {} @@ -13086,6 +12679,10 @@ snapshots: get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -13135,7 +12732,8 @@ snapshots: dependencies: has-flag: 4.0.0 - tagged-tag@1.0.0: {} + tagged-tag@1.0.0: + optional: true tailwind-merge@3.5.0: {} @@ -13143,7 +12741,7 @@ snapshots: tapable@2.3.3: {} - tar@7.5.16: + tar@7.5.20: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -13182,11 +12780,13 @@ snapshots: tinyrainbow@3.1.0: {} - tldts-core@7.0.30: {} + tldts-core@7.4.9: + optional: true - tldts@7.0.30: + tldts@7.4.9: dependencies: - tldts-core: 7.0.30 + tldts-core: 7.4.9 + optional: true tmp-promise@3.0.3: dependencies: @@ -13200,9 +12800,10 @@ snapshots: toidentifier@1.0.1: {} - tough-cookie@6.0.1: + tough-cookie@6.0.2: dependencies: - tldts: 7.0.30 + tldts: 7.4.9 + optional: true trim-lines@3.0.1: {} @@ -13236,9 +12837,10 @@ snapshots: type-fest@0.13.1: optional: true - type-fest@5.6.0: + type-fest@5.8.0: dependencies: tagged-tag: 1.0.0 + optional: true type-is@2.1.0: dependencies: @@ -13277,12 +12879,11 @@ snapshots: undici-types@7.16.0: {} - undici-types@7.19.2: {} + undici-types@7.24.6: {} undici@6.27.0: {} - undici@7.28.0: - optional: true + undici@7.28.0: {} unicorn-magic@0.3.0: {} @@ -13335,7 +12936,16 @@ snapshots: unpipe@1.0.0: {} - until-async@3.0.2: {} + until-async@3.0.2: + optional: true + + unzipper@0.12.5: + dependencies: + bluebird: 3.7.2 + duplexer2: 0.1.4 + fs-extra: 11.3.1 + graceful-fs: 4.2.11 + node-int64: 0.4.0 update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: @@ -13343,10 +12953,6 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7): dependencies: react: 19.2.7 @@ -13376,13 +12982,6 @@ snapshots: vary@1.1.2: {} - verror@1.10.1: - dependencies: - assert-plus: 1.0.0 - core-util-is: 1.0.2 - extsprintf: 1.4.1 - optional: true - vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 @@ -13398,7 +12997,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4): + vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4): dependencies: esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.4) @@ -13407,16 +13006,16 @@ snapshots: rollup: 4.60.3 tinyglobby: 0.2.16 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 yaml: 2.8.4 - vitest@4.1.5(@types/node@25.6.0)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.6.0)(typescript@7.0.2))(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)): + vitest@4.1.5(@types/node@25.9.5)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(msw@2.14.3(@types/node@25.6.0)(typescript@7.0.2))(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + '@vitest/mocker': 4.1.5(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -13433,10 +13032,10 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vite: 7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.6.0 + '@types/node': 25.9.5 happy-dom: 20.9.0 transitivePeerDependencies: - msw @@ -13451,7 +13050,13 @@ snapshots: web-namespaces@2.0.1: {} - web-streams-polyfill@3.3.3: {} + webcrypto-core@1.9.2: + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/json-schema': 1.1.12 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 whatwg-mimetype@3.0.0: {} @@ -13545,7 +13150,7 @@ snapshots: y18n: 4.0.3 yargs-parser: 18.1.3 - yargs@17.7.2: + yargs@17.7.3: dependencies: cliui: 8.0.1 escalade: 3.2.0