` instead of the group-bound
+ primitive. (`SelectLabel` kept as `GroupLabel` — unused; select labels are
+ idiomatically inside `SelectGroup`.)
+2. **Menu item `onSelect` silently stopped firing.** Base UI `Menu.Item` activates
+ via `onClick`, not `onSelect` (which now maps to the native DOM handler and
+ type-checks), so the typecheck-driven sweep missed it. Converted **286** sites
+ (DropdownMenuItem/ContextMenuItem/CheckboxItem/RadioItem + the `Item` alias)
+ `onSelect`→`onClick`, adding `closeOnClick={false}` where the handler used
+ `preventDefault` (Radix keep-open) — 26 sites. cmdk `CommandItem.onSelect` and
+ custom components' own `onSelect` props left untouched.
+
+## Behavior deltas (flagged, not silently patched)
+1. **Tabs** default to manual activation (Base UI default; matches shadcn base registry).
+2. **Menu** checkbox/radio items default to *not* closing on select.
+3. **Positioner defaults**: collision/arrow padding 0→5; tooltip sideOffset 0→4;
+ hover delays shifted.
+4. **Dropped non-forwarded positioner props** at a few call sites (the wrappers
+ forward only align/side/offsets): `avoidCollisions={false}` (TaskPage reviewer
+ popover, NativeChatExperimentalSetting select), `collisionPadding`
+ (WorkspaceKanbanSettingsMenu, SourceControlActionVariableChips,
+ WorkspaceSpaceManagerPanel, BrowserPane), `side` on some SelectContent (== Base
+ default). Restore by forwarding the extra Positioner props from the wrappers.
+5. **Tooltip Root `delayDuration` dropped**: `ResourceUsageStatusSegment`'s 7
+ custom-delay tooltips now use the default hover delay (Base UI has no delay on
+ `Tooltip` Root).
+6. **Dismiss/focus rewrites**: several `onInteractOutside`/`onOpenAutoFocus`
+ handlers were rewritten to the typed `onOpenChange`+reason+`cancel()` /
+ `initialFocus` paths — intent preserved; smoke-test.
+
+## Final verification
+- `tsc` node + cli + web: **0 errors**.
+- `pnpm build:web`: **pass**. `oxlint src/renderer/src`: **0 errors**.
+- Not run: full electron/native build (`build:native`) — needs platform toolchains;
+ the renderer (all changes) bundles clean via `build:web`.
+
+## Derived status
+0 wrappers remain on Radix.
diff --git a/.migration/scroll-area.md b/.migration/scroll-area.md
new file mode 100644
index 00000000000..bfc722c80c7
--- /dev/null
+++ b/.migration/scroll-area.md
@@ -0,0 +1,16 @@
+# scroll-area
+
+2026-07-16. Transformation engine (legacy `new-york-v4`). Renames only.
+
+## Changed
+- `ui/scroll-area.tsx`: → `@base-ui/react/scroll-area`; `ScrollAreaScrollbar` →
+ `Scrollbar`, `ScrollAreaThumb` → `Thumb`. Structure/classes preserved.
+
+## Left alone
+- No `type`/`scrollHideDelay` existed.
+
+## Behavior changes
+- Scrollbar visibility is CSS-driven; no visible change (wrapper never set `type`).
+
+## Verify by hand
+- Scroll a long list/panel: scrollbar appears, thumb drags.
diff --git a/.migration/select.md b/.migration/select.md
new file mode 100644
index 00000000000..7a2c5880aa7
--- /dev/null
+++ b/.migration/select.md
@@ -0,0 +1,24 @@
+# select
+
+2026-07-16. Transformation engine (legacy `new-york-v4`). Restructured.
+
+## Changed
+- `ui/select.tsx`: → `@base-ui/react/select`; `Select` bare re-export of Root;
+ `Content` → `Portal > Positioner > Popup` (`isolate z-50` on Popup); `Viewport`→
+ `List`, `ScrollUp/DownButton`→`ScrollUp/DownArrow`, `Label`→`GroupLabel`; item
+ anatomy ItemText-first. Dropped `position`; exposes `alignItemWithTrigger`.
+ Item highlight `focus:*` → `data-highlighted:*` (trigger keeps `focus-visible:`).
+- Call sites: `position="popper"`→`alignItemWithTrigger={false}`; `onValueChange`
+ widened to `string | null` (null-guarded).
+
+## Left alone
+- `SelectLabel` kept as `GroupLabel` (unused; select labels belong in `SelectGroup`).
+
+## Behavior changes
+- `onValueChange` receives `value | null` + eventDetails. `side`/`collisionAvoidance`
+ not forwarded — a couple sites dropped `side="bottom"` (== default) and one
+ `avoidCollisions={false}` (delta #4).
+
+## Verify by hand
+- Open selects (device pickers, settings dropdowns): options open aligned to the
+ trigger, keyboard highlights options, selecting updates value + shows the check.
diff --git a/.migration/separator.md b/.migration/separator.md
new file mode 100644
index 00000000000..bba40067a4c
--- /dev/null
+++ b/.migration/separator.md
@@ -0,0 +1,17 @@
+# separator
+
+2026-07-16. Transformation engine (legacy `new-york-v4`). Callable primitive.
+
+## Changed
+- `ui/separator.tsx`: → `@base-ui/react/separator`; callable (no `.Root`); dropped
+ hardcoded `decorative`; kept `orientation`.
+- `decorative` removed at ~21 call sites.
+
+## Left alone
+- Nothing relevant.
+
+## Behavior changes
+- Always semantic (`role="separator"`); no call site depended on decorative semantics.
+
+## Verify by hand
+- Separators render at the right orientation between sections/menu groups.
diff --git a/.migration/sheet.md b/.migration/sheet.md
new file mode 100644
index 00000000000..924894d8f35
--- /dev/null
+++ b/.migration/sheet.md
@@ -0,0 +1,20 @@
+# sheet
+
+2026-07-16. Transformation engine (legacy `new-york-v4`). Dialog primitive, side slides.
+
+## Changed
+- `ui/sheet.tsx`: → `@base-ui/react/dialog`; `Overlay`→`Backdrop`, `Content`→`Popup`;
+ per-side slide rewritten to `data-starting-style`/`data-ending-style` translates
+ keyed on each `side` (300ms open / 200ms close preserved).
+- Call sites: SheetContent `onOpenAutoFocus`→`initialFocus`; VisuallyHidden titles →
+ `className="sr-only"`.
+
+## Left alone
+- Side variants and sizing classes unchanged.
+
+## Behavior changes
+- Same dismiss/focus model change as dialog.
+
+## Verify by hand
+- Open issue drawers (Jira/Linear/GitLab/GitHub project): slides in from the right
+ side, has an (sr-only) title, no focus steal where autofocus was prevented.
diff --git a/.migration/slider.md b/.migration/slider.md
new file mode 100644
index 00000000000..dee8f6f585b
--- /dev/null
+++ b/.migration/slider.md
@@ -0,0 +1,20 @@
+# slider
+
+2026-07-16. Transformation engine (legacy `new-york-v4`). Restructured.
+
+## Changed
+- `ui/slider.tsx`: → `@base-ui/react/slider`; `Root > Control > Track >
+ (Indicator, Thumb)`; `Range` → `Indicator`; layout classes moved Root → Control;
+ added `thumbAlignment="edge"`.
+- Call sites: `onValueCommit` → `onValueCommitted`; value handlers widened to
+ `number | readonly number[]`.
+
+## Left alone
+- Single `Thumb` kept; dead `disabled:*` on Thumb left as-is.
+
+## Behavior changes
+- `thumbAlignment="edge"` preserves Radix positioning; value callbacks may deliver arrays.
+
+## Verify by hand
+- Drag a slider (notification volume, terminal settings): thumb tracks, value
+ updates live, commit fires on release.
diff --git a/.migration/tabs.md b/.migration/tabs.md
new file mode 100644
index 00000000000..7166143d8f7
--- /dev/null
+++ b/.migration/tabs.md
@@ -0,0 +1,17 @@
+# tabs
+
+2026-07-16. Transformation engine (legacy `new-york-v4`).
+
+## Changed
+- `ui/tabs.tsx`: → `@base-ui/react/tabs`; `Trigger` → `Tab`, `Content` → `Panel`;
+ `data-[state=active]:` → `data-active:`.
+
+## Left alone
+- Did not add `activateOnFocus` (matches shadcn base registry).
+
+## Behavior changes
+- **Manual activation**: Base UI defaults `activateOnFocus={false}`; tabs activate
+ on click/Enter, not arrow-key focus. Flagged, not patched.
+
+## Verify by hand
+- Arrow-key through a tab list: panel changes only on Enter/Space; click switches immediately.
diff --git a/.migration/toggle-group.md b/.migration/toggle-group.md
new file mode 100644
index 00000000000..651e8507a52
--- /dev/null
+++ b/.migration/toggle-group.md
@@ -0,0 +1,20 @@
+# toggle-group
+
+2026-07-16. Transformation engine (legacy `new-york-v4`).
+
+## Changed
+- `ui/toggle-group.tsx`: group → `@base-ui/react/toggle-group` (callable), items
+ reuse `@base-ui/react/toggle`.
+- Call sites (13): removed `type="single"`; `value={x}` → `value={[x]}`; handlers
+ read `value[0]`.
+
+## Left alone
+- Custom `data-[variant]/[size]/[spacing]` attrs unchanged.
+
+## Behavior changes
+- `value`/`defaultValue` are arrays now; single-select preserved via `value[0]`.
+ Roving focus always on (no opt-out; nothing relied on disabling it).
+
+## Verify by hand
+- Each single-select group (view switches, scope filters, kanban group-by) selects
+ exactly one option.
diff --git a/.migration/toggle.md b/.migration/toggle.md
new file mode 100644
index 00000000000..09858068fc0
--- /dev/null
+++ b/.migration/toggle.md
@@ -0,0 +1,16 @@
+# toggle
+
+2026-07-16. Transformation engine (legacy `new-york-v4`). Direct.
+
+## Changed
+- `ui/toggle.tsx`: → `@base-ui/react/toggle`; callable primitive (`.Root` dropped);
+ `data-[state=on]:` → `data-pressed:`.
+
+## Left alone
+- `toggleVariants` and classes unchanged.
+
+## Behavior changes
+- None (presence-attribute rename only).
+
+## Verify by hand
+- A standalone toggle reflects pressed styling when active.
diff --git a/.migration/tooltip.md b/.migration/tooltip.md
new file mode 100644
index 00000000000..31cb6b64d8a
--- /dev/null
+++ b/.migration/tooltip.md
@@ -0,0 +1,23 @@
+# tooltip
+
+2026-07-16. Transformation engine (legacy `new-york-v4`). Positioner model.
+
+## Changed
+- `ui/tooltip.tsx`: → `@base-ui/react/tooltip`; `Content` → `Portal > Positioner >
+ Popup`; Provider `delayDuration` → `delay`; `disableHoverableContent` → Root
+ `disableHoverablePopup`; Positioner `isolate z-[90]`.
+- Call sites: `TooltipProvider delayDuration`→`delay`, `skipDelayDuration`→`timeout`
+ (~40 files); `TooltipTrigger asChild`→`render`.
+
+## Left alone
+- Kept the user's exact arrow classes.
+
+## Behavior changes
+- Defaults: sideOffset 0→4; hover delay 700→600; skip 300→400; collision/arrow padding 0→5.
+- `
` on Root is unsupported; most sites wrapped in
+ ``, but `ResourceUsageStatusSegment`'s 7 custom-delay
+ tooltips dropped to default delay (delta #5 in project.md).
+
+## Verify by hand
+- Hover tooltips (sidebar cards, toolbar): appear after delay, correct side, arrow
+ points at trigger. Check status-bar tooltips feel OK (now default delay).
diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs
index 0dd6a3d85c7..2b7b5576caa 100644
--- a/config/electron-builder.config.cjs
+++ b/config/electron-builder.config.cjs
@@ -208,10 +208,9 @@ module.exports = {
shortcutName: '${productName}',
uninstallDisplayName: '${productName}',
createDesktopShortcut: 'always',
- // Why: on a real uninstall, stop and remove the relocated terminal daemon
- // (which lives outside the install dir under LOCALAPPDATA by design). Guarded
- // by ${isUpdated} inside so it never runs during an update's uninstallOldVersion.
- include: resolve(__dirname, 'nsis', 'daemon-host-uninstall.nsh')
+ // Why: install-time firewall setup and real-uninstall cleanup share hooks;
+ // daemon cleanup stays guarded from an update's uninstallOldVersion pass.
+ include: resolve(__dirname, 'nsis', 'windows-installer-hooks.nsh')
},
mac: {
icon: 'resources/build/icon.icns',
diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt
index 543ff2e6a60..d7e7b67000a 100644
--- a/config/max-lines-baseline.txt
+++ b/config/max-lines-baseline.txt
@@ -224,7 +224,6 @@ inline src/renderer/src/components/right-sidebar/FileExplorerRow.tsx
inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
inline src/renderer/src/components/right-sidebar/SourceControl.tsx
inline src/renderer/src/components/right-sidebar/checks-panel-content.tsx
-inline src/renderer/src/components/right-sidebar/index.tsx
inline src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts
inline src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts
inline src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts
diff --git a/config/nsis/daemon-host-uninstall.nsh b/config/nsis/daemon-host-uninstall.nsh
deleted file mode 100644
index dc3a497ce67..00000000000
--- a/config/nsis/daemon-host-uninstall.nsh
+++ /dev/null
@@ -1,23 +0,0 @@
-; Clean up the relocated terminal daemon on a REAL uninstall.
-;
-; Why: the daemon host is deliberately copied to a distinct image name
-; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app
-; UPDATES cannot kill it — that relocation is what keeps terminals alive across
-; updates. The same design means a normal uninstall's process sweep and file
-; removal both miss it, leaving an orphaned daemon plus its runtime copy behind.
-;
-; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as
-; part of uninstallOldVersion on EVERY update, and killing the daemon there would
-; defeat the whole feature. Only clean up on a genuine uninstall.
-;
-; The image name and the LOCALAPPDATA folder name must stay in sync with
-; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
-; src/main/daemon/daemon-host-relocation.ts.
-!macro customUnInstall
- ${ifNot} ${isUpdated}
- nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
- ; Give the OS a moment to release the image lock before removing the tree.
- Sleep 500
- RMDir /r "$LOCALAPPDATA\Orca\daemon-host"
- ${endIf}
-!macroend
diff --git a/config/nsis/windows-installer-hooks.nsh b/config/nsis/windows-installer-hooks.nsh
new file mode 100644
index 00000000000..9c3358fe2f0
--- /dev/null
+++ b/config/nsis/windows-installer-hooks.nsh
@@ -0,0 +1,18 @@
+; Windows install/uninstall hooks for Spool networking and daemon cleanup.
+; Keep 52777 in sync with SPOOL_INGRESS_PORT in spool-wire-contract.ts.
+!macro customInstall
+ nsExec::Exec 'netsh advfirewall firewall delete rule name="Orca.Spool"'
+ nsExec::Exec 'netsh advfirewall firewall add rule name="Orca.Spool" description="Allows Orca Spool sharing over Tailscale." dir=in action=allow enable=yes profile=private protocol=TCP localport=52777 program="$INSTDIR\Orca.exe" edge=no'
+!macroend
+
+!macro customUnInstall
+ ; Why: the relocated daemon deliberately survives updates; only a real uninstall may remove it.
+ ; Keep the image and folder names in sync with daemon-host-relocation.ts.
+ ${ifNot} ${isUpdated}
+ nsExec::Exec 'netsh advfirewall firewall delete rule name="Orca.Spool"'
+ nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
+ ; Give the OS a moment to release the image lock before removing the tree.
+ Sleep 500
+ RMDir /r "$LOCALAPPDATA\Orca\daemon-host"
+ ${endIf}
+!macroend
diff --git a/docs/reference/2026-07-14-spool-tailnet-worktree-sharing-architecture.md b/docs/reference/2026-07-14-spool-tailnet-worktree-sharing-architecture.md
new file mode 100644
index 00000000000..a1d97f9f60c
--- /dev/null
+++ b/docs/reference/2026-07-14-spool-tailnet-worktree-sharing-architecture.md
@@ -0,0 +1,1061 @@
+# Spool Tailnet Worktree Sharing — Architecture
+
+**Date:** 2026-07-14
+
+**Status:** Implemented.
+
+**Product model:** [Spool Tailnet Worktree Sharing — Product Plan](./plans/2026-07-14-spool-team-session-sharing.md)
+
+## Outcome
+
+Spool is a Tailnet-only remote-worktree capability inside Orca Desktop. Each Desktop discovers other running Desktops, receives a server-produced projection of their Public worktrees, and opens those worktrees through a dedicated encrypted connection. Public access is read-only. An owner can grant one physical connection mutable access to one whole worktree, including semantic owner-side terminal and agent creation. The grant disappears on any connection loss and is never replayed.
+
+The architecture has six security-critical properties:
+
+1. Spool has one dedicated fixed-port ingress for both discovery and encrypted WebSocket traffic. It reuses Orca's crypto, framing, terminal, file, Git, and execution Modules without inheriting the existing mobile/runtime admission surface.
+2. Tailnet source identity comes from Tailscale, while one-time tickets bind that identity to one E2EE client key and one connection attempt.
+3. The host serializes a Public-only catalog. A requester never receives the owner's full repos, account state, AI Vault, paths, host targets, or credentials and then filters them locally.
+4. Spool has a separate, default-deny RPC registry. Every exposed operation owns its external schema, resource binding, access rule, execution Adapter, result projection, and error projection.
+5. Visibility and control are enforced by the owner process. Renderer gating is a usability mirror, not the authorization boundary.
+6. One physical encrypted WebSocket carries catalog updates, RPC calls, and subscriptions. Losing it invalidates every pending request and grant associated with it.
+
+## Scope decisions
+
+This design covers Orca Git worktrees and the synthetic workspaces of a `Repo.kind = 'folder'` project on local, WSL, SSH, and paired runtime execution hosts. A folder project's root workspace uses `repoId::path`; additional workspace instances use `repoId::path::workspace:`. Independent ProjectGroup-backed `FolderWorkspace` entries keyed as `folder:` remain deferred; they do not yet participate in Spool visibility or publication.
+
+A folder-project workspace gets a durable incarnation from a random `.orca-spool-incarnation-v1` marker created at its canonical root. The marker is owner-only Spool metadata: Files hides it and rejects direct or symlinked access. The actual host sandwiches marker access between stable directory `dev`/`ino` checks, then derives the published UUID from the marker, actual-host scope, and directory identity. Renaming or moving the same directory preserves the proof, while replacing the directory or copying its marker does not. A host that cannot provide stable directory identity or durable marker storage cannot publish that folder workspace.
+
+The first version does not expose browser profiles, cookies, OS dialogs, the owner's clipboard, settings, account selection, hosted-review mutations, issue trackers, automations, emulator/computer control, pairing administration, or SSH trust and credential prompts. These are not reliably worktree-scoped and are not covered by the approval copy.
+
+With control, the requester may select `New Terminal` or one agent from the owner's sanitized enabled/detected list. It never supplies a command, working directory, environment, path, prompt, account, or launch argument. The Owner Desktop resolves those details on the actual execution host.
+
+Once a control grant exists, a terminal is still a normal shell and can exercise the owner's broader OS-user authority. The narrower GUI RPC surface prevents accidental cross-worktree actions; it is not a sandbox.
+
+## Ubiquitous language
+
+| Term | Meaning |
+| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Owner Desktop** | The running Orca Desktop process that publishes worktrees and executes remote operations. |
+| **Requester Desktop** | The running Orca Desktop process browsing or requesting control of another Desktop. |
+| **Tailnet principal** | Tailscale-authenticated source node identity obtained from the inbound network flow. It identifies a machine/node, not a human account inside Spool. |
+| **Connection** | One physical E2EE WebSocket. It is the smallest grant lifetime and has one server-generated `connectionId`. |
+| **Connection epoch** | Requester-side generation that changes immediately whenever the physical socket changes. UI control state is keyed by it. |
+| **Worktree instance** | One durable workspace incarnation, represented by `WorktreeMeta.instanceId` and validated against an owner-only Git or folder marker on the actual host. |
+| **Share epoch** | A generation for one worktree's current Public publication. It rotates on Private, delete, incarnation change, and later re-publication. |
+| **Public projection** | The sanitized Desktop → Project → Worktree → Session catalog serialized by the owner. |
+| **Control grant** | An in-memory capability for one connection and one Public worktree instance. Several connections may hold separate grants concurrently. |
+| **Opaque reference** | A random, connection-scoped identifier that the owner resolves to a project, worktree, session, terminal, file root, or pane. It is never a raw path or host ID. |
+| **Created session** | An owner-owned terminal or agent process started inside a Public worktree after a granted requester selects a semantic launch option. |
+
+## System view
+
+```text
+Requester Desktop Owner Desktop
+
+TailscaleCommandAdapter TailscaleCommandAdapter
+ │ │
+TailnetPeerDirectory ── POST /spool/v1/probe :SPOOL_INGRESS_PORT ──► SpoolIngress
+ │ │ whois(source)
+ │◄── descriptor + E2EE key + one-use ticket ───┤
+ │ │
+SpoolPeerConnection ── WS /spool/v1/connect :SPOOL_INGRESS_PORT ──►│
+ │ one E2EE socket │
+SpoolDesktopCatalog SpoolRpcGateway
+ │ ├─ SpoolShareCatalog
+ │ ├─ SpoolAccessAuthority
+ │ └─ SpoolExecutionGateway
+ │ │
+ │ local / WSL / SSH / runtime
+ ▼ ▼
+renderer slice/UI existing Orca execution
+```
+
+The Tailnet is the discovery population and network path. It is not a Spool account system. Orca still performs application authentication, projection, authorization, and revocation.
+
+## Module map
+
+The design favors a small number of deep Modules. Their Interfaces hide platform differences, identity mapping, lifecycle cleanup, and existing Orca execution details.
+
+### TailnetControl
+
+`TailnetControl` is the Seam between Spool and the installed Tailscale client:
+
+```ts
+interface TailnetControl {
+ readSnapshot(): Promise
+ identifySource(address: TailnetFlowAddress): Promise
+}
+```
+
+The first production Adapter is `TailscaleCommandAdapter`. It invokes the binary with `execFile`, never through a shell, and owns:
+
+- Ordered lookup on `PATH`, the standard macOS app bundle location, standard Linux locations, and standard Windows installation locations.
+- Strict command timeouts, stdout/stderr byte caps, bounded concurrency, and cancellation.
+- Defensive parsing of `tailscale status --json` and `tailscale whois --json`.
+- IPv4, IPv6, IPv4-mapped IPv6, DNS-name, and node-ID normalization.
+- A short successful-identity cache and per-source lookup rate limit so probes cannot create unbounded subprocesses.
+- Clear `unavailable`, `not-running`, `permission-denied`, `unsupported-output`, and `timed-out` diagnostics.
+
+The Interface permits a LocalAPI Adapter later without changing Spool. Tailscale documents `status --json` and `whois --json` for automation but warns that JSON output can change, so missing identity fields always fail closed. See the [Tailscale CLI reference](https://tailscale.com/docs/reference/tailscale-cli) and [identity documentation](https://tailscale.com/docs/concepts/tailscale-identity).
+
+### TailnetPeerDirectory
+
+`TailnetPeerDirectory` owns requester-side discovery:
+
+```ts
+interface TailnetPeerDirectory {
+ snapshot(): readonly DiscoveredSpoolDesktop[]
+ subscribe(listener: (snapshot: readonly DiscoveredSpoolDesktop[]) => void): () => void
+ start(): void
+ stop(): void
+}
+```
+
+It reads only peers enumerated by `TailnetControl`; it never scans `100.64.0.0/10`. It probes all advertised Tailnet IPs with bounded parallelism and deduplicates them by verified node ID. A successful probe is authoritative that Orca Desktop is running. A peer is removed after two missed reconciliation passes to avoid one transient timeout flickering the sidebar.
+
+`Online` from `tailscale status` is advisory. The Spool probe and encrypted connection decide usability.
+
+### SpoolIngress
+
+`SpoolIngress` is a dedicated HTTP/WebSocket listener. V1 fixes `SPOOL_INGRESS_PORT = 52777`, inside IANA's dynamic/private range, and binds only the current local Tailscale addresses. It runs only in Orca Desktop mode, not `orca serve`.
+
+The same listener serves:
+
+- `POST /spool/v1/probe` for source verification and one-time ticket issuance.
+- `GET /spool/v1/connect` with WebSocket upgrade for the one physical encrypted connection.
+
+There is no dynamic second RPC port. One fixed port means one Tailnet ACL/firewall decision, and the listener never shares admission logic with paired mobile or runtime clients. If the port cannot bind, Spool reports a local diagnostic and remains disabled; normal Orca runtime/mobile RPC continues unaffected. No random fallback is advertised because peers would have no trustworthy way to find it.
+
+The [IANA registry](https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml) defines `49152–65535` as dynamic/private rather than assigned service ports. `52777` avoids Orca's existing `6768`/`6769` runtime ports and is a wire-protocol constant, not a user preference. Because an ephemeral local connection can still occupy it, port-collision handling remains mandatory.
+
+`SpoolIngress` owns one listener per current Tailnet IPv4/IPv6 address on the same port. It reconciles additions and removals when the Tailnet snapshot changes, using IPv6-only socket options where needed so dual-stack binds do not collide. Existing sockets on a removed address close and therefore revoke normally. It never falls back to a wildcard bind.
+
+On Windows, a Spool-specific firewall rule is created, updated, and removed through the same platform-aware pattern as Orca's current mobile rule, but scoped to the Spool executable and fixed port. Failure surfaces an actionable diagnostic rather than silently claiming the Desktop is discoverable.
+
+The listener enforces bounded request bodies, a pre-auth timeout, per-node connection limits, total connection limits, probe rate limits, and a protocol-version negotiation header. It rejects browser Origin/CORS requests and forwarded-address headers; only the socket's normalized remote address participates in identity.
+
+The probe descriptor is limited to protocol versions, owner runtime ID, Spool public key/fingerprint, Orca version, and OS family. User and device display names come from the requester's verified Tailnet snapshot, not a peer-supplied label. Catalog, quota, paths, account state, and host inventory are available only after encrypted authentication and projection.
+
+### SpoolTicketAuthority
+
+`SpoolTicketAuthority` owns short-lived connection admission:
+
+```ts
+interface SpoolTicketAuthority {
+ issue(binding: SpoolTicketBinding): SpoolTicket
+ consume(ticket: string, binding: SpoolTicketBinding): AuthenticatedSpoolPrincipal | null
+ clear(): void
+}
+```
+
+A ticket is random, memory-only, one-use, and expires after 30 seconds. It is bound to:
+
+- Verified requester Tailnet node ID and source Tailnet IP.
+- Requester's ephemeral E2EE public key from the probe.
+- Owner runtime ID and owner Spool key fingerprint.
+- Negotiated Spool protocol version.
+
+The ticket contains no capability and grants no catalog access until consumed during the encrypted handshake. It cannot be used as a paired runtime token.
+
+### SpoolPeerConnection
+
+`SpoolPeerConnection` owns exactly one requester-side physical socket per Owner Desktop:
+
+```ts
+interface SpoolPeerConnection {
+ request(method: string, params: unknown): Promise>
+ subscribe(method: string, params: unknown, sink: SpoolSink): SpoolSubscription
+ subscribeState(listener: (state: SpoolConnectionState) => void): () => void
+ close(): void
+}
+```
+
+It reuses extracted Orca E2EE framing, bounded backpressure, and heartbeat, but it does not inherit `RemoteRuntimeSharedControlConnection`: that class has different reconnect/replay semantics and currently permits separate stream sockets. Spool V1 keeps terminal output and control on the checked JSON RPC registry; inbound binary frames fail the physical connection closed instead of opening a second authorization path.
+
+On socket loss, `SpoolPeerConnection` synchronously:
+
+1. Increments its connection epoch.
+2. Publishes `read-only/disconnected` to the requester UI.
+3. Rejects all pending mutations; a request whose outcome is unknown is reported as `outcome_unknown`.
+4. Ends logical streams and drops all local grant state.
+5. Notifies `SpoolDesktopCatalog`; it does not perform discovery or reconnect itself.
+
+After a fresh socket authenticates, it may recreate catalog and selected Public-read subscriptions. It never replays a control request, approval, terminal input, file write, Git mutation, session mutation, or other side effect.
+
+### SpoolDesktopCatalog
+
+Requester-side `SpoolDesktopCatalog` is the sole coordinator above discovery and connections:
+
+```ts
+interface SpoolDesktopCatalog {
+ snapshot(): readonly SpoolRemoteDesktop[]
+ subscribe(listener: (snapshot: readonly SpoolRemoteDesktop[]) => void): () => void
+ requestControl(target: SpoolControlTarget): Promise
+ revokeLocalConnection(desktopRef: string): void
+}
+```
+
+It owns `TailnetPeerDirectory`, creates and replaces `SpoolPeerConnection` instances, obtains fresh tickets, restores only read subscriptions, and projects main-process facts to IPC. This is the only requester-side Module that decides when to reconnect. `SpoolPeerConnection` owns a socket; `TailnetPeerDirectory` owns peer reconciliation; neither calls the other.
+
+The active/selected remote workspace route is renderer navigation state and does not live in `SpoolDesktopCatalog`.
+
+### SpoolWorktreeVisibility
+
+`SpoolWorktreeVisibility` is the only Module allowed to interpret or mutate sharing fields in `WorktreeMeta`:
+
+```ts
+interface SpoolWorktreeVisibility {
+ snapshot(): SpoolVisibilitySnapshot
+ setWorktree(worktreeId: string, visibility: 'public' | 'private'): void
+ setProject(projectId: string, visibility: 'public' | 'private'): void
+ resolvePublicInstance(instanceId: string, shareEpoch: string): PublicWorktreeInstance | null
+ subscribe(listener: (change: SpoolVisibilityChange) => void): () => void
+}
+```
+
+`WorktreeMeta` gains:
+
+```ts
+spoolVisibility?: 'public' | 'private' // missing means private
+spoolIncarnationId?: string
+```
+
+The current path-derived `Worktree.id` is not an authorization identity. `instanceId` names the logical workspace, while `spoolIncarnationId` proves that its current Git worktree or folder directory is the incarnation that the owner published.
+
+Publishing a worktree performs these steps on its actual execution host:
+
+1. Resolve its canonical root and actual-host scope.
+2. For a Git target, resolve the per-worktree Git administrative directory and read or create its private random marker. For a folder target, read or create the hidden random marker at the canonical root while stable `dev`/`ino` evidence proves that the root did not change around marker access.
+3. Compare it with persisted `spoolIncarnationId`; a mismatch rotates `instanceId`, clears session provenance, and leaves the worktree Private.
+4. Reject overlapping registered roots. Synthetic workspaces from the same folder repo may share one exactly equal root; cross-repo overlap and ancestor/descendant overlap remain rejected.
+5. Persist Public synchronously and atomically.
+6. Rotate the share epoch and only then publish the worktree.
+
+Every startup and host reconnection revalidates the incarnation proof before initial publication. Missing Git support for a Git target, unavailable stable directory identity or marker storage for a folder target, an unavailable execution host, or an ambiguous/overlapping root is `not shareable`, never an optimistic Public state. After a successful publication, a transport-classified host outage may retain only the same-epoch sanitized catalog row; every operation remains unavailable until incarnation revalidation succeeds again. If a new disallowed overlapping root appears beneath a Public root, publication is suspended fail closed until the owner resolves the overlap.
+
+`resolvePublicInstance` also revalidates the incarnation proof when binding every supported structured file, Git, Checks, session, and terminal subscription operation, and mutation admission revalidates it again at its commit/spawn guard. A host-side reconciliation loop invalidates long-lived streams when the proof or root disappears; it is an acceleration, not a substitute for bind/commit checks.
+
+### Visibility durability
+
+The existing `Store.setWorktreeMeta()` is debounced and cannot protect a security state. `Store` therefore adds one narrow operation, `commitSpoolVisibility(changes)`, which mutates a cloned state, uses the existing synchronous atomic `flushOrThrow()` path, and rolls back on failure.
+
+A small deny journal in the canonical user-data directory closes the Private crash window:
+
+- **Make Private:** synchronously add affected instance IDs to the atomic deny journal; change the in-memory visibility epoch; revoke grants and purge streams; commit Private metadata; then remove the now-redundant journal entries.
+- **Make Public:** validate the host incarnation; commit Public metadata; remove any deny entry; then advertise a fresh share epoch.
+- **Startup:** apply deny entries before starting `SpoolIngress`, repair metadata to Private, and only then consider persisted Public entries.
+
+If either security store cannot write, Spool stops its ingress and surfaces the persistence failure. It does not continue sharing from uncertain state.
+
+Project bulk actions resolve the project's current worktree IDs once and commit them in one visibility transaction. `Make all public` prevalidates every current worktree and changes none if any incarnation/root cannot be proven; `Make all private` journals and invalidates the whole resolved set together. A worktree created afterwards has no `spoolVisibility` value and is therefore Private.
+
+### SpoolShareCatalog
+
+`SpoolShareCatalog` owns the only remotely serializable model:
+
+```ts
+interface SpoolShareCatalog {
+ openProjection(principal: AuthenticatedSpoolPrincipal): SpoolCatalogProjection
+ closeProjection(connectionId: string): void
+}
+```
+
+Each connection gets a distinct `SpoolCatalogProjection` and a fresh opaque-reference table. References are 128-bit random values and rotate on connection replacement, Private, delete, incarnation change, or re-publication. The projection contains:
+
+```ts
+type SpoolDesktopCatalog = {
+ protocolVersion: number
+ ownerRuntimeId: string
+ catalogRevision: number
+ quota: readonly SpoolProviderQuota[]
+ projects: readonly SpoolProjectCatalogEntry[]
+}
+
+type SpoolProjectCatalogEntry = {
+ projectRef: string
+ name: string
+ worktrees: readonly SpoolWorktreeCatalogEntry[]
+}
+
+type SpoolWorktreeCatalogEntry = {
+ kind: 'git' | 'folder'
+ worktreeRef: string
+ shareEpoch: string
+ name: string
+ branch: string | null
+ sessions: readonly SpoolSessionCatalogEntry[]
+ sessionCatalog: {
+ status: 'loading' | 'complete' | 'error'
+ nextCursor: string | null
+ }
+}
+
+type SpoolSessionCatalogEntry =
+ | { sessionRef: string; kind: 'terminal'; agent: null; title: string }
+ | {
+ sessionRef: string
+ kind: 'agent'
+ agent: SpoolAgentLaunchId | null
+ title: string
+ }
+```
+
+The `kind` discriminator keeps plain shells out of agent-only views. Known agents retain their bounded protocol agent ID (including Gemini and OpenCode); an observed custom agent uses `kind: 'agent', agent: null` rather than widening the protocol enum or being mislabeled as a terminal. The wire model deliberately omits absolute paths, `instanceId`, repo IDs, execution-host IDs, SSH target IDs, pairing data, activity/status categories, AI Vault file paths, Codex home paths, and resume commands. A project with no Public worktrees is omitted. The Desktop and sanitized quota may remain visible even when the projects array is empty.
+
+The owner catalog stream sends bounded project/worktree base rows. `catalog.sessions.page` then returns at most 512 sessions behind a connection-scoped opaque cursor until every attributable session reaches `complete`; the requester main process merges pages before projecting them to the renderer. A failed or malformed chain is `error`, never a shorter list presented as complete, and retries read-only pages with bounded exponential backoff until a new catalog or disconnect cancels the chain. Quota-only updates retain the identity-bearing `catalogRevision` and reuse in-flight or completed worktrees instead of restarting pagination.
+
+Snapshots and pages carry `ownerRuntimeId` in the authenticated envelope, `catalogRevision`, and worktree `shareEpoch`; page cursors are additionally bound to the projection generation and physical connection. A consumer discards a late result that does not match every applicable generation. Private, delete, incarnation change, or connection replacement invalidates the whole chain, including session aliases learned on later pages.
+
+### SessionCatalog
+
+`SessionCatalog` is an internal part of `SpoolShareCatalog`, not a global AI Vault passthrough. Claude/Codex AI Vault files are external records and are not modified to add Spool fields. A separate local `SpoolSessionProvenanceIndex` persists:
+
+```text
+(execution-host identity, provider, provider session ID)
+ → (worktree instanceId, spool incarnationId)
+```
+
+The index lives in canonical user data as an atomically replaced, versioned `spool-session-provenance.json`; it never leaves the owner. Session create, resume, and proven live binding update it. Incarnation rotation and worktree deletion purge its entries. Losing an update can only hide a session, not publish an unproven one. The catalog merges:
+
+- Workspace tabs and live terminal handles.
+- Claude and Codex session records.
+- Historical AI Vault records that have proven worktree-instance provenance.
+
+Agent session ID is the preferred deduplication key. Execution host plus canonical current root is a secondary consistency check, not the durable identity.
+
+The first time an owner makes an existing worktree Public, the publication confirmation bulk-attests legacy candidates whose execution host and canonical CWD resolve to that worktree as the most-specific registered root. Those candidates are written to the provenance index before Public is committed; there is no per-session visibility toggle. A candidate with a missing host/CWD or ambiguous root is not attributable to that worktree and remains undisclosed. After this one-time migration, all newly observed sessions carry durable provenance.
+
+Selecting a live session attaches a read subscription to its terminal. Selecting a historical session does not create a requester-side transcript surface. After worktree control is granted, the owner resolves the saved record, constructs its resume command internally, starts the exact Claude or Codex agent session, and returns directly to the terminal surface. The requester never receives or supplies a resume command.
+
+Historical-to-live handoff does not wait for pagination. `SpoolTerminalAttachmentRegistry` binds the original connection-scoped session alias to the owner PTY before `session.continue` responds. Terminal subscribe/input/resize resolve the attachment first, then the catalog.
+
+Owner-side creation follows the same immediate-attachment rule. Before `terminal.create` returns, the owner mints a connection-scoped `sessionRef`, binds it to the new PTY, and returns it with a sanitized tab descriptor. The requester can select and subscribe immediately.
+
+Creation also invalidates the owner session source so paged catalogs for every Public viewer converge on the new owner-owned session. The creator's alias stays pinned until it maps to the stable catalog identity; merging must not duplicate the tab or lose its selection.
+
+An agent may initially lack a provider session ID. The actual-host binding keeps its identity stable when a later hook reports that ID, then invalidates the catalog. The exact-worktree change stream carries only the bounded positive provider identity proof and its nullable owner-minted live key; the outer owner attests it only while the corresponding Public execution route is still observed. Subscribing before the actual-host initial snapshot closes the healthy read/subscribe window. A lost route or hook hides the unproven history rather than assigning it by CWD.
+
+The owner keeps a bounded live-to-provider identity alias so a created Agent retains one connection-scoped `sessionRef` after its PTY closes and the same provider record becomes historical. If one PTY launches consecutive agents, only the current provider identity inherits the live alias; the previous session returns to its provider-derived key. This lineage changes the key of an already-proven row only and never creates provenance.
+
+If a continued or created process crosses the spawn boundary but its reply is lost, the requester waits for a fresh catalog and never automatically repeats the mutation. An explicit live `terminal.subscribe` also pins its alias so a catalog rebuild can rebind the active terminal.
+
+After pagination reaches `complete` or `error`, an ordinary absent alias may be cleared. Pending, attached, or outcome-unknown aliases remain bounded until they materialize or resolve. Attachments and aliases are connection-scoped, revalidate the Public worktree on every bind, and are cleared on disconnect.
+
+A genuine PTY close removes the attachment. Paired transport loss is a subscription error and cannot masquerade as a PTY close. Revocation removes future mutation authority but does not close an attached or created owner process.
+
+### Quota projection
+
+The quota projection receives a narrow `getCachedActiveRateLimitState()` dependency from the main-process composition root. It does not call `accounts.list`, `runtime.getAccountsSnapshot()`, or any source that returns identities or performs refresh. A Public catalog request never triggers provider authentication, refresh, account switching, or a credential prompt.
+
+```ts
+type SpoolProviderQuota = {
+ provider: 'claude' | 'codex'
+ status: 'ok' | 'unavailable'
+ updatedAt: number | null
+ fiveHour: { usedPercent: number; resetsAt: number | null } | null
+ sevenDay: { usedPercent: number; resetsAt: number | null } | null
+}
+```
+
+Projection strips account email, account ID, organization/workspace, authentication source, credential paths, targets, raw provider errors, raw responses, usage metadata, and token/cookie material.
+
+### SpoolAccessAuthority
+
+`SpoolAccessAuthority` owns all pending requests and grants:
+
+```ts
+interface SpoolAccessAuthority {
+ request(target: {
+ connectionId: string
+ instanceId: string
+ shareEpoch: string
+ }): SpoolControlRequest
+ decide(ownerDecision: SpoolOwnerDecision): SpoolControlDecision
+ requireControl(connectionId: string, instanceId: string, shareEpoch: string): ControlGrant
+ revoke(grantId: string): void
+ connectionClosed(connectionId: string): void
+ subscribeOwnerRequests(listener: (requests: readonly SpoolControlRequest[]) => void): () => void
+}
+```
+
+The RPC gateway resolves `worktreeRef` before calling the authority. This keeps access decisions independent of connection-scoped catalog aliases. Worktree invalidation and connection cleanup are internal operations invoked through the gateway's connection lifecycle, not fan-out responsibilities for the application composition root.
+
+It stores a set of grants, not one scalar controller state. A grant record contains:
+
+```text
+grantId
+ownerRuntimeId
+verified requester Tailnet node ID
+physical connectionId
+worktree instanceId
+shareEpoch
+approvedAt
+```
+
+The exact socket and verified Tailnet node are authority. Requester-supplied Desktop labels or runtime descriptors are never authority. Approval copy uses the verified Tailnet user/node display; Orca version/platform labels are descriptive.
+
+Requests are deduplicated per connection and worktree. Approval rechecks that the same connection is alive and the same share epoch is Public. A disconnect, Private transition, incarnation rotation, or delete removes pending requests so a late `Allow` cannot create a grant.
+
+The owner renderer receives a queued main-process event and answers through owner-only IPC. Several requesters can be approved concurrently, and the owner UI lists/revokes each exact grant. Private revokes all grants for that worktree.
+
+### Authenticated principals
+
+The generic E2EE channel is refactored so authentication returns an immutable discriminated principal rather than a Boolean:
+
+```ts
+type AuthenticatedRpcPrincipal =
+ | { kind: 'paired-device'; deviceId: string; scope: 'mobile' | 'runtime' }
+ | {
+ kind: 'spool'
+ connectionId: string
+ tailnet: TailnetPrincipal
+ channelKeyFingerprint: string
+ }
+```
+
+Legacy `{ type: 'e2ee_auth', deviceToken }` remains compatible on the existing runtime listener. Spool's dedicated listener accepts only `{ type: 'e2ee_auth', spoolTicket }`. A channel cannot switch principal after authentication, and every JSON dispatch receives the same immutable principal object. Binary data remains available to existing paired-device protocols but is rejected on the dedicated Spool gateway in V1.
+
+Spool uses a dedicated persisted E2EE keypair in canonical user data. The probe returns its public key and fingerprint. This fingerprint distinguishes Orca installations on a Tailnet node but is not a replacement for Tailscale source identity; the first probe is trust-on-the-Tailnet, not a public-key infrastructure.
+
+Requester-side list identity is `(verified Tailnet node ID, owner Spool key fingerprint, owner runtime ID)`. The node identifies the Tailnet machine, the persistent Spool key distinguishes the Orca installation, and the runtime ID detects application restart. The owner still authorizes the requester by verified node plus exact socket; the requester's ephemeral channel key only prevents ticket transfer and is not presented as a human/device identity.
+
+## Connection handshake
+
+One requester-to-owner connection is established as follows:
+
+1. The requester obtains the owner's node ID and Tailnet IPs from `TailnetControl.readSnapshot()`.
+2. It generates one ephemeral E2EE client keypair for this connection attempt.
+3. It posts a bounded probe containing protocol versions and the client public key to the exact Tailnet IP on `SPOOL_INGRESS_PORT`.
+4. The owner derives the source from the TCP socket and calls `identifySource`; it ignores identity claims in the body.
+5. The owner returns only its runtime ID, Spool key fingerprint/public key, protocol selection, sanitized Desktop descriptor, ticket, and expiry.
+6. The requester opens `ws://:SPOOL_INGRESS_PORT/spool/v1/connect`. It never follows an endpoint hostname or address supplied by the peer.
+7. The existing ECDH handshake creates an encrypted channel. The encrypted auth frame presents the one-use ticket.
+8. The owner consumes the ticket against the actual socket source and hello public key, creates a physical `connectionId`, and binds one immutable Spool principal.
+9. Only then may the requester subscribe to the Public catalog.
+
+Tailscale encrypts the network path; Orca E2EE protects the application frames and keeps Spool aligned with existing Orca remote protocols. A process that can fully compromise either endpoint machine remains outside the threat boundary.
+
+## Dedicated Spool RPC registry
+
+Spool does not expose the approximately 482-method runtime registry and then try to subtract unsafe methods. `SpoolRpcGateway` has a separate checked-in registry. Familiar wire names such as `terminal.subscribe`, `files.read`, `git.status`, and `checks.read` may remain, but their Spool schemas accept opaque references and relative paths rather than the existing raw host parameters.
+
+`SpoolRpcGateway` is the deep Module. Its registry is a declarative manifest whose entries select a small set of typed schemas, resource binders, execution operations, and result/error projectors owned by the file, Git, Checks, session, terminal, and catalog domains:
+
+```ts
+type SpoolMethodSpec = {
+ name: string
+ schema: SpoolExternalSchemaId
+ access: 'catalog-read' | 'worktree-read' | 'worktree-control'
+ binder: SpoolResourceBinderId
+ operation: SpoolExecutionOperationId
+ projector: SpoolResultProjectorId
+}
+```
+
+This avoids hundreds of one-method pass-through wrappers while keeping every exposed operation explicit and reviewable. Omission means denial.
+
+After authentication, `SpoolIngress` opens one connection-scoped gateway Module:
+
+```ts
+interface SpoolServerConnection {
+ dispatchJson(frame: string): void
+ close(): void
+}
+
+interface SpoolRpcGateway {
+ openConnection(principal: AuthenticatedSpoolPrincipal): SpoolServerConnection
+}
+```
+
+`close()` is idempotent and is the one owner-side cleanup path for aliases, create-idempotency ledgers, pending requests, grants, streams, queues, and downstream subscriptions. The gateway subscribes once to `SpoolWorktreeVisibility`; Private, delete, and incarnation rotation fan out internally through the same connection Modules. `SpoolIngress` does not manually call several cleanup owners.
+
+The dispatch sequence is:
+
+1. Reject non-Spool methods before parsing and return a uniform `method_not_found`.
+2. Parse the Spool-specific external schema.
+3. Resolve connection-scoped references and bind them to one current worktree instance/share epoch.
+4. Require Public read or the exact control grant.
+5. For a create mutation, reserve its connection/worktree-scoped `clientMutationId` before the spawn guard.
+6. Invoke the existing terminal/file/Git/Checks/session execution Module through `SpoolExecutionGateway`.
+7. Project the result and errors, then recheck the share epoch before enqueueing read data.
+
+Streaming emits carry a worktree/share epoch guard. Private or delete cancels the stream and drops queued data. Mutations have a final access-generation guard at their commit/spawn point. Once a process, Git operation, or write has crossed that point, later revocation prevents new work but does not promise rollback.
+
+Direct owner RPC, paired-runtime RPC, and renderer parsing reuse one strict set of execution-result schemas for files, Git, Checks, terminal launch/create, continuation, and mutations. The owner wire projector validates before serialization, so a legal host result cannot be accepted under a wider relay bound and then rejected under a narrower renderer bound. Malformed post-admission mutation output becomes `outcome_unknown`; malformed read output is never forwarded.
+
+Private, nonexistent, stale-epoch, and cross-worktree references all return the same sanitized `resource_not_found`. Projected errors never contain owner paths, host IDs, command lines, credentials, or raw downstream error objects.
+
+No policy is inferred from a method-name prefix. A test snapshots every exposed registry entry and fails if an entry lacks a real schema, binder, access class, execution operation, or projector.
+
+## Capability matrix
+
+| Area | Public | With control | Always owner-only / denied remotely |
+| -------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
+| Catalog | Sanitized Desktop/project/worktree/session list and cached quota | Sanitized enabled/detected agent launch options for the selected worktree | Raw repos, projects, accounts, settings, AI Vault, host inventory |
+| Sessions | List attributed sessions and attach live terminal output | Continue proven Claude/Codex history; create an owner-side terminal or advertised agent | Transcript RPC/UI; close, rename, pin, drag, or move tabs/sessions; account selection; administration |
+| Terminal | Subscribe, bounded snapshot/scrollback, sequenced output and resize events | Input, resize, and semantic `New Terminal` | Raw create command/CWD/env/path/prompt; owner clipboard, auto-download, notification, external open |
+| Files | List tree, read/chunk/preview; view diff on Git targets | Create, write, rename, delete inside the worktree | OS file dialogs, reveal/open on owner Desktop, paths outside root, owner's clipboard |
+| Git | Git targets only: current worktree/index/HEAD status, diff, HEAD history, branch | Git targets only: stage, unstage, and commit through structured methods | All Git APIs for folder targets; checkout/merge/rebase/fetch/pull/push; worktree administration |
+| Agents | Navigate agent entries from the Public paginated catalog and select their terminal | Launch an owner-enabled/detected agent by semantic ID; continue a proven Claude/Codex session | Requester AI Vault, raw launch args, settings, auth/account switching, credential or account identity |
+| Checks | Git targets only: sanitized current hosted-review status | Remains read-only | Raw provider responses/errors, credentials, CI logs, reruns, approvals, merges, or other mutations |
+| Browser/integrations | None in V1 | None in V1 | Profiles, cookies, hosted reviews, GitHub/GitLab/Linear/Jira writes, automations, emulator/computer |
+| Worktree administration | None | None | Public/Private, approve/deny/revoke, delete worktree, create other worktrees |
+| SSH/runtime administration | Observe `resource_unavailable` only | Use an already-authorized route | Host-key trust, credentials, pairing, interactive reconnect prompts, target management |
+
+The matrix is the product-level rule; the checked-in registry is its executable inventory. The granted terminal remains a normal owner-side shell, so it can run commands beyond the narrower structured GUI methods without turning those commands into Spool RPC capabilities. Adding an exposed operation requires updating both and adding a security-contract test.
+
+## SpoolExecutionGateway
+
+`SpoolExecutionGateway` hides where the owner worktree actually runs:
+
+```ts
+interface SpoolExecutionGateway {
+ invoke(target: BoundWorktreeTarget, operation: SpoolOperation): Promise
+ subscribe(
+ target: BoundWorktreeTarget,
+ operation: SpoolSubscriptionOperation
+ ): SpoolHostSubscription
+ closeConnection(connectionId: string): void
+}
+```
+
+- Local and WSL worktrees reuse the owner runtime's current filesystem, Git, PTY, and agent execution Modules.
+- SSH worktrees reuse the already-established SSH relay and providers.
+- Paired runtime-backed worktrees forward through the owner's persisted runtime connection.
+
+Terminal and agent creation use the same route as the target worktree. The gateway never substitutes a local process when the target is WSL, SSH, or paired runtime-backed.
+
+For a semantic launch, the owner resolves the canonical worktree root, default shell, enabled agent definition, platform command, configured overrides, environment, and active credentials. The process starts in the background and does not activate or rearrange the owner's local workspace or tabs.
+
+The gateway chooses an actual-host `SpoolHostAdapter`. Besides normal operations, each Adapter must provide `resolveWorktreeIncarnation` and `openVerified`. The paired-runtime Adapter therefore adds narrow owner-only downstream operations for Git or folder markers, folder directory identity, and verified file access; the Owner Desktop cannot inspect a runtime host's marker, directory identity, or file handles directly. These internal operations are authenticated by the existing paired-runtime principal and are absent from the external Spool registry.
+
+Authorization always happens on the Owner Desktop before forwarding. The downstream call receives internal owner credentials, never the requester's ticket or principal. Upstream and downstream cancellation are linked. A forwarded Spool call cannot recursively expose another Spool gateway.
+
+Remote traffic cannot create an SSH/runtime route or trigger trust, credential, pairing, or account prompts. If an already-authorized route is absent, the owner returns sanitized `resource_unavailable`. The owner may reconnect it independently through existing policy; requester traffic does not broaden that policy.
+
+### Execution admission guard
+
+Dispatch-time authorization is not the last mutation check. `SpoolExecutionGateway` creates an `ExecutionAdmissionGuard` bound to connection, instance, share epoch, and grant generation:
+
+```ts
+interface ExecutionAdmissionGuard {
+ beforeSideEffect(): void
+}
+```
+
+The existing file, Git, session, and PTY execution Modules gain a narrow optional guard and call it after target/path resolution but immediately before their first real write, rename, delete, Git/process spawn, PTY input, resize, or session lifecycle mutation. Concretely, this introduces Seams in `orca-runtime-files.ts`, `orca-runtime-git.ts`, agent/session spawn paths, and terminal input/viewport handling; a check only in `SpoolRpcGateway` is insufficient.
+
+For local, WSL, and direct SSH-provider operations, that call is the commitment boundary. For a paired runtime, successful transmission of the authenticated downstream mutation is the V1 commitment boundary: after the owner sends it, Spool treats the mutation as started even if the downstream process has not spawned yet. A stronger downstream two-phase admission protocol is not assumed. Revocation before transmission blocks; revocation after transmission does not promise cancellation or rollback.
+
+## File and path containment
+
+Spool file schemas contain a `worktreeRef` and a normalized relative path. The host resolves them on the actual execution host using its path semantics.
+
+For every GUI file operation:
+
+1. Resolve the current canonical worktree root.
+2. Reject absolute paths, device paths, drive changes, NULs, and traversal before host resolution.
+3. Resolve the target's real path, or for a create, the nearest existing parent's real path.
+4. Require containment beneath the canonical root using platform-aware path comparison.
+5. Recheck the worktree instance/share epoch and control grant at read enqueue or write commit.
+
+For a Git target, the file binder hides and rejects the in-root `.git` file/directory and every resolved per-worktree or common Git administrative path, preserving the existing Git containment rules. A folder target has no Git administrative root: it rejects every relative path containing a `.git` segment and hides every `.git` entry at every depth, case-insensitively. It also hides and rejects the root incarnation marker, including aliases and symlinks. Structured `files.diff` and all `git.*` operations are unavailable for folder targets. Only internal host Adapters may touch incarnation or Git administration metadata; it is never part of the Public file tree.
+
+Containment covers symlink escapes and symlink retargeting within the endpoint threat boundary. Public reads use a host-side `openVerified` operation: traverse with no-follow semantics where the platform supports it, resolve and stat the canonical target, open a handle, compare the handle identity with the resolved identity, read through that handle, and recheck the share epoch before returning bytes. The SSH/runtime relay implements the operation on the execution host. Where a backend cannot prove a symlink-containing traversal, it rejects that path rather than falling back to path-only checks. Create/write operations use a verified parent handle or reject symlink components, then verify the parent again at their commit guard.
+
+Directory enumeration is descriptor-bound through `/proc/self/fd` when this code runs in a Linux process, including a relay or runtime actually executing on Linux. Node does not expose handle-relative `scandir` on macOS or Windows; a Windows main process accessing WSL through UNC therefore also uses the Windows fallback. Those hosts use the already-verified directory handle plus canonical pathname identity checks immediately before opening the directory stream, immediately after opening it, and again after buffering every entry; the batch is discarded on any mismatch and is never streamed incrementally. This leaves a narrow non-atomic ABA replacement residual for a process already able to rename paths as the owner's OS user. Such a process is an endpoint compromise, and a granted remote shell already has broader authority, so V1 accepts that platform residual rather than disabling the core Files experience on macOS and Windows.
+
+The containment Module runs through local, WSL, SSH, and runtime filesystem Adapters; it never applies a POSIX string prefix to a Windows or remote-host path.
+
+`files.open` and `files.openDiff` currently mutate the owner's renderer. They are not Public reads. Spool fetches file/diff data and opens it in the requester renderer instead.
+
+This containment applies to structured GUI operations only. A granted shell may `cd` anywhere its OS user can access, as the approval warning states.
+
+## Git read profile
+
+“Read-only Git” means no intended repository mutation or network access, not merely a method whose name sounds read-only. Public Git reads use a separately audited execution profile:
+
+- `GIT_TERMINAL_PROMPT=0`, no pager, and no credential prompt.
+- `GIT_OPTIONAL_LOCKS=0` so observational commands avoid optional index locks.
+- Filesystem monitor integration disabled for the invocation.
+- External diff and text conversion disabled where the command supports their Git 2.25-compatible switches.
+- No fetch, remote helper, hosted-provider refresh, or implicit upstream network call.
+- Output, file count, diff size, and execution time bounded.
+
+Sibling worktrees often share one Git common directory, refs, and object database even when their filesystem roots do not overlap. Public Git input therefore cannot select an arbitrary ref, branch, revision range, or object ID. History starts only at the Public worktree's current `HEAD`; the projection issues opaque commit references for commits it has already returned from that ancestry, and detail/diff calls accept only those references. Working-copy diffs use fixed worktree/index/HEAD bases. Current-upstream projection is limited to its name and ahead/behind counts, not upstream-only content. Local branch enumeration, ref search, and sibling worktree state are denied.
+
+The Public read profile is a new mode in the existing Git execution Module, not the behavior of today's `git.status`/`git.diff`/`git.history` handlers. Implementation extends `orca-runtime-git.ts` and the lower Git command Modules so the profile applies on the actual host. Every option must satisfy `docs/reference/git-compatibility.md`; preferred newer behavior needs the existing host-scoped capability/fallback pattern.
+
+A granted terminal can run fetch, checkout, merge, rebase, and ref updates that affect repository state shared by Private sibling worktrees. That is accepted under the powerful control grant; V1 does not expose those actions as structured Git RPCs, and the GUI remains scoped to the selected worktree without claiming repository isolation.
+
+## Terminal protocol and safety
+
+Spool V1 uses separate checked JSON methods over the single encrypted socket. It registers `terminal.launchOptions`, `terminal.create`, `terminal.subscribe`, `terminal.input`, and `terminal.resize`, but not the broad paired-device `terminal.multiplex` handler.
+
+`terminal.launchOptions` requires the current worktree grant. It returns `New Terminal` plus every supported agent that the owner actually has enabled and detected for the worktree's execution host:
+
+```ts
+type SpoolLaunchCapabilities = {
+ agents: readonly SpoolAgentLaunchId[]
+ defaultAgent: SpoolAgentLaunchId | null
+}
+```
+
+`SpoolAgentLaunchId` is a closed, protocol-versioned semantic enum derived from Orca's supported TUI agents, so the result is bounded without silently truncating a supported option. The projection contains no executable, command, path, arguments, environment, settings, account identity, authentication state, or raw detection error.
+
+The owner builds this projection with its bounded, cached actual-host detection path and current enabled/default-agent settings. A requester cannot install or enable an agent, refresh credentials, select an account, or open an owner-side prompt. A stale option is revalidated at the create admission guard.
+
+`terminal.create` accepts only this external schema:
+
+```ts
+type SpoolTerminalCreateParams = {
+ worktreeRef: string
+ clientMutationId: string
+ launch: { kind: 'shell' } | { kind: 'agent'; agent: SpoolAgentLaunchId }
+}
+
+type SpoolTerminalCreateResult = {
+ sessionRef: string
+ session:
+ | { kind: 'terminal'; agent: null; title: string }
+ | { kind: 'agent'; agent: SpoolAgentLaunchId | null; title: string }
+}
+```
+
+The owner maps the semantic launch to its current settings and actual-host execution Adapter. Unknown, disabled, no-longer-detected, or stale agent values fail with a sanitized error. There is no external field through which a requester can inject command, CWD, env, path, prompt, account, or arguments.
+
+Before replying, the owner binds `sessionRef` to the new PTY attachment and invalidates its session catalog. The requester can subscribe immediately, while pagination later merges the same owner-owned session without a duplicate or selection change.
+
+`clientMutationId` is bounded and scoped to the physical connection, worktree instance, and share epoch. The owner keeps an in-flight/completed ledger until disconnect. Repeating an ID with the same semantic request returns the original promise/result; reusing it with different parameters is rejected and never spawns.
+
+The ledger has a fixed per-connection/worktree capacity. Once full, a new unique ID returns `resource_busy`; an existing record is never evicted while its connection lives. This preserves idempotency without unbounded memory.
+
+`terminal.create` is a mutation. If the connection is lost after its spawn boundary but before a conclusive response, Spool returns `outcome_unknown` locally and never automatically retries. A new connection may inspect the fresh Public catalog, but it has neither the old alias nor control grant and must request approval again.
+
+The created process/session belongs to the owner Public worktree and remains running after revoke, connection loss, or requester restart. The owner can manage it through normal local controls; owner restart follows Orca's normal process lifetime. A fresh connection stays read-only until approval. Remote close, rename, pin, drag, and move remain absent from the registry.
+
+Creation is supported through local, WSL, SSH, and paired runtime Adapters. It uses only an already-authorized route and runs without changing owner-side focus, active workspace, or selected tab.
+
+`terminal.subscribe` binds a connection-scoped session alias to one owner terminal. It emits a bounded initial snapshot followed by sequenced output/resize events and closes on publication invalidation.
+
+`terminal.input` and `terminal.resize` each bind the alias again from the current connection projection and in-memory Public publication, then require the exact current worktree grant. This bind path does not rescan every Public worktree: the actual-host incarnation plus grant-generation guard still runs immediately before the PTY or viewport side effect. A binary frame sent to the Spool listener is a protocol violation and terminates the physical connection, which also revokes its grants.
+
+A Public viewer does not register as a terminal driver, viewport owner, query authority, or display subscriber with owner-side effects. The first granted resize/input may register requester viewport state, and revoke removes that state. No lease or conflict arbitration is added; owner and approved requesters may race.
+
+Requester-side terminal rendering treats remote output as untrusted active content. OSC clipboard writes, automatic file/image downloads, automatic URL opening, host notifications, and shell-integration actions with local side effects are disabled for Spool sessions. Hyperlinks may be shown but require an explicit requester click.
+
+## Single-socket QoS
+
+One socket must not let terminal output grow memory indefinitely or preserve authority after invalidation. Encrypted JSON replies therefore use one ordered bounded queue. If the transport backlog crosses the hard cap, the socket is terminated and the requester must rebuild read subscriptions from fresh snapshots. Terminal events carry monotonically increasing sequence numbers so the renderer drops duplicates and stale events.
+
+Private, delete, incarnation-proof/root drift, and incarnation changes terminate the physical Spool socket rather than attempting to selectively purge an encrypted WebSocket backlog. That teardown discards application queues, closes streams, and revokes all connection grants. Bytes already delivered to the peer cannot be recalled; revocation prevents later admission and enqueueing but does not roll back a started process.
+
+## Resource limits
+
+Limits are protocol constants and return sanitized `resource_busy` or `result_too_large` errors rather than allocating without bound. At minimum the implementation caps:
+
+- Probe body size, probe concurrency, and probes per source.
+- Pre-auth sockets, total sockets, and sockets per Tailnet node.
+- Concurrent RPCs and pending owner approvals per connection.
+- Concurrent terminal/agent creates per connection and worktree.
+- Enabled-agent launch options and retained create-idempotency records per connection.
+- Logical subscriptions and terminal streams per connection/worktree.
+- Connection-scoped created-session attachments and pinned aliases per worktree.
+- Terminal snapshot and retained scrollback bytes.
+- File chunk size, directory entry count, search result count, and diff bytes.
+- Public worktrees per owner Desktop (128).
+- Catalog page size and session-delta batch size.
+- Outbound queue bytes globally and per stream.
+
+The Public-worktree cap is enforced before the durable visibility transition. Publishing the 129th worktree, including a project bulk action that would cross the cap, fails atomically; the catalog never truncates Public rows to fit the wire limit.
+
+Control requests are deduplicated per connection/worktree. A denied request has a short per-connection cooldown to prevent dialog spam while preserving the explicit-request model.
+
+## Renderer architecture
+
+Network connections and tickets stay in the requester main process. `SpoolDesktopCatalog` owns peer connections, their connection epochs, catalog snapshots, and requester actions. `src/main/ipc/spool-sharing.ts` plus a narrow preload contract exposes sanitized snapshots/actions to a volatile Zustand slice:
+
+```text
+src/renderer/src/store/slices/spool-sharing.ts
+```
+
+Grant state, opaque references, pending requests, and remote routes are never persisted in the workspace session. Only harmless disclosure expansion preferences may persist.
+
+The renderer does not synthesize `Repo`, `Worktree`, or persisted `WorkspaceKey` objects for remote resources. Those types carry local paths, host IDs, persistence behavior, reconciliation, and Git assumptions. It uses a separate route:
+
+```ts
+type SpoolWorkspaceRoute = {
+ desktopRef: string
+ worktreeRef: string
+ sessionRef?: string
+ connectionEpoch: number
+}
+```
+
+### Sidebar
+
+A pure `spool-sidebar-rows.ts` projection creates Desktop, Project, Worktree, and Session row kinds. A new `workspace-sidebar-row-projection.ts` is the explicit high-level Seam that combines existing local `RenderRow[]` with `SpoolSidebarRow[]` and owns virtual-row keys, measured sizes, sticky behavior, and ordering. `worktree-list-groups.ts` and `WorktreeCard` continue to model only local worktrees.
+
+The rows use the existing sidebar presentation Modules rather than Spool-specific approximations. Desktop and Project rows share the native Project-header shell and disclosure; Worktree rows share the native Worktree-card surface; every disclosure therefore has one size, position, color, and rotation source. Spool adds no alternate hover background. The anchors are Desktop 10px, Project 20px, Worktree content 30px, and Session 48px. Plain terminal sessions use the Terminal glyph, while recognized agents use their provider glyph and custom agents use a neutral agent glyph. There are no Live/Stopped/Resumable pills. A Desktop can remain present with quota and no Public projects. Owner-side active grants are projected once by `WorktreeList` and rendered inside the corresponding native `WorktreeCard` with direct Revoke actions; cards do not subscribe independently and there is no separate global grant panel.
+
+Quota does not occupy permanent tree rows. `SpoolDesktopUsageHoverCard` adapts the sanitized quota snapshot to `ProviderRateLimits` and renders the same `ProviderPanel` used by the status-bar hover surface, including usage bars, reset copy, and the global used/remaining preference. It does not import the large `StatusBar` Module or raw account slices.
+
+### Workspace surface
+
+At the content root, `SpoolWorkspaceSurface` is selected instead of the local workspace controller, but it reuses the normal Worktree presentation shell. `workspace-column-chrome.ts` is shared with `TabGroupSplitLayout` and owns the 4px top drag region, 36px total top-band alignment, and full-height left divider. `WorkspacePaneFrame` owns the same 32px tab strip, border, surface, window-drag region, and titlebar-control clearances used by local tab groups. `WorkspaceTabStripViewport` is shared with the local `TabBar`, so overflow arrows, fade masks, wheel navigation, active-tab scroll-into-view, and unused titlebar drag space have one implementation.
+
+Spool reuses the local tab `+` menu presentation through a remote action adapter, not the local workspace controller. Read-only, pending, and disconnected states keep it disabled with an explanatory tooltip. A grant enables `New Terminal` and only the owner-advertised enabled/detected agents. It never shows Browser, Markdown, Simulator, agent settings, account switching, or raw shell variants.
+
+Remote tabs use the same sizing, border, active, and focus tokens but stay select-only. They cannot drag, close, rename, pin, create persisted local tab state, or enter local workspace reconciliation. Creation runs as a background owner-side operation and never changes the owner's active tab or focus.
+
+The virtualized left tree remains the complete navigator for every materialized session. The center strip is bounded to 24 entries: it prioritizes leading, recent, and selected sessions, and always includes the active one. Catalog `loading` and `error` remain visible; a partial page chain is never presented as complete.
+
+Existing sessions attach or continue only after explicit selection. A successful explicit create is different: the returned volatile alias is inserted, selected, and subscribed immediately. Catalog reconciliation later replaces or merges it with the stable entry without duplicate tabs or selection loss.
+
+The ordinary Worktree right sidebar is a presentation Seam: Spool reuses `RightSidebarFrame` plus the shared activity-tab definitions, ordering, icons, labels, and status-indicator semantics. `SpoolRightSidebar` filters that definition to Explorer, Agents, Source Control, and Checks for a Git worktree, and Explorer plus Agents for a folder worktree. It supplies remote panel adapters; it does not mount local panels whose selectors expect a requester-side Repo, Worktree, path, AI Vault, provider account, or Git client.
+
+Agents is a second navigator over only the `kind: 'agent'` entries in the already-loaded Public paginated session catalog. Its row viewport is virtualized, so the bounded 55,000-row materialized catalog does not become a 55,000-node DOM. It renders the sanitized agent/title fields and selecting a row updates the remote route so `SpoolSessionPane` opens that session's terminal in the center. It does not query or mirror the requester's local AI Vault, and pagination remains authoritative rather than presenting a partial chain as complete.
+
+Checks is an owner-side sanitized read projection. The checked, Git-only `checks.read` RPC resolves the Public worktree on its actual host and returns bounded review fields needed by the read-only panel and its activity status indicator: provider, number, title, state, URL, aggregate status, updated time, mergeability, and review decision. It may also return at most 256 check rows containing only name, status, conclusion, and a normalized credential-free HTTP(S) URL, plus `truncated` and `detailStatus`. The owner keeps a 15-second, 128-entry per-publication single-flight cache and performs the provider lookup without analytics side effects. A provider failure projects `unavailable`; a successful lookup with no review projects an ordinary empty result. The strict result schema rejects excess fields, malformed dates, credential-bearing URLs, and unsafe protocols at direct and paired-runtime boundaries. It returns no repository path, credential, raw provider response/error, check log, or mutation capability; a control grant does not make Checks writable.
+
+Because remote previews use checked relative-path and patch contracts rather than local editor models, file and change selection uses a single-column drill-in within the sidebar and an explicit Back action. `SpoolFilesPane`, `SpoolFileTree`, and `SpoolFilePreview` continue to own relative-path RPC and requester-side previews. `SpoolGitPane`, `SpoolGitSidebar`, and `SpoolGitDiffPane` continue to own the bounded Git surface. Every panel invokes checked Spool IPC rather than requester-side filesystem, editor, Source Control, AI Vault, provider, or `PtyTransport` clients. This preserves the normal Worktree layout without synthesizing a `Repo`, `Worktree`, persisted `WorkspaceKey`, owner absolute path, or owner credential.
+
+`SpoolSessionPane` resolves existing, continued, and newly created aliases and always converges on `SpoolTerminalPane`; it never renders a separate provider transcript UI. The center shows raw and agent terminals through the same xterm-backed remote surface, while the right sidebar provides the ordinary workspace navigation over remote projections.
+
+`SpoolTerminalPane` starts `terminal.subscribe`, applies one bounded snapshot followed by sequenced output/resize events, and drops duplicate or stale sequences. A disconnect or new connection epoch tears down the subscription; reopening starts a fresh snapshot. There is no separate PTY transport protocol in V1.
+
+Create, input, and resize consult the current `canControl` selector and use the ordered mutation path that surfaces uncertain outcomes. Input coalesces short adjacent bursts up to the chunk limit; resize remains an ordering barrier. An `outcome_unknown` create refreshes read state but never issues another create.
+
+All Spool file, Git, terminal, create, and session mutation controls consume the dynamic `canControl` selector. Browser, settings, account switching, and integration entry points remain unavailable regardless of grant. On epoch change, revoke, or Private, one state transition makes every surface read-only. The server remains authoritative.
+
+### Owner approval surface
+
+Incoming requests follow the existing queued-dialog pattern: main process authority → renderer event → volatile request queue → one root-level `SpoolControlRequestDialog`. Disconnect, Private, or delete emits cancellation and closes a stale dialog.
+
+The initial focus is `Deny`, so Enter cannot accidentally approve. `Allow this connection` uses the normal primary action style. Deny and Revoke are quiet actions, not destructive-red styling.
+
+The approval copy remains:
+
+```text
+Allow Xinyao · MacBook to control this worktree?
+
+They will be able to create terminals, start enabled agents,
+send terminal input, modify files, run commands, and use your active agent accounts.
+Terminal commands are not confined to this worktree.
+
+ [Deny] [Allow this connection]
+```
+
+Making a worktree Public also requires owner-visible copy explaining that every existing session transcript/scrollback and future terminal output becomes readable and may already contain content produced outside the worktree. This is necessary because no file-containment rule can redact arbitrary bytes previously printed in a terminal.
+
+## Lifecycle state machines
+
+### Visibility
+
+```text
+Private
+ └─ owner publishes + incarnation validates + durable commit
+ ▼
+Public(read)
+ ├─ owner makes Private ───────────────► Private
+ ├─ delete/incarnation mismatch ───────► Private/new instance
+ └─ control approval (per connection) ─► Public(read) + Grant set
+```
+
+Visibility belongs to the worktree. Grants are a separate set and never turn Public into a persisted “controlled” state.
+
+### Request and grant
+
+```text
+none ── request ACK ──► pending ── owner allow ──► granted
+ │ │ │
+ │ ├─ deny/cancel ─────────► none
+ │ └─ disconnect/private ──► none
+ └──────────────────────────────────────────────────┘
+ revoke/disconnect/private/delete
+```
+
+The owner decision is valid only against the exact request ID, connection ID, instance ID, and share epoch that produced the dialog.
+
+### Mutation outcome
+
+```text
+received → bound → authorized → commit/spawn guard → started → result
+ │ │
+ revoke blocks here └─ later revoke does not roll back
+```
+
+Mutations are never automatically retried. If the socket dies after the commit/spawn guard and before the reply, the requester sees `outcome_unknown`. For creation it inspects the fresh paged catalog; any later explicit action requires a new connection-scoped approval and mutation ID.
+
+## Reconciliation and failures
+
+| Event | Owner behavior | Requester behavior |
+| ------------------------------------------------ | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
+| Tailnet peer misses one scan | No change | Keep current row while reconciling |
+| Peer misses two scans / probe fails | No change | Close requester connection and remove Desktop after reconciliation |
+| Heartbeat or socket loss | Delete grants, requests, streams, aliases, and create-id ledgers; created processes continue | Increment epoch immediately; show disconnected/read-only |
+| Fresh socket, same owner runtime | Issue new aliases; Public catalog can return | Replay only read subscriptions; require new control request |
+| Owner restart | New runtime ID; no grants exist | Treat as a new catalog epoch and read-only connection |
+| Public SSH/WSL/runtime host unavailable | Keep visibility but publish resource unavailable from validated cached catalog only | Preserve row; opening reports sanitized unavailable state |
+| Worktree disappears | Invalidate share epoch; revoke and remove | Remove worktree and close its views |
+| Same path reappears with a new incarnation proof | Rotate instance ID and persist Private | Old references remain permanently invalid |
+| Worktree becomes Private during an awaited read | Cancel/purge stream and discard late result | Remove worktree; never render late content |
+| Grant revoked during a queued mutation | Commit/spawn guard rejects if not started | Demote to read; no replay |
+| Already-started create loses connection | Owner process/session continues and enters the Public catalog | Report `outcome_unknown`; inspect catalog; do not retry |
+| Revoke after a successful create | Keep the owner process/session running and Public | Keep read access; remove input/create authority |
+
+The requester may retain a last successful Public row during a brief execution-host outage, but never across a visibility/share-epoch change. Cached content is scoped to the same connection/runtime/share generations.
+
+## Privacy and logging
+
+Spool logs protocol versions, node IDs in a redacted/fingerprinted form, connection lifecycle, method class, grant decision, worktree opaque ID, durations, sizes, and sanitized error codes. It does not log:
+
+- Tickets, E2EE keys, auth tokens, cookies, or credentials.
+- Absolute worktree/session/file paths.
+- Terminal input/output or file contents.
+- Account identity or raw provider responses.
+- Resolved launch/resume commands, agent overrides, SSH targets, or downstream pairing offers.
+
+Telemetry, if added, records aggregate feature events only and never forms a central discovery/control plane.
+
+## Compatibility and migration
+
+- Existing mobile and runtime pairing wire contracts remain compatible. The E2EE refactor preserves legacy auth frames, channel-bound token mismatch checks, device/client identity, `wsClientIds` close cleanup, and revoke termination semantics while mapping the result to a paired-device principal.
+- Missing `spoolVisibility` and `spoolIncarnationId` always mean Private. There is no migration that makes an existing worktree Public.
+- Existing sessions gain worktree-instance provenance only when safely observed or created. Unprovable historical records are not shared.
+- Remotely initiated terminals and agents are ordinary owner-owned sessions. They need no ownership migration when a requester disconnects.
+- Git commands retain Git 2.25 as the baseline and use host-scoped capability caches for any newer preferred behavior.
+- Paths use Node/Electron host path functions or execution-host Adapters; keyboard labels and shortcuts continue to use platform-aware Orca conventions.
+- The requester-to-owner Spool path always terminates at the Owner Desktop even when the selected worktree executes over SSH, WSL, or a paired runtime.
+
+## Verification strategy
+
+Security race tests use controllable barriers between bind, authorize, execute, commit/spawn, await completion, project, and enqueue. They do not use sleeps to hope for a race.
+
+### Unit contracts
+
+- Defensive Tailnet status/whois parsing, CLI location, address normalization, timeout/output caps, and peer deduplication.
+- Ticket TTL, one-use consumption, source/node/client-key/runtime/version binding, and replay rejection.
+- E2EE principal immutability and compatibility with existing device-token clients.
+- Visibility default, atomic project bulk, deny-journal recovery, rename continuity, incarnation-proof mismatch, same-path replacement, and overlap rejection.
+- Public-only catalog projection, connection-scoped opaque references, generation handling, created-session alias convergence, provenance/deduplication, and quota sanitization.
+- Session privacy fixtures cover nested roots, identical paths on different execution hosts, stale/cross-worktree provenance, and ambiguous legacy records.
+- Recursive fixtures containing email, IDs, auth sources, paths, errors, tokens, cookies, and raw metadata prove those fields/values never serialize.
+- Separate Spool RPC registry default denial and the complete bind/access/execute/result/error contract for every entry.
+- File containment for traversal, `.git`/administrative metadata denial, symlink escape/retarget, missing-target parent resolution, Windows drives/UNC/case, WSL, SSH, and runtime hosts.
+- Git read profile, current-HEAD opaque commit refs, sibling-worktree isolation, and Git 2.25 compatibility.
+- Semantic launch-option projection; `terminal.create` rejects command/CWD/env/path/prompt/account fields and stale or disabled agent IDs.
+- Create-idempotency scope, same-ID result reuse, conflicting-payload denial, full-ledger rejection, disconnect cleanup, alias pinning, and `outcome_unknown` without automatic retry.
+- Terminal method direction, per-operation grant checks, inactive viewer behavior, OSC/local-side-effect suppression, sequencing, and connection teardown on invalidation.
+
+### Process integration
+
+- One probe/upgrade port; a port collision disables only Spool.
+- Ingress binds only current Tailnet interfaces, ignores body/header identity claims, verifies the real socket source, and never redirects a requester to a peer-supplied endpoint.
+- Exactly one physical socket carries requests and streaming replies; Spool binary input fails closed.
+- Disconnect clears pending approvals/grants and reconnect replays only Public reads.
+- A grant cannot move to a second socket on the same node, another channel key, worktree/share epoch, or stale owner decision.
+- Requester and owner restart tests separately prove that grants, requests, aliases, streams, and `canControl` never survive.
+- Public → Private closes catalog/terminal/file/Git streams, cancels in-flight Checks reads, and discards all late results.
+- Delete, incarnation-proof mismatch, incarnation rotation, and Private invalidate active requests, grants, aliases, and streams through the same path.
+- Crash injection after each deny-journal, metadata, and epoch step never publishes uncertain visibility.
+- Revoke between authorization and commit/spawn prevents the side effect; revoke after start does not claim rollback.
+- Owner-side terminal and enabled-agent creation runs on local, WSL, SSH, and paired runtime targets without opening a route, prompting for trust/credentials, or stealing owner focus.
+- A created attachment is usable before pagination completes; catalog convergence exposes one stable session to the creator and other Public viewers.
+- Revoke, connection loss, and requester restart leave an already-created owner process running while removing requester mutation authority; owner restart follows normal Orca process lifetime.
+- Revoke/Private invalidation terminates saturated connections so stale queued replies cannot survive the publication epoch.
+- Public file and Git reads never open owner UI or trigger credential/trust/provider prompts.
+- The same authorization and symlink-retarget/visibility TOCTOU suite runs against local, WSL, SSH relay, and paired runtime execution.
+- Downstream disconnect cleans upstream subscriptions and recursive Spool forwarding is rejected.
+- Probe, forwarding, error, and log sentinels prove that tickets, principals, credentials, paths, SSH targets, terminal bytes, and raw errors never escape their allowed boundary.
+- Real Git 2.25 and a newer representative binary exercise the Public read profile, fallback cache, concurrent probes, and host isolation.
+- Every connection/subscription/result/queue limit rejects cleanly and releases capacity after disconnect.
+
+### Renderer and E2E
+
+- Sidebar projects Desktop → Project → Worktree → every attributable Session, never merges Desktops by person, and shows no session-status pills.
+- Light/dark, 220 px sidebar density, truncation, disclosure, virtual scrolling, and Desktop usage hover cards follow `docs/STYLEGUIDE.md` and canonical CSS tokens.
+- Public surfaces are consistently read-only; one grant ACK enables all allowed controls; revoke/disconnect/Private disables them in one render turn.
+- The shared tab `+` is disabled with read-only guidance, then offers only `New Terminal` and owner-advertised agents after grant; Browser/settings/account actions never appear.
+- Successful create selects and subscribes its volatile alias immediately; paged catalog merge preserves one tab and its selection.
+- Remote tabs remain select-only with no close, rename, pin, drag, move, or local persistence path.
+- Git worktrees show Explorer, Agents, Source Control, and Checks in the ordinary sidebar order; folder worktrees show Explorer and Agents only.
+- Agents follows paginated catalog loading/error/completion and selects the same center terminal without consulting local AI Vault state.
+- Checks displays the owner-projected aggregate status, up to 256 sanitized check rows, truncation, and provider-unavailable state; malformed, credential-bearing, stale, folder-target, and late responses fail closed.
+- Approval dialog warning, safe initial focus, cancellation, multi-grant list, and exact revoke action.
+- Two fake-Tailnet Desktop processes prove discover → Public read → request → approve → mutate → disconnect → reconnect read-only.
+- Real Tailnet smoke tests remain manual/nightly; CI injects an in-memory `TailnetControl` rather than depending on a developer Tailnet.
+
+P0 merge gates are default-deny registry coverage, projection sanitization, terminal mutation commit guards, documented containment/TOCTOU behavior, every loss-of-authority transition, the four execution-host routes, and the two-Desktop reconnect-read-only E2E.
+
+## Delivery sequence
+
+### Slice 1: visibility, projections, and UI shell
+
+- Add the persisted Private-by-default visibility fields, incarnation proof, deny journal, and atomic Store operation.
+- Add owner worktree/project visibility actions and first-publication warning.
+- Add static/fixture-backed Spool sidebar rows, Desktop usage hover cards, remote route, read-only workspace shell, approval queue, and dynamic `canControl` gates.
+- Validate the UX in light/dark and narrow sidebar layouts before networking.
+
+### Slice 2: dedicated ingress and Public reads
+
+- Add `TailnetControl`, `TailscaleCommandAdapter`, peer reconciliation, fixed-port `SpoolIngress`, tickets, immutable principals, and `SpoolPeerConnection`.
+- Add Public-only catalog/session/quota projections and generation-bound opaque references.
+- Add the default-deny read registry for terminal, file, diff, Git, and sanitized Checks data.
+- Prove local, WSL, SSH, and runtime execution routing and fail-closed unavailable behavior.
+
+### Slice 3: ephemeral control
+
+- Add owner request queue, access authority, multi-grant UI, explicit revoke, and guarded terminal control methods.
+- Add semantic launch capabilities and guarded owner-side terminal/agent creation across local, WSL, SSH, and paired runtime routes.
+- Add immediate connection-scoped attachment, `clientMutationId` deduplication, paged catalog convergence, and background creation without owner focus changes.
+- Add guarded file mutations, Git stage/unstage/commit, and Claude/Codex session continuation through the reviewed control registry.
+- Add commit/spawn guards, outcome-unknown handling, resource limits, priority invalidation, and reconnect-no-replay coverage.
+
+## Expected file ownership
+
+Exact splits may change to respect max-line limits, but these names describe concrete responsibilities:
+
+```text
+src/shared/spool/
+ spool-wire-contract.ts
+ spool-catalog-contract.ts
+ spool-access-contract.ts
+ spool-agent-launch-contract.ts
+ spool-checks-result-schema.ts
+
+src/main/spool/
+ tailnet-control.ts
+ tailscale-cli-locator.ts
+ tailscale-command-adapter.ts
+ tailnet-peer-directory.ts
+ spool-ingress.ts
+ spool-ticket-authority.ts
+ spool-peer-connection.ts
+ spool-desktop-catalog.ts
+ spool-worktree-visibility.ts
+ spool-worktree-incarnation.ts
+ spool-visibility-deny-journal.ts
+ spool-share-catalog.ts
+ spool-session-catalog.ts
+ spool-terminal-attachment-registry.ts
+ spool-session-provenance-index.ts
+ spool-quota-projection.ts
+ spool-access-authority.ts
+ spool-rpc-gateway.ts
+ spool-rpc-registry.ts
+ spool-execution-gateway.ts
+ spool-worktree-containment.ts
+
+src/main/ipc/
+ spool-sharing.ts
+
+src/renderer/src/store/slices/
+ spool-sharing.ts
+
+src/renderer/src/components/sidebar/
+ workspace-sidebar-row-projection.ts
+ spool-sidebar-rows.ts
+ SpoolDesktopRow.tsx
+ SpoolDesktopUsageHoverCard.tsx
+ SpoolProjectRow.tsx
+ SpoolWorktreeRow.tsx
+ SpoolSessionRow.tsx
+ SidebarDisclosure.tsx
+ SidebarProjectHeader.tsx
+ WorktreeCardSurface.tsx
+ WorktreeCardControlGrants.tsx
+
+src/renderer/src/components/tab-bar/
+ AgentLaunchMenuItems.tsx
+ WorkspaceTabCreateMenu.tsx
+ WorkspaceSelectableTab.tsx
+ WorkspaceTabStripViewport.tsx
+
+src/renderer/src/components/tab-group/
+ WorkspacePaneFrame.tsx
+ workspace-column-chrome.ts
+
+src/renderer/src/components/right-sidebar/
+ RightSidebarFrame.tsx
+ right-sidebar-activity-items.ts
+ check-status-presentation.ts
+
+src/renderer/src/components/status-bar/
+ ProviderUsageSegment.tsx
+
+src/renderer/src/components/spool/
+ SpoolWorkspaceSurface.tsx
+ SpoolSessionTabStrip.tsx
+ SpoolSessionCreateMenu.tsx
+ SpoolRightSidebar.tsx
+ SpoolAgentsPane.tsx
+ SpoolChecksPane.tsx
+ SpoolChecksResult.tsx
+ SpoolTerminalPane.tsx
+ SpoolSessionPane.tsx
+ SpoolSessionContinuationNotice.tsx
+ SpoolFilesPane.tsx
+ SpoolFileTree.tsx
+ SpoolFilePreview.tsx
+ SpoolFilePreviewToolbar.tsx
+ SpoolTooltipIconButton.tsx
+ SpoolGitPane.tsx
+ SpoolGitSidebar.tsx
+ SpoolGitDiffPane.tsx
+ SpoolControlRequestDialog.tsx
+ SpoolWorktreeVisibilityDialog.tsx
+ spool-session-catalog-status.ts
+```
+
+The implementation also changes these existing Seams deliberately:
+
+- `src/shared/types.ts` and `src/main/persistence.ts` for visibility/incarnation metadata and the narrow atomic commit.
+- `src/main/index.ts` for composition, cached quota injection, lifecycle, and Windows firewall setup.
+- `src/preload/index.ts`, `src/preload/api-types.ts`, and a concrete Spool subscription contract for renderer IPC.
+- `src/main/runtime/rpc/core.ts`, `e2ee-channel.ts`, and `runtime-rpc.ts` for immutable principals while preserving paired-device wire behavior.
+- `src/main/runtime/orca-runtime.ts` terminal input/viewport seams for final side-effect admission.
+- `src/main/runtime/orca-runtime-files.ts`, `orca-runtime-git.ts`, and lower host providers for verified file access, the audited Git read profile, and final side-effect guards.
+- Paired-runtime internal RPC for incarnation and verified-file host operations.
+
+Existing runtime/file/Git/PTY Modules do not import Spool catalog or renderer code; the dependency points from the Spool execution gateway toward those Modules.
+
+## Rejected alternatives
+
+- **Use the existing runtime WebSocket port directly:** it mixes Spool tickets with persisted broad-scope device tokens and makes safe review depend on the much larger runtime registry.
+- **Fixed discovery port that returns a dynamic RPC port:** it adds a second Tailnet ACL/firewall requirement and still mixes ingress semantics.
+- **Use Tailscale Services in V1:** Services require Tailnet administration and advertisement, conflicting with zero-setup peer discovery. See [Tailscale Services](https://tailscale.com/kb/1552/tailscale-services).
+- **Scan the Tailnet address range:** slower, noisier, and less authoritative than the Tailscale peer snapshot.
+- **Auto-mint a runtime pairing token:** it would silently grant persistent broad runtime authority.
+- **Open several sockets and group them into a logical connection:** a terminal-input socket could outlive the socket whose grant was approved.
+- **Send global repos/accounts/sessions and filter in the renderer:** any renderer or serialization error would leak Private state.
+- **Annotate every existing RPC method with a read/control enum:** existing schemas and results accept raw/global data, and a single enum cannot express binding, projection, streaming, or commit guards.
+- **Use raw paths or stable internal IDs as selectors:** they leak topology, invite cross-host confusion, and survive longer than the publication they name.
+- **Persist grants or replay them after reconnect:** contradicts the connection-scoped approval model.
+- **Treat renderer-disabled controls as authorization:** a modified renderer can still call the network protocol.
+- **Expose raw terminal or agent launch configuration:** command, CWD, env, path, prompt, arguments, and account selection exceed the approved worktree capability and create injection paths.
+- **Kill remotely initiated processes on revoke/disconnect:** those are owner-owned worktree sessions; revocation removes future authority rather than rolling back accepted work.
+- **Promise a worktree sandbox:** a granted terminal is the owner's shell authority.
+- **Continuously assign historical sessions by CWD:** path reuse and nested roots can disclose a previous worktree instance.
+- **Permit overlapping Public/Private roots:** a parent file tree or Git history can bypass the nested worktree's visibility.
+
+## Architecture closure
+
+The product loop is closed without a central service:
+
+```text
+Tailnet peer snapshot
+ → verified running Desktop
+ → one-use E2EE connection
+ → owner-produced Public projection
+ → read-only Orca workspace
+ → explicit whole-worktree request
+ → owner approval for one physical connection
+ → semantic terminal/agent create or other owner-side execution
+ → immediate attachment plus Public catalog convergence
+ → revoke or any disconnect
+ → already-created owner processes continue
+ → automatic return to Public read-only after a fresh connection
+```
+
+Implementation can now be decomposed into tickets. Remaining choices such as exact numeric resource caps are tuning constants, not unresolved identity, authorization, persistence, routing, or lifecycle semantics.
diff --git a/docs/reference/plans/2026-07-14-spool-team-session-sharing.md b/docs/reference/plans/2026-07-14-spool-team-session-sharing.md
new file mode 100644
index 00000000000..0bfcce37c9f
--- /dev/null
+++ b/docs/reference/plans/2026-07-14-spool-team-session-sharing.md
@@ -0,0 +1,359 @@
+# Spool Tailnet Worktree Sharing — Product Plan
+
+**Status:** Implemented. Architecture recorded in [Spool Tailnet Worktree Sharing — Architecture](../2026-07-14-spool-tailnet-worktree-sharing-architecture.md).
+
+**Goal:** Extend Orca into Spool so people running Orca Desktop on the same reachable Tailnet can discover one another, browse explicitly public worktrees, inspect every safely attributed session and the surrounding development state, and request temporary control of an entire remote worktree through the owner's existing Orca runtime.
+
+## Confirmed product model
+
+Spool has two access levels and one visibility setting:
+
+| State | What a remote Desktop can do |
+| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Private worktree | Discover nothing about the worktree or its sessions. |
+| Public Git worktree | Read all attributed sessions, terminals, files, diffs, scoped Git state, and sanitized Checks state in the worktree. |
+| Public folder-project workspace | Read attributed sessions, terminals, and files; its sidebar exposes Explorer and Agents. `.git`, diffs, Git, and Checks are unavailable. |
+| Public worktree with a control grant | Mutate supported operations and create owner-side terminals or enabled agents for the current connection; a folder target still does not gain Git APIs. |
+
+`Public` and `Private` belong only to worktrees. Projects and sessions do not have their own persisted visibility setting.
+
+This includes the synthetic workspaces under a `Repo.kind = 'folder'` project: the root uses `repoId::path`, and additional instances use `repoId::path::workspace:`. It does not include independent ProjectGroup-backed `FolderWorkspace` entries keyed as `folder:`.
+
+## Product principles
+
+1. **Orca remains the product shell.** Spool extends Orca's sidebar, worktree, terminal, editor, source-control, agent, and runtime experiences instead of introducing a parallel UI.
+2. **The Tailnet is the discovery boundary.** There is no Spool account, team creation flow, invitation, or central team service in the first version.
+3. **Private by default.** A newly created worktree starts Private and reveals no metadata to another Desktop.
+4. **Public is read-only.** A Public worktree exposes its complete read model, but every mutation remains blocked until its owner approves the current connection.
+5. **Control is worktree-wide.** Approval is not scoped to one session or provider. It also permits owner-side terminal and enabled-agent creation for the current connection.
+6. **Approval is ephemeral.** Every disconnect or application restart invalidates control and requires a new owner confirmation.
+7. **Credentials never move.** Claude, Codex, Git, SSH, and other credentials remain on the owner's Desktop or execution host.
+8. **No false sandbox promise.** A writable terminal is a remote shell. Spool must not claim that terminal commands are confined to the selected worktree.
+
+## Tailnet discovery
+
+When Orca Desktop opens, Spool enumerates the peers visible to the local Tailscale client, then probes those peers for a running Orca Desktop endpoint. The intended discovery input is the machine-readable peer list from `tailscale status --json`, not a brute-force scan of the `100.64.0.0/10` address range.
+
+Only peers that satisfy all of the following appear:
+
+- They are visible to the local Tailscale client.
+- Tailnet policy and device settings allow a connection.
+- Orca Desktop is currently running and responds to the Spool probe.
+
+There is no persistent offline roster. Closing Orca Desktop or becoming unreachable removes that Desktop from the discovered list after reconciliation.
+
+Tailscale provides peer discovery, reachability, and the private network path. Orca's authenticated encrypted WebSocket RPC remains the application protocol. The implementation must not treat possession of a `100.x` address alone as identity.
+
+The Tailscale CLI documents `status --json` as an automation-oriented detailed peer list while warning that its JSON shape is subject to change. Discovery must therefore parse defensively and fail closed when peer identity cannot be established.
+
+## Desktop identity
+
+The top-level sidebar item represents one running Desktop, not one merged person. If the same person runs Orca on two machines, both appear independently:
+
+```text
+Alice · MacBook Pro
+Alice · Linux workstation
+```
+
+This matches the real ownership of worktrees, active provider accounts, credentials, sessions, and runtime connections. Spool does not merge projects or quota across devices.
+
+The display identity should come from verified Tailnet peer information plus the remote Orca Desktop descriptor. A client-supplied display name alone is not an authorization identity.
+
+## Sidebar hierarchy
+
+The left sidebar uses this hierarchy:
+
+```text
+Desktop
+└─ Project
+ └─ Worktree
+ └─ Sessions
+```
+
+Example:
+
+```text
+Spool
+
+▾ Alice · MacBook Pro
+ ▾ orca
+ ▾ feature/session-sharing
+ [Claude] Sharing UI
+ [Codex] RPC review
+ [Claude] Initial exploration
+
+▸ Alice · Linux workstation
+```
+
+Desktop and Project rows reuse Orca's existing Project-header shell and disclosure affordance; Worktree rows reuse the existing Worktree-card surface. Spool does not define parallel hover backgrounds, arrow colors, or row chrome. The hierarchy uses the native anchors with one additional level for the Desktop: Desktop 10px, Project 20px, Worktree content 30px, and Session 48px. Hovering a Desktop opens a card with its Claude and Codex usage, rendered with the same provider segments and global used/remaining preference as the status bar. Quota does not consume permanent rows in the narrow navigation tree.
+
+The owner still sees Private worktrees in their normal local Orca sidebar. Another Desktop receives only Public worktrees, so a Private worktree's name, path, branch, sessions, counts, and activity do not cross the connection. An active control grant is rendered inside that owner's existing Worktree card as a compact requester row with a direct Revoke action; it is not collected in a separate global Spool panel.
+
+Session rows are deliberately simple. Spool lists every terminal-backed session the owner can attribute to a Public worktree without adding `Live`, `Stopped`, or `Resumable` categories. The catalog discriminates plain terminals from agents; plain shells use the Terminal glyph, recognized agents retain their bounded provider label and glyph (including Gemini and OpenCode), and custom agents use a neutral agent identity. Selecting a live session attaches its terminal. Selecting a historical session does not open a separate transcript UI: once control is granted, the owner Desktop resumes the exact Claude or Codex session in that worktree and Spool immediately attaches the resulting terminal. Without control, the worktree request remains the only way to authorize that mutation. An unavailable session reports the observed failure only after the user tries to open it.
+
+Opening a shared Worktree uses Orca's normal workbench presentation. The center reuses the local Worktree pane and overflow-aware tab strip for terminals and agent sessions. Its `+` menu uses the same presentation as local tabs. Without control it is disabled with an explanatory tooltip; with control it offers `New Terminal` plus the agents that the owner actually has enabled and detected.
+
+The remote tab strip remains select-only: it cannot close, rename, pin, drag, or persist remote tabs. Creation runs in the background on the owner Desktop and never changes the owner's active worktree, tab, or focus.
+
+To keep paged history bounded, the strip keeps leading and recently selected sessions plus the active one; the virtualized left tree remains complete. The right sidebar reuses the ordinary Worktree sidebar chrome and shared tab definitions. A Git worktree exposes Explorer, Agents, Source Control, and Checks; a folder worktree exposes Explorer and Agents.
+
+Those tabs use remote adapters instead of mounting local panels against requester state. Agents projects only agent-session entries from the Public paginated session catalog through a virtualized row viewport, and selecting a row opens that session's terminal in the center. Checks uses a checked owner-side RPC to return a sanitized read-only hosted-review projection, up to 256 check rows, truncation/detail availability, and the ordinary activity status indicator. A provider failure is shown as unavailable rather than being confused with a branch that has no hosted review. None of these paths creates a fake local Repo or Worktree, discloses an owner path or credential, or routes work through requester-side filesystem, Git, AI Vault, or provider clients.
+
+The UI follows `docs/STYLEGUIDE.md`: existing sidebar tokens, quiet monochrome chrome, shadcn primitives, existing list-row states, and color reserved for meaningful application state.
+
+## Provider quota display
+
+Each discovered Desktop publishes the observable rate-limit state for its current active Claude and Codex accounts.
+
+When available, the Desktop hover card shows the same normalized fields and visual treatment Orca's status bar already uses:
+
+- Five-hour utilization and reset time.
+- Seven-day utilization and reset time.
+- A provider-reported unavailable state. Raw provider errors are stripped and folded into unavailable.
+
+Spool does not expose account email addresses, account lists, authentication sources, credential paths, tokens, cookies, or raw provider responses. It does not invent a percentage or reset time when the provider does not report one.
+
+If the owner changes the active account, the remote quota summary updates to represent the new active account. A granted controller uses the owner's active account, settings, and credentials at execution time, including when starting an agent.
+
+## Worktree visibility
+
+Every worktree has one persisted visibility value:
+
+- **Private:** accessible only from its owner Desktop.
+- **Public:** readable by other reachable Orca Desktops on the same Tailnet.
+
+Making a worktree Public automatically covers every current and future session inside that worktree, including owner-side sessions created at a remote controller's request. On first publication, the same confirmation bulk-attests legacy sessions that match the current execution host and worktree root; sessions do not need individual confirmation. A legacy record without safe worktree attribution remains undisclosed.
+
+Projects do not persist a visibility value. Their context menu provides bulk operations over the worktrees that exist at the time of the action:
+
+- `Make all worktrees public`
+- `Make all worktrees private`
+
+The bulk action does not become a policy for future worktrees. Every later worktree still starts Private.
+
+V1 allows at most 128 Public worktrees on one owner Desktop. Publishing a worktree or running a project bulk action that would cross the limit fails before any visibility change, so the remote catalog never silently omits a Public worktree.
+
+The worktree context menu provides the direct actions:
+
+- `Make public`
+- `Make private`
+
+Making a worktree Public must warn the owner that all existing session history, terminal scrollback, and future terminal output becomes readable. Terminal history may already contain content produced outside the worktree, so Public cannot promise path-based redaction of past output.
+
+Making a worktree Private immediately removes it from remote discovery, ends its read subscriptions, and revokes any active control grant.
+
+## Public read-only experience
+
+Opening a Public worktree without a grant provides the complete V1 read-only worktree workspace:
+
+- List and open every session attributed to the worktree.
+- Browse agent-session entries from the same Public paginated catalog in the Agents sidebar and select one into the center terminal.
+- Watch terminal snapshots, scrollback, ANSI/TUI state, and realtime output.
+- Browse files and read file contents.
+- For Git worktrees, inspect diffs, the current worktree/index/HEAD status, HEAD history, current branch, upstream state, and sanitized hosted-review Checks state.
+
+A folder-project workspace deliberately has no Git surface. Its file browser hides every `.git` entry at every depth and rejects every path containing a `.git` segment, case-insensitively. Its incarnation proof combines a hidden random root marker with the actual-host scope and stable directory `dev`/`ino`; Files also hides and rejects that marker. Renaming the same directory preserves the proof, while replacement or a copied marker does not. Synthetic workspaces from the same folder repo may share the same exact file root and proof, while cross-repo and ancestor/descendant overlaps remain unavailable.
+
+The server is authoritative. Hiding or disabling controls is not sufficient: terminal input, file writes, Git mutations, process/session creation, and every other mutation must be rejected unless the current connection holds an approved grant for that worktree.
+
+For a terminal-backed session, Spool reuses Orca's remote terminal path:
+
+1. Resolve the selected session to its terminal on the owner Desktop.
+2. Subscribe through `terminal.subscribe` over the encrypted WebSocket RPC channel.
+3. Render the initial serialized snapshot and scrollback.
+4. Continue rendering realtime terminal output.
+5. Keep terminal input disabled until control is granted.
+
+The PTY, agent process, worktree, and credentials remain on the owner side. Tailscale carries the connection; it does not replace Orca's terminal protocol.
+
+## Requesting control
+
+A Public worktree exposes one primary control request in its workspace header:
+
+```text
+Alice / orca / feature-session-sharing [Request control]
+```
+
+The user requests the whole worktree once. Spool does not trigger separate approval prompts for terminal input, file edits, Git mutations, or individual sessions.
+
+While a request is pending, the requester keeps read-only access. The owner receives an in-app approval surface that identifies the requesting Tailnet Desktop and the target worktree.
+
+The confirmation must state the real security boundary:
+
+```text
+Allow Xinyao · MacBook to control this worktree?
+
+They will be able to create terminals, start enabled agents,
+send terminal input, modify files, run commands, and use your active agent accounts.
+Terminal commands are not confined to this worktree.
+
+ [Deny] [Allow this connection]
+```
+
+There is no auto-approve or remembered approval in the first version.
+
+## Granted control
+
+After approval, the current connection receives the V1 mutable worktree capabilities defined below:
+
+- Create a new owner-side terminal or start any agent that the owner Desktop currently exposes as enabled and detected for that execution host.
+- Send input to every terminal/session in the worktree.
+- Continue a historical Claude or Codex session in a new owner-side terminal; selecting and switching sessions remains requester-side navigation.
+- Run commands from a granted owner-side terminal using the owner's environment and quota.
+- Modify files.
+- Stage, unstage, and commit through Spool's structured Source Control methods. Other Git commands remain available through the granted terminal shell.
+- Use Spool's mutable terminal, file, diff, and reviewed Source Control controls for that worktree.
+
+Structured creation is intentionally semantic. The requester sends only `New Terminal` or one owner-advertised agent identifier plus a `clientMutationId`. It cannot send a command, working directory, environment, path, prompt, account, or launch arguments. The owner resolves its worktree root, actual execution host, shell, settings, agent overrides, environment, and active credentials.
+
+The created process and session belong to the owner's Public worktree, not to the requester. The creator attaches immediately through a connection-scoped alias, while the paged catalog later converges on the same session. Every other read-only viewer of that Public worktree can see it once it reaches the catalog.
+
+Provider identity is learned only from an exact-worktree owner hook or initial actual-host snapshot. A bounded live-to-provider alias keeps the creator's `sessionRef` stable when the PTY later becomes a historical Claude/Codex record, including paired-runtime short-lived agents. Lost proof hides a record; Spool never repairs an ordinary catalog page by guessing from its current CWD.
+
+V1 still exposes no remote tab close, rename, pin, drag, or move operation. It also exposes no structured checkout, merge, rebase, fetch, pull, or push. A granted terminal remains an ordinary owner-side shell, but the UI must not imply that unsupported GUI methods exist.
+
+Each create carries a connection- and worktree-scoped `clientMutationId`. Repeating the same ID on that connection returns the original in-flight or completed result and never spawns twice. If the response becomes uncertain after the spawn boundary, Spool reports `outcome_unknown`; it may refresh the catalog but never automatically retries the mutation.
+
+The owner retains exclusive authority to:
+
+- Change the worktree between Public and Private.
+- Approve or deny control requests.
+- Revoke a granted connection.
+- Delete the worktree.
+
+Remote work never creates a requester-owned worktree or migrates a session. A remotely requested terminal or agent is still created and owned inside the owner's existing worktree on its existing execution host.
+
+This rule must also hold when the worktree is backed by WSL, SSH, or another Orca runtime. The owner Desktop remains the sharing gateway and routes reads and mutations to the actual execution host; Spool must not assume that the worktree path or agent process is local to the owner Desktop.
+
+## No concurrency coordination
+
+Spool does not introduce a controller lease, input lock, editing lock, queue, ownership handoff, or conflict warning.
+
+The owner and any granted remote connections may operate concurrently. They may type into the same terminal, create several terminals or agents, modify the same file, run conflicting Git commands, or consume provider quota at the same time. Resulting races and conflicts are accepted behavior for the first version.
+
+This is a deliberate non-goal, not an implied safety guarantee.
+
+## Grant lifetime and revocation
+
+A grant is scoped to exactly:
+
+```text
+requesting Tailnet Desktop + owner Desktop + worktree + current connection
+```
+
+It is never persisted. The following events immediately remove write capability:
+
+- The owner selects `Revoke`.
+- The owner makes the worktree Private.
+- Either side's WebSocket connection closes, including a transient network loss.
+- Either Orca Desktop exits or restarts.
+- The worktree is deleted.
+
+After a network reconnect, a Public worktree may restore its read-only state automatically, but control does not return. The requester must select `Request control` again and the owner must explicitly approve again.
+
+While control is active, the owner sees the requesting Desktop and a direct revocation action in the worktree UI:
+
+```text
+feature/session-sharing
+Xinyao · MacBook has access [Revoke]
+```
+
+Revocation prevents future mutations. It does not close a terminal or agent already created, and cannot undo commands, file changes, Git operations, or provider usage. Those owner-owned sessions remain visible under the Public worktree and the owner can manage them locally. Controlling them after reconnect requires a new approval.
+
+## Security boundary
+
+Spool is designed for mutually trusted people sharing a Tailnet. Tailnet membership and reachability are not substitutes for server-side authorization, but they define the first-version discovery population.
+
+Public access is enforced as read-only. Granted access is intentionally powerful. Because Orca terminals are ordinary shells, a granted user can leave the selected directory, inspect other paths available to the owner's system user, start background processes, and execute destructive commands. Without a sandbox, `worktree control` is a product context rather than an operating-system security boundary.
+
+Owner, paired-runtime, and renderer boundaries parse the same strict execution-result schemas. A downstream result cannot cross a wider relay schema and then fail under a narrower UI schema; malformed mutation acknowledgements after admission are treated as `outcome_unknown`.
+
+Provider, Git, SSH, and system credentials stay on the host, but a granted shell may exercise the authority those credentials provide. Product copy and approval surfaces must state this without implying confinement that does not exist.
+
+## End-to-end acceptance scenario
+
+1. Alice and Xinyao run Orca Desktop on separate machines reachable in the same Tailnet.
+2. Each Desktop discovers the other without a Spool account or invitation.
+3. Xinyao sees one sidebar item per Alice Desktop and the observable active Claude/Codex quota for each.
+4. Alice makes one Orca worktree Public. Her other worktrees remain undisclosed.
+5. Xinyao expands `Alice Desktop → orca → public worktree` and sees every session attributed to that worktree.
+6. Xinyao opens sessions, watches terminals, reads files, inspects diffs, and inspects Git state; mutation attempts are rejected by Alice's host.
+7. Xinyao selects `Request control` on the worktree.
+8. Alice sees the requesting Desktop, the worktree, and the remote-shell warning, then approves the current connection.
+9. The shared `+` menu now offers `New Terminal` and Alice's enabled/detected agents. Xinyao creates both without changing Alice's focused tab, then immediately attaches to the returned terminal.
+10. The new sessions belong to Alice's Public worktree and appear through the paged catalog for another read-only viewer. Xinyao also continues an existing Claude session and uses Alice's active quota.
+11. Repeating one `clientMutationId` produces no second process. A post-spawn response loss reports `outcome_unknown`, refreshes the catalog, and never automatically retries.
+12. The same creation behavior holds for local, WSL, SSH, and paired runtime worktrees without a local fallback or requester-triggered route prompt.
+13. Alice and Xinyao may operate concurrently; Spool does not arbitrate conflicts.
+14. Alice selects `Revoke`, or either Desktop disconnects. Xinyao immediately loses mutation access, but already-created processes continue on Alice's host.
+15. After reconnecting, Xinyao can browse those sessions read-only but must request and receive approval again before controlling or creating anything.
+
+## Existing Orca capabilities to reuse
+
+- AI Vault and agent session discovery.
+- Workspace session tabs and terminal handle resolution.
+- The local tab `+` menu presentation and owner-side terminal/agent launch paths.
+- Runtime routing for local, WSL, SSH, and paired execution hosts.
+- Encrypted WebSocket RPC.
+- `terminal.subscribe`, serialized snapshots, scrollback, streaming output, input, resize, and terminal attachment binding.
+- Remote file, diff, Git, Checks, and worktree reads.
+- The ordinary Worktree sidebar chrome, activity-tab definitions, and status-indicator presentation, backed by Spool-specific remote projections.
+- Existing terminal, editor, source-control, and worktree UI.
+- Existing Claude and Codex normalized rate-limit state.
+- Existing E2EE framing, heartbeat, terminal resync, and execution-host routing where they preserve the connection-scoped grant rules above.
+
+## Proposed delivery slices
+
+### Slice 1: Local visibility model and static UI
+
+- Persist Public/Private on worktrees, defaulting every new worktree to Private.
+- Add worktree visibility actions and project-level bulk actions.
+- Add the Desktop → Project → Worktree → Sessions sidebar hierarchy by reusing the native Project header, Worktree card, and disclosure presentation seams.
+- Prototype the Public read-only header, `Request control`, approval, active-control, and revocation states.
+- Validate density and hierarchy against `docs/STYLEGUIDE.md` before networking work.
+
+### Slice 2: Tailnet discovery and read-only sharing
+
+- Enumerate Tailnet peers and probe for running Orca Desktops on macOS, Linux, and Windows.
+- Authenticate discovered peer identity and establish encrypted RPC connections.
+- Publish active-account quota summaries and Public worktree metadata only.
+- Support read-only sessions, terminal streaming, files, and the remote Agents projection for every supported target; add diffs, Git state, and sanitized Checks for Git worktrees.
+- Prove that every mutation is rejected server-side without a grant.
+- Route SSH, WSL, and runtime-backed worktrees through the owner Desktop correctly.
+
+### Slice 3: Connection-scoped control
+
+- Add worktree-level control requests and owner approval.
+- Bind approved capability to the exact current connection and worktree.
+- Unlock owner-side `New Terminal`, semantic enabled-agent launch, terminal input, file mutations, structured Git stage/unstage/commit, and proven Claude/Codex continuation.
+- Accept only semantic launch identifiers; resolve commands, roots, environment, settings, credentials, and execution routes on the owner.
+- Return a connection-scoped attachment immediately, then converge it with the paged session catalog without duplicate tabs.
+- Deduplicate create mutations by `clientMutationId` and never automatically retry `outcome_unknown`.
+- Reuse the owner's active sessions, authentication, and quota across local, WSL, SSH, and paired runtime targets.
+- Add explicit revoke, Private transition, disconnect, restart, and deletion invalidation.
+- Verify that reconnect restores only Public read access and always requires another confirmation.
+
+## Explicitly out of scope for the first version
+
+- Spool accounts, teams, invitations, or a central control plane.
+- Public internet discovery or unauthenticated links.
+- Merging multiple Desktops belonging to one person.
+- Session-level or project-level visibility.
+- Per-member Public visibility rules.
+- Automatically making future worktrees Public after a project bulk action.
+- Persisted, expiring, provider-specific, or session-specific grants.
+- Auto-approval after a previously approved connection.
+- Controller leases, conflict detection, input arbitration, or collaborative editing.
+- Sandboxing terminal commands to the selected worktree.
+- Moving credentials, PTYs, sessions, or worktrees to the requester's machine.
+- Creating requester-owned remote worktrees or sessions, or moving owner sessions to the requester. Remotely requested owner-side sessions inside the Public worktree are supported.
+- Sharing independent ProjectGroup-backed `FolderWorkspace` entries keyed as `folder:`; `Repo.kind = 'folder'` synthetic workspaces are supported.
+- Browser profiles/panes, cookies, hosted-review or issue-tracker mutations, automations, emulator/computer control, settings, account switching, and SSH/runtime administration.
+- Remote tab close, rename, pin, drag, or move operations.
+- Invented quota estimates when provider usage data is unavailable.
+- Session lifecycle labels or status-based grouping in the sidebar.
+- Replacing Orca RPC with a Tailscale-specific terminal protocol.
+
+## Architecture resolution
+
+The discovery Adapter, dedicated ingress, Tailnet/E2EE identity binding, opaque resource references, default-deny RPC registry, visibility durability, execution-host routing, and reconciliation semantics are resolved in [Spool Tailnet Worktree Sharing — Architecture](../2026-07-14-spool-tailnet-worktree-sharing-architecture.md).
diff --git a/package.json b/package.json
index ca641723c64..cdc0c713ee4 100644
--- a/package.json
+++ b/package.json
@@ -101,6 +101,7 @@
"bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs"
},
"dependencies": {
+ "@base-ui/react": "1.6.0",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@floating-ui/dom": "1.7.6",
@@ -191,7 +192,6 @@
"oxlint-plugin-react-doctor": "0.2.10",
"oxlint-tsgolint": "0.23.0",
"pdfjs-dist": "^5.7.284",
- "radix-ui": "^1.6.2",
"react": "^19.2.7",
"react-colorful": "^5.7.0",
"react-dom": "^19.2.7",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b8d00d79dc2..d8206514ac5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -25,6 +25,9 @@ importers:
.:
dependencies:
+ '@base-ui/react':
+ specifier: 1.6.0
+ version: 1.6.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@electron-toolkit/preload':
specifier: ^3.0.2
version: 3.0.2(electron@43.1.0)
@@ -290,9 +293,6 @@ importers:
pdfjs-dist:
specifier: ^5.7.284
version: 5.7.284
- radix-ui:
- specifier: ^1.6.2
- version: 1.6.2(@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:
specifier: ^19.2.7
version: 19.2.7
@@ -556,6 +556,33 @@ packages:
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
+ '@base-ui/react@1.6.0':
+ resolution: {integrity: sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ '@date-fns/tz': ^1.2.0
+ '@types/react': ^17 || ^18 || ^19
+ date-fns: ^4.0.0
+ react: ^17 || ^18 || ^19
+ react-dom: ^17 || ^18 || ^19
+ peerDependenciesMeta:
+ '@date-fns/tz':
+ optional: true
+ '@types/react':
+ optional: true
+ date-fns:
+ optional: true
+
+ '@base-ui/utils@0.3.1':
+ resolution: {integrity: sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==}
+ peerDependencies:
+ '@types/react': ^17 || ^18 || ^19
+ react: ^17 || ^18 || ^19
+ react-dom: ^17 || ^18 || ^19
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
'@braintree/sanitize-url@7.1.2':
resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==}
@@ -1579,132 +1606,9 @@ packages:
'@posthog/types@1.372.9':
resolution: {integrity: sha512-B7k9S+H9WUKHXxe1HOkQWbpWtMcrBvsodm5stZaLQ3pYxf9TowtwssdzTtX4hHjzSYqgrS1IpNnJX4vs1KgBzA==}
- '@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==}
-
- '@radix-ui/react-accessible-icon@1.1.11':
- resolution: {integrity: sha512-HQDOFTKwSnmUij6l54wYJJtxTAnxI71+YJLOrjm2ladFB8HAV5Jt7hwaZPhWTGBkYoW4+ZAOfNZrLDh/qvxSYA==}
- 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-accordion@1.2.16':
- resolution: {integrity: sha512-BpZJNmetujnGgUI6OX0jEhEmlA46WPqgub8Rv09Kyquwd0cc1ndMKpiPYCjmBU6KSSRPAMtgLpEoZSG/tdNIWQ==}
- 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-alert-dialog@1.1.19':
- resolution: {integrity: sha512-FA7n1f6D/DwGE0+AWxiY5LacNbbExQuEgMubeG06idEaH+mSLuf9dp/qBNqOnvbTQ+4gZ2ue1RATF1Ub91Mg5g==}
- 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-arrow@1.1.11':
- resolution: {integrity: sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==}
- 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-aspect-ratio@1.1.11':
- resolution: {integrity: sha512-IUAhIVpBUvP5NNICjlaB1OFmtRLGqQqTF3ZOSGPoq3XeLXRFtHiWTRxSVEULgOd9GQR2c7tsYqDnhUennapZnw==}
- 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-avatar@1.2.2':
- resolution: {integrity: sha512-sST0qh8GzOB7besQ3tMLWLyngnRuSk0gc/Hm+667KYKQFCt6Y6ZXv25WlqM7dIDK54ULCh5+CHmk4LIolzfz+A==}
- 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-checkbox@1.3.7':
- resolution: {integrity: sha512-JroKHfQBfh+fDuzpPsBC+pESkhuq8ql4hljTguz8MWnS35cISr3d/Jhl9kYrB44FlDtxCArYdDvTx+BSsJ64rQ==}
- 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-collapsible@1.1.16':
- resolution: {integrity: sha512-opfXRe6nnzyGmCDPx+l1Aqo/RbqWtQal2FnsBqF9hhePp6j0LsRoBaRxcMOlTv+uYTJVtWYZKg9t9wTe+BA/ZA==}
- 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-collection@1.1.12':
- resolution: {integrity: sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==}
- 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-compose-refs@1.1.2':
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
peerDependencies:
@@ -1714,28 +1618,6 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-compose-refs@1.1.3':
- resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==}
- 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-menu@2.3.3':
- resolution: {integrity: sha512-PS+gKE0z2prJ74Y0sM+brAGK4mYOHIR7TlcV5EJgUQ6E0xMvyswkK2X4yRqyganrzsRL+WCSKAPu0NQITICRWg==}
- 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-context@1.1.2':
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
peerDependencies:
@@ -1745,15 +1627,6 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-context@1.2.0':
- resolution: {integrity: sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
'@radix-ui/react-dialog@1.1.15':
resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
peerDependencies:
@@ -1767,28 +1640,6 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-dialog@1.1.19':
- resolution: {integrity: sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==}
- 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-direction@1.1.2':
- resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
'@radix-ui/react-dismissable-layer@1.1.11':
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
peerDependencies:
@@ -1802,32 +1653,6 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-dismissable-layer@1.1.15':
- resolution: {integrity: sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==}
- 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-dropdown-menu@2.1.20':
- resolution: {integrity: sha512-slfm+rRaZRuQBvHq60lXvSVUPhid0IPtjSZzIuUlWZMUs01iYZNlGS3mJgRD3ChLQVBAYlKiL/tFyWGX+dz8Xw==}
- 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-focus-guards@1.1.3':
resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
peerDependencies:
@@ -1837,28 +1662,6 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-focus-guards@1.1.4':
- resolution: {integrity: sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==}
- 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-scope@1.1.12':
- resolution: {integrity: sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==}
- 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-focus-scope@1.1.7':
resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==}
peerDependencies:
@@ -1872,32 +1675,6 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-form@0.1.12':
- resolution: {integrity: sha512-JTX94E4LDL91rzLg7X0mHPdxr0A8JEdVwZEmeOwZJSMDHCGW5DFtSlTSJozUyUs807IQmnvbfzKZFVCK5DmkqQ==}
- 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-hover-card@1.1.19':
- resolution: {integrity: sha512-2KTgMLQtKvicznQgbindEI2RZ3QbDIwU5gabjUPwFJsormjGDz+rUvO4NANmYwzEEpTcTONUt33vBHIfTIVSfw==}
- 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-id@1.1.1':
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
peerDependencies:
@@ -1907,43 +1684,8 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-id@1.1.2':
- resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
- '@radix-ui/react-label@2.1.11':
- resolution: {integrity: sha512-3PKvDDxOn62k0oV1n4QtNtD2vpu+zYjXR7ojLBPaO6SPvhy53yg0vAmgNeBQeJW5rV3dffoRG+HYfLBZuzw0CQ==}
- 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-menu@2.1.20':
- resolution: {integrity: sha512-VsUrXxFe9d2ScbZF0fR/oPR1+qjyeLs5p0jzG8h90puMoA9bq4SirYlXbE+USRg9Q2qTeJSFNqjw2nts8jJe4w==}
- 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-menubar@1.1.20':
- resolution: {integrity: sha512-gzFZvybgmwYsFBWDqanycIoEYnhyk8MMnuLamdFVHUZYGp4COM+sqXiwbnn0VMWqGLeeU7GV7jm+dXRa+Wufag==}
+ '@radix-ui/react-portal@1.1.9':
+ resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1955,8 +1697,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-navigation-menu@1.2.18':
- resolution: {integrity: sha512-K9HiuxZ6xCwSaHcIuUpxyhy4w5gpwzWjh9dHTSbMN3Ix4qAyVObS9RlU3zMycb0PO3v9Tpk0BXMwWvXOUbVXew==}
+ '@radix-ui/react-presence@1.1.5':
+ resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -1968,112 +1710,8 @@ packages:
'@types/react-dom':
optional: true
- '@radix-ui/react-one-time-password-field@0.1.12':
- resolution: {integrity: sha512-nQLu5OAcORDQp1EHAv6k3mJGV1hjMTw2NTGVAsGE1g/mWeNqAd1R5jyaAs3U+A8ZD/W8XNPY2yKT0ZdQnqo3NA==}
- 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-password-toggle-field@0.1.7':
- resolution: {integrity: sha512-gB1Mr8vzdv1XzDjrtJTXmL0JORRs1B4g7ngUs0F+H2VvMOwXTZMTmLCl0wZZ3m7ylX8TssI7NCvgiSHmLuTm/A==}
- 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-popover@1.1.19':
- resolution: {integrity: sha512-jkrTdQVxnIB8fpn0NyyxW9CTB5aCXZZelVz5z+Xmii6g5WxMqS3fInNslZ63puP39+Puu4jYohUK31y3dT87gQ==}
- 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-popper@1.3.3':
- resolution: {integrity: sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g==}
- 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-portal@1.1.13':
- resolution: {integrity: sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==}
- 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-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:
- '@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.3':
- resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
+ '@radix-ui/react-primitive@2.1.3':
+ resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
@@ -2086,314 +1724,20 @@ packages:
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:
- '@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-progress@1.1.12':
- resolution: {integrity: sha512-ZPHyI0JyzoH/rP0tq2uRaIZTj/4s8+kAbqPz+e2N8+ejHvwPJ889dHhqn+vh7PNvNeq+boAoH9yzqeoShzwF2w==}
- 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-radio-group@1.4.3':
- resolution: {integrity: sha512-WwZFjWV4s3aC1QtR3k04R+oANHtX2q6fgKlc7MCEiDNlnTxCZ3H8k3mHtEgVlOejystwk1WQgarQhNOQZ2bK1g==}
- 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-roving-focus@1.1.15':
- resolution: {integrity: sha512-40svmmugfM3mUN7VUDGVE1tQGOhyi8enlGD0CNJEcMM36C1f71PKM21DFgNHUfem0XnA+d8H8oN3Z9ZpJjSslg==}
- 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-scroll-area@1.2.14':
- resolution: {integrity: sha512-bBODCWZK7JTbQLHs0uIP4f73wIWatakK4OS33UzkR1x897wu0PuO658a3f+6P2GEGyDzGYMuHRatMVoAk9WZTw==}
- 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-select@2.3.3':
- resolution: {integrity: sha512-L5RQTXz6Anxsf9CCv+pTgiAsUpyVj7rJxsGtmhFaEOJ++cVfXucv4qWfsIO0AIB4NAhi3yovWGVMKKS1Xf1Wrg==}
- 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-separator@1.1.11':
- resolution: {integrity: sha512-jRhe86+8PF7VZ1u14eOWVOuh2BuAhALg/FT1VcMC4OHedMTRUazDnDlKTt+yxo5cRNKHMfmvZ4sSQtWDeMV4CQ==}
- 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-slider@1.4.3':
- resolution: {integrity: sha512-CWVVj+XaTom0SKCqw1EUgb0NuiLwS+N3OFG73mVEezKEjgNIvZiu0EevMelSSU+CbX3owbqJweG2gPU31WGC5A==}
- 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-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:
- '@types/react': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
- '@radix-ui/react-switch@1.3.3':
- resolution: {integrity: sha512-1+mlB4/lxJfk5tgJ4g+R5mUCbRpPE1T9+UsEyeLYbGgMtwiMgmuTnfKz4Mw1nHALHjuwyxw4MLd4cSHn6pNSlQ==}
- 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-tabs@1.1.17':
- resolution: {integrity: sha512-nRyXnrAVCwjeXcHbvEbLS6ndbTeKHG1RqCP4A8Gw5L4cemDzPXdD8rAmr6wet0v57R69wGvuIIsFjHSVkZiMzQ==}
- 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-toast@1.2.19':
- resolution: {integrity: sha512-SxfVZfVOibWKWdkf0Xx1awW2d09fQu4V4PXDY1j5hi4MVf7MWdJZqTBJMa1KWtOr1S6GGtCk02nniZ0Iia+dHw==}
- 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-toggle-group@1.1.15':
- resolution: {integrity: sha512-gIC5Q+Xljg7lmUdzSuDoy0t97yZn1sZl00Ra37ZvKrYdWnQLU6sWLd09yG8cIB9jUAlQfHgJ2ACAG00MFwsqSQ==}
- 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-toggle@1.1.14':
- resolution: {integrity: sha512-QI/hB65XKWACA66P64A+aHxtLUgHJeJLkaQa+awUNXT6T3swndtY5DojeHA+vldrTspMTtFBd7HfZ9QGbM1Qrw==}
- 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-toolbar@1.1.15':
- resolution: {integrity: sha512-t/iEuVjUnXXtrsGK40AA43uIx37sn3AqZ7oAVnPICK6lFJP6dzMzWR3U9b6eCfFjb6wtSEqkJ9Rn9xDjiOx20g==}
- 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-tooltip@1.2.12':
- resolution: {integrity: sha512-U3HoftgWnmla78vzQbLvKKb7bUYJxoiiqYFzp1wu/TBMyDqMZSuCl3aRICsD6EfVEwcJD2mumGDGUXLFVqQHKA==}
- 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-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:
- '@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.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:
- '@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.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:
- '@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.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==}
+ 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-use-is-hydrated@0.1.1':
- resolution: {integrity: sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==}
+ '@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
@@ -2401,8 +1745,8 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-use-layout-effect@1.1.1':
- resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
+ '@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
@@ -2410,8 +1754,8 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-use-layout-effect@1.1.2':
- resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==}
+ '@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
@@ -2419,8 +1763,8 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-use-previous@1.1.2':
- resolution: {integrity: sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==}
+ '@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
@@ -2428,8 +1772,8 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-use-rect@1.1.2':
- resolution: {integrity: sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==}
+ '@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
@@ -2437,8 +1781,8 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-use-size@1.1.2':
- resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==}
+ '@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
@@ -2446,21 +1790,14 @@ packages:
'@types/react':
optional: true
- '@radix-ui/react-visually-hidden@1.2.7':
- resolution: {integrity: sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw==}
+ '@radix-ui/react-use-layout-effect@1.1.1':
+ resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
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/rect@1.1.2':
- resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==}
'@react-grab/cli@0.1.34':
resolution: {integrity: sha512-L2eAxN46Vq2Ss3nDegrH7wQVMeWH03ahawp+OdzUtQWqL3cq6Bt149q9XhY3cWc9fJsxuWjLfCn+3T9uApIlBA==}
@@ -5837,19 +5174,6 @@ packages:
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
engines: {node: '>=10'}
- radix-ui@1.6.2:
- resolution: {integrity: sha512-OwYUjzMwiInCUxgAWpPsavXC3Kh4iyi/49uU1/qZTG3RQDlvegyk1GOMiGvSkjua1RDb3JD3fo3eroL9FV4GQw==}
- 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
-
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
@@ -6004,6 +5328,9 @@ packages:
resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==}
engines: {node: '>=12', npm: '>=6'}
+ reselect@5.2.0:
+ resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
+
resolve-alpn@1.2.1:
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
@@ -7070,6 +6397,29 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
+ '@base-ui/react@1.6.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@base-ui/utils': 0.3.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@floating-ui/utils': 0.2.11
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ use-sync-external-store: 1.6.0(react@19.2.7)
+ optionalDependencies:
+ '@types/react': 19.2.17
+
+ '@base-ui/utils@0.3.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ '@babel/runtime': 7.29.7
+ '@floating-ui/utils': 0.2.11
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ reselect: 5.2.0
+ use-sync-external-store: 1.6.0(react@19.2.7)
+ optionalDependencies:
+ '@types/react': 19.2.17
+
'@braintree/sanitize-url@7.1.2': {}
'@chevrotain/types@11.1.2': {}
@@ -7798,374 +7148,48 @@ snapshots:
'@parcel/watcher-linux-arm64-musl': 2.5.6
'@parcel/watcher-linux-x64-glibc': 2.5.6
'@parcel/watcher-linux-x64-musl': 2.5.6
- '@parcel/watcher-win32-arm64': 2.5.6
- '@parcel/watcher-win32-ia32': 2.5.6
- '@parcel/watcher-win32-x64': 2.5.6
-
- '@playwright/test@1.59.1':
- dependencies:
- playwright: 1.59.1
-
- '@posthog/core@1.28.3':
- dependencies:
- '@posthog/types': 1.372.9
-
- '@posthog/types@1.372.9': {}
-
- '@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)':
- dependencies:
- '@radix-ui/react-visually-hidden': 1.2.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)
- optionalDependencies:
- '@types/react': 19.2.17
- '@types/react-dom': 19.2.3(@types/react@19.2.17)
-
- '@radix-ui/react-accordion@1.2.16(@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
- '@radix-ui/react-collapsible': 1.1.16(@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-collection': 1.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)
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(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)
- '@radix-ui/react-use-controllable-state': 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-alert-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
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@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-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)
- optionalDependencies:
- '@types/react': 19.2.17
- '@types/react-dom': 19.2.3(@types/react@19.2.17)
-
- '@radix-ui/react-arrow@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/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)
- optionalDependencies:
- '@types/react': 19.2.17
- '@types/react-dom': 19.2.3(@types/react@19.2.17)
-
- '@radix-ui/react-aspect-ratio@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/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)
- optionalDependencies:
- '@types/react': 19.2.17
- '@types/react-dom': 19.2.3(@types/react@19.2.17)
-
- '@radix-ui/react-avatar@1.2.2(@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-context': 1.2.0(@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)
- '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-layout-effect': 1.1.2(@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-checkbox@1.3.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/primitive': 1.1.5
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@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)
- '@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)
- '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-size': 1.1.2(@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-collapsible@1.1.16(@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
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@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)
- '@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)
- '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-layout-effect': 1.1.2(@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-collection@1.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/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@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)
- '@radix-ui/react-slot': 1.3.0(@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-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
- optionalDependencies:
- '@types/react': 19.2.17
-
- '@radix-ui/react-context-menu@2.3.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/primitive': 1.1.5
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-menu': 2.1.20(@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.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-use-controllable-state': 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-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
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@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)
- '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-focus-scope': 1.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)
- '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-portal': 1.1.13(@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.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-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)
- '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-controllable-state': 1.2.3(@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-direction@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-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
- '@radix-ui/react-compose-refs': 1.1.3(@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)
- '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-effect-event': 0.0.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-dropdown-menu@2.1.20(@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
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-menu': 2.1.20(@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.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-use-controllable-state': 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-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
- optionalDependencies:
- '@types/react': 19.2.17
-
- '@radix-ui/react-focus-scope@1.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/react-compose-refs': 1.1.3(@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)
- '@radix-ui/react-use-callback-ref': 1.1.2(@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-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)
+ '@parcel/watcher-win32-arm64': 2.5.6
+ '@parcel/watcher-win32-ia32': 2.5.6
+ '@parcel/watcher-win32-x64': 2.5.6
- '@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)':
+ '@playwright/test@1.59.1':
dependencies:
- '@radix-ui/primitive': 1.1.5
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-label': 2.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-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)
- optionalDependencies:
- '@types/react': 19.2.17
- '@types/react-dom': 19.2.3(@types/react@19.2.17)
-
- '@radix-ui/react-hover-card@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
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@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)
- '@radix-ui/react-popper': 1.3.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-portal': 1.1.13(@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.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-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)
- '@radix-ui/react-use-controllable-state': 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)
+ playwright: 1.59.1
- '@radix-ui/react-id@1.1.1(@types/react@19.2.17)(react@19.2.7)':
+ '@posthog/core@1.28.3':
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
+ '@posthog/types': 1.372.9
+
+ '@posthog/types@1.372.9': {}
+
+ '@radix-ui/primitive@1.1.3': {}
- '@radix-ui/react-id@1.1.2(@types/react@19.2.17)(react@19.2.7)':
+ '@radix-ui/react-compose-refs@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)
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
- '@radix-ui/react-label@2.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-context@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies:
- '@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)
optionalDependencies:
'@types/react': 19.2.17
- '@types/react-dom': 19.2.3(@types/react@19.2.17)
- '@radix-ui/react-menu@2.1.20(@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
- '@radix-ui/react-collection': 1.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)
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@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)
- '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-focus-scope': 1.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)
- '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-popper': 1.3.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-portal': 1.1.13(@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.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-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)
- '@radix-ui/react-roving-focus': 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-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-callback-ref': 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)':
+ 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)
@@ -8174,132 +7198,42 @@ snapshots:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
- '@radix-ui/react-menubar@1.1.20(@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
- '@radix-ui/react-collection': 1.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)
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-menu': 2.1.20(@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.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-roving-focus': 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-use-controllable-state': 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-navigation-menu@1.2.18(@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
- '@radix-ui/react-collection': 1.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)
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@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)
- '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@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)
- '@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)
- '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-visually-hidden': 1.2.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)
- optionalDependencies:
- '@types/react': 19.2.17
- '@types/react-dom': 19.2.3(@types/react@19.2.17)
-
- '@radix-ui/react-one-time-password-field@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/number': 1.1.2
- '@radix-ui/primitive': 1.1.5
- '@radix-ui/react-collection': 1.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)
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-direction': 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)
- '@radix-ui/react-roving-focus': 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-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-layout-effect': 1.1.2(@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-password-toggle-field@0.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-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.5
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(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)
- '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7)
+ '@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-popover@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
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@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)
- '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-focus-scope': 1.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)
- '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-popper': 1.3.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-portal': 1.1.13(@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.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-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)
- '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
- aria-hidden: 1.2.6
+ '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.17)(react@19.2.7)':
+ dependencies:
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-popper@1.3.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-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:
- '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
- '@radix-ui/react-arrow': 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-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@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)
- '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-rect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/rect': 1.1.2
+ '@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-portal@1.1.13(@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)':
dependencies:
- '@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)
- '@radix-ui/react-use-layout-effect': 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-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:
@@ -8321,15 +7255,6 @@ snapshots:
'@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)
- 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.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)
@@ -8348,137 +7273,6 @@ snapshots:
'@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)
- 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-progress@1.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/react-context': 1.2.0(@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)
- optionalDependencies:
- '@types/react': 19.2.17
- '@types/react-dom': 19.2.3(@types/react@19.2.17)
-
- '@radix-ui/react-radio-group@1.4.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/primitive': 1.1.5
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@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)
- '@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)
- '@radix-ui/react-roving-focus': 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-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-size': 1.1.2(@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-roving-focus@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
- '@radix-ui/react-collection': 1.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)
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(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)
- '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-layout-effect': 1.1.2(@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-scroll-area@1.2.14(@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/number': 1.1.2
- '@radix-ui/primitive': 1.1.5
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@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)
- '@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)
- '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-layout-effect': 1.1.2(@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-select@2.3.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/number': 1.1.2
- '@radix-ui/primitive': 1.1.5
- '@radix-ui/react-collection': 1.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)
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@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)
- '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-focus-scope': 1.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)
- '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-popper': 1.3.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-portal': 1.1.13(@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.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-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)
- '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-visually-hidden': 1.2.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)
- 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-separator@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/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)
- optionalDependencies:
- '@types/react': 19.2.17
- '@types/react-dom': 19.2.3(@types/react@19.2.17)
-
- '@radix-ui/react-slider@1.4.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/number': 1.1.2
- '@radix-ui/primitive': 1.1.5
- '@radix-ui/react-collection': 1.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)
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-direction': 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)
- '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-size': 1.1.2(@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-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)
@@ -8493,137 +7287,12 @@ snapshots:
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)
- react: 19.2.7
- optionalDependencies:
- '@types/react': 19.2.17
-
- '@radix-ui/react-switch@1.3.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/primitive': 1.1.5
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@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)
- '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-size': 1.1.2(@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-tabs@1.1.17(@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
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@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)
- '@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)
- '@radix-ui/react-roving-focus': 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-use-controllable-state': 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-toast@1.2.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
- '@radix-ui/react-collection': 1.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)
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@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)
- '@radix-ui/react-portal': 1.1.13(@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.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-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)
- '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-visually-hidden': 1.2.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)
- optionalDependencies:
- '@types/react': 19.2.17
- '@types/react-dom': 19.2.3(@types/react@19.2.17)
-
- '@radix-ui/react-toggle-group@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
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-direction': 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)
- '@radix-ui/react-roving-focus': 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-toggle': 1.1.14(@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-controllable-state': 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-toggle@1.1.14(@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
- '@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)
- '@radix-ui/react-use-controllable-state': 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-toolbar@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
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-direction': 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)
- '@radix-ui/react-roving-focus': 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-separator': 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-toggle-group': 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)
- 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-tooltip@1.2.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
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@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)
- '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-popper': 1.3.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-portal': 1.1.13(@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.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-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)
- '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-visually-hidden': 1.2.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)
- optionalDependencies:
- '@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)
@@ -8632,14 +7301,6 @@ snapshots:
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)
- '@radix-ui/react-use-layout-effect': 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-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)
@@ -8647,13 +7308,6 @@ snapshots:
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)
- react: 19.2.7
- 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)
@@ -8661,62 +7315,12 @@ snapshots:
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)
- react: 19.2.7
- optionalDependencies:
- '@types/react': 19.2.17
-
- '@radix-ui/react-use-is-hydrated@0.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.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
- optionalDependencies:
- '@types/react': 19.2.17
-
- '@radix-ui/react-use-previous@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-rect@1.1.2(@types/react@19.2.17)(react@19.2.7)':
- dependencies:
- '@radix-ui/rect': 1.1.2
- react: 19.2.7
- optionalDependencies:
- '@types/react': 19.2.17
-
- '@radix-ui/react-use-size@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)
- react: 19.2.7
- optionalDependencies:
- '@types/react': 19.2.17
-
- '@radix-ui/react-visually-hidden@1.2.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-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)
- optionalDependencies:
- '@types/react': 19.2.17
- '@types/react-dom': 19.2.3(@types/react@19.2.17)
-
- '@radix-ui/rect@1.1.2': {}
-
'@react-grab/cli@0.1.34':
dependencies:
commander: 14.0.3
@@ -12442,69 +11046,6 @@ snapshots:
quick-lru@5.1.1: {}
- radix-ui@1.6.2(@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
- '@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)
- '@radix-ui/react-accordion': 1.2.16(@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-alert-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-arrow': 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-aspect-ratio': 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-avatar': 1.2.2(@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-checkbox': 1.3.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-collapsible': 1.1.16(@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-collection': 1.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)
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-context-menu': 2.3.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-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-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@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)
- '@radix-ui/react-dropdown-menu': 2.1.20(@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.4(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-focus-scope': 1.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)
- '@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)
- '@radix-ui/react-hover-card': 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-label': 2.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-menu': 2.1.20(@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-menubar': 1.1.20(@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-navigation-menu': 1.2.18(@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-one-time-password-field': 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)
- '@radix-ui/react-password-toggle-field': 0.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-popover': 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-popper': 1.3.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-portal': 1.1.13(@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.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-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)
- '@radix-ui/react-progress': 1.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)
- '@radix-ui/react-radio-group': 1.4.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-roving-focus': 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-scroll-area': 1.2.14(@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-select': 2.3.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-separator': 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-slider': 1.4.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.3.0(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-switch': 1.3.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-tabs': 1.1.17(@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-toast': 1.2.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-toggle': 1.1.14(@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-toggle-group': 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-toolbar': 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-tooltip': 1.2.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)
- '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-escape-keydown': 1.1.3(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7)
- '@radix-ui/react-visually-hidden': 1.2.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)
- optionalDependencies:
- '@types/react': 19.2.17
- '@types/react-dom': 19.2.3(@types/react@19.2.17)
-
range-parser@1.2.1: {}
raw-body@3.0.2:
@@ -12717,6 +11258,8 @@ snapshots:
dependencies:
pe-library: 0.4.1
+ reselect@5.2.0: {}
+
resolve-alpn@1.2.1: {}
resolve-from@4.0.0: {}
diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts
index 4b39e75eb72..3ebf16607ff 100644
--- a/src/main/ai-vault/cached-session-list.ts
+++ b/src/main/ai-vault/cached-session-list.ts
@@ -1,21 +1,23 @@
-import { join } from 'node:path'
import { scanAiVaultSessions } from './session-scanner'
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
+import {
+ getConfiguredAiVaultAdditionalCodexSessionsDirs,
+ resetAiVaultSessionRootConfigurationForTests
+} from './session-root-configuration'
// Why: ONE module owns the scan cache so the desktop IPC handler AND the runtime
// RPC method share a single cache instance — opening the desktop panel and the
// mobile screen for the same scope must not double-scan hundreds of transcripts.
const AI_VAULT_CACHE_TTL_MS = 15_000
-// Why: codex-home + WSL home dirs must be sourced from a serve-mode-reachable
-// seam (the OrcaRuntimeService deps), NOT the window-only registerCoreHandlers
-// path — `orca serve` never runs that path, so sourcing it there would silently
-// drop managed-Codex sessions from remote/SSH results.
-export type AiVaultSessionSources = {
- getAdditionalCodexHomePaths?: () => readonly string[]
-}
+export {
+ configureAiVaultSessionSources,
+ getConfiguredAiVaultAdditionalCodexSessionsDirs,
+ getConfiguredAiVaultClaudeProjectsDirs
+} from './session-root-configuration'
+export type { AiVaultSessionSources } from './session-root-configuration'
type CachedAiVaultList = {
key: string
@@ -26,12 +28,6 @@ type CachedAiVaultList = {
let cachedList: CachedAiVaultList | null = null
let inflightList: Promise | null = null
let inflightKey: string | null = null
-let sources: AiVaultSessionSources = {}
-
-export function configureAiVaultSessionSources(next: AiVaultSessionSources): void {
- sources = next
-}
-
export async function listAiVaultSessions(args?: AiVaultListArgs): Promise {
// Scope paths change the result set, so they must be part of the cache key.
const key = JSON.stringify({
@@ -49,8 +45,7 @@ export async function listAiVaultSessions(args?: AiVaultListArgs): Promise join(homePath, 'sessions')) ?? []
+ const additionalCodexSessionsDirs = getConfiguredAiVaultAdditionalCodexSessionsDirs()
inflightList = (async () =>
scanAiVaultSessions({
limit: args?.limit,
@@ -97,5 +92,5 @@ export function resetAiVaultSessionListCacheForTests(): void {
cachedList = null
inflightList = null
inflightKey = null
- sources = {}
+ resetAiVaultSessionRootConfigurationForTests()
}
diff --git a/src/main/ai-vault/local-spool-session-inventory.ts b/src/main/ai-vault/local-spool-session-inventory.ts
new file mode 100644
index 00000000000..21f010289d4
--- /dev/null
+++ b/src/main/ai-vault/local-spool-session-inventory.ts
@@ -0,0 +1,296 @@
+import { join } from 'node:path'
+import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types'
+import type { ExecutionHostId } from '../../shared/execution-host'
+import { parseWslUncPath } from '../../shared/wsl-paths'
+import {
+ getConfiguredAiVaultAdditionalCodexSessionsDirs,
+ getConfiguredAiVaultClaudeProjectsDirs
+} from './session-root-configuration'
+import { AiVaultSessionInventoryCursorStore } from './session-inventory-cursor-store'
+import type {
+ AiVaultSessionInventoryPage,
+ AiVaultSessionInventorySlice,
+ AiVaultSessionInventorySnapshot
+} from './session-inventory-page-types'
+import { codexHomeForSessionsDir } from './session-scanner-codex-paths'
+import { parseAgentSessionFileCached } from './session-scanner-parse-cache'
+import {
+ claudeProjectsRootDirs,
+ codexSessionRootDirs,
+ DEFAULT_CODEX_HOME_DIR
+} from './session-scanner-source-discovery'
+import type { SessionFileCandidate } from './session-scanner-types'
+import { resolveAiVaultWslHomeDirsForDistro } from './session-wsl-home-resolution'
+import { discoverSpoolSessionInventorySources } from './spool-session-inventory-source-discovery'
+import { SessionInventorySnapshotCache } from './session-inventory-snapshot-cache'
+import {
+ estimateSpoolSessionInventorySnapshotBytes,
+ SPOOL_SESSION_INVENTORY_SNAPSHOT_MAX_RETAINED_BYTES,
+ SPOOL_SESSION_INVENTORY_SNAPSHOT_OPENING_RESERVATION_BYTES
+} from './spool-session-inventory-memory-budget'
+
+const SESSION_PARSE_CONCURRENCY = 8
+const INVENTORY_PAGE_SIZE = 64
+
+type LocalSpoolSessionInventorySnapshot = AiVaultSessionInventorySnapshot & {
+ candidates: readonly SessionFileCandidate[]
+ executionHostId: ExecutionHostId
+ // Why: batch publication coalesces equal offsets without retaining all parsed rows.
+ pageReads: Map>
+}
+
+export type LocalSpoolSessionInventoryPageArgs = {
+ bindingKey: string
+ cursor: string | null
+ executionHostId: ExecutionHostId
+ inventoryScope: string
+ worktreePath: string
+ localWslDistro: string | null
+ signal?: AbortSignal
+}
+
+// Why: paired-runtime pages carry resume commands and locator metadata; a
+// smaller internal page keeps each encrypted RPC response comfortably bounded.
+const cursorStore = new AiVaultSessionInventoryCursorStore({
+ pageSize: INVENTORY_PAGE_SIZE
+})
+const snapshotCache = new SessionInventorySnapshotCache({
+ maxRetainedBytes: SPOOL_SESSION_INVENTORY_SNAPSHOT_MAX_RETAINED_BYTES,
+ openingReservationBytes: SPOOL_SESSION_INVENTORY_SNAPSHOT_OPENING_RESERVATION_BYTES,
+ measureSnapshotBytes: (snapshot) =>
+ estimateSpoolSessionInventorySnapshotBytes(snapshot.candidates)
+})
+
+export async function listLocalSpoolSessionInventoryPage(
+ args: LocalSpoolSessionInventoryPageArgs
+): Promise {
+ return await cursorStore.readPage({
+ bindingKey: localInventoryBindingKey(args),
+ cursor: args.cursor,
+ signal: args.signal,
+ openSnapshot: async (signal) =>
+ await snapshotCache.resolve(
+ localSnapshotKey(args),
+ async (openingSignal) => await openLocalInventorySnapshot(args, openingSignal),
+ signal
+ ),
+ readSnapshotPage: readLocalInventoryPage,
+ releaseSnapshot: (snapshot) => snapshotCache.release(snapshot)
+ })
+}
+
+export function releaseLocalSpoolSessionInventoryPage(
+ args: LocalSpoolSessionInventoryPageArgs
+): void {
+ cursorStore.release(localInventoryBindingKey(args), args.cursor)
+}
+
+async function openLocalInventorySnapshot(
+ args: LocalSpoolSessionInventoryPageArgs,
+ signal: AbortSignal
+): Promise {
+ signal.throwIfAborted()
+ const issues: AiVaultScanIssue[] = []
+ const roots = await resolveLocalInventoryRoots(args, signal)
+ const discoveries = await discoverSpoolSessionInventorySources({
+ options: {
+ additionalCodexSessionsDirs: roots.additionalCodexSessionsDirs,
+ wslHomeDirs: roots.wslHomeDirs
+ },
+ issues,
+ claudeProjectsDirs: roots.claudeProjectsDirs,
+ codexSessionDirs: roots.codexSessionDirs,
+ signal
+ })
+ signal.throwIfAborted()
+ const candidates = discoveries
+ .flatMap((discovery) =>
+ discovery.files.map(
+ (file): SessionFileCandidate => ({
+ agent: discovery.agent,
+ file,
+ codexHome:
+ discovery.agent === 'codex'
+ ? codexHomeForSessionsDir(discovery.rootDir, DEFAULT_CODEX_HOME_DIR)
+ : null
+ })
+ )
+ )
+ .sort(compareCandidates)
+
+ return {
+ candidates,
+ executionHostId: args.executionHostId,
+ pageReads: new Map(),
+ scannedAt: new Date().toISOString()
+ }
+}
+
+async function readLocalInventoryPage(
+ snapshot: LocalSpoolSessionInventorySnapshot,
+ offset: number,
+ pageSize: number,
+ signal: AbortSignal
+): Promise {
+ const key = `${offset}:${pageSize}`
+ const existing = snapshot.pageReads.get(key)
+ if (existing) {
+ return await existing
+ }
+ const read = readLocalInventoryPageUncached(snapshot, offset, pageSize, signal)
+ snapshot.pageReads.set(key, read)
+ try {
+ return await read
+ } finally {
+ if (snapshot.pageReads.get(key) === read) {
+ snapshot.pageReads.delete(key)
+ }
+ }
+}
+
+async function readLocalInventoryPageUncached(
+ snapshot: LocalSpoolSessionInventorySnapshot,
+ offset: number,
+ pageSize: number,
+ signal: AbortSignal
+): Promise {
+ const sessions: AiVaultSession[] = []
+ const nextOffset = Math.min(offset + pageSize, snapshot.candidates.length)
+ for (let index = offset; index < nextOffset; index += SESSION_PARSE_CONCURRENCY) {
+ signal.throwIfAborted()
+ const batch = snapshot.candidates.slice(
+ index,
+ Math.min(index + SESSION_PARSE_CONCURRENCY, nextOffset)
+ )
+ signal.throwIfAborted()
+ const parsed = await Promise.all(
+ batch.map(
+ async (candidate) =>
+ await parseInventoryCandidate(candidate, snapshot.executionHostId, signal)
+ )
+ )
+ signal.throwIfAborted()
+ for (const session of parsed) {
+ if (session) {
+ sessions.push(session)
+ }
+ }
+ }
+
+ signal.throwIfAborted()
+ return {
+ sessions,
+ nextOffset,
+ complete: nextOffset >= snapshot.candidates.length
+ }
+}
+
+async function parseInventoryCandidate(
+ candidate: SessionFileCandidate,
+ executionHostId: ExecutionHostId,
+ signal: AbortSignal
+): Promise {
+ try {
+ const session = await parseAgentSessionFileCached(
+ candidate,
+ process.platform,
+ undefined,
+ signal
+ )
+ if (!session) {
+ return null
+ }
+ return {
+ ...session,
+ executionHostId,
+ id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}`
+ }
+ } catch (error) {
+ const code = (error as NodeJS.ErrnoException | null)?.code
+ if (code === 'ENOENT') {
+ return null
+ }
+ // Why: parsers already represent malformed transcripts as null; unexpected
+ // failures must not turn a partial inventory into an authoritative end-of-list.
+ throw error
+ }
+}
+
+function compareCandidates(left: SessionFileCandidate, right: SessionFileCandidate): number {
+ const byMtime = right.file.mtimeMs - left.file.mtimeMs
+ if (byMtime !== 0) {
+ return byMtime
+ }
+ const byAgent = compareText(left.agent, right.agent)
+ return byAgent !== 0 ? byAgent : compareText(left.file.path, right.file.path)
+}
+
+function compareText(left: string, right: string): number {
+ return left < right ? -1 : left > right ? 1 : 0
+}
+
+function localInventoryBindingKey(args: LocalSpoolSessionInventoryPageArgs): string {
+ return JSON.stringify([args.bindingKey, args.executionHostId])
+}
+
+function localSnapshotKey(args: LocalSpoolSessionInventoryPageArgs): string {
+ return JSON.stringify([args.inventoryScope, args.executionHostId, localInventoryRuntime(args)])
+}
+
+async function resolveLocalInventoryRoots(
+ args: LocalSpoolSessionInventoryPageArgs,
+ signal: AbortSignal
+) {
+ const runtime = localInventoryRuntime(args)
+ if (runtime.kind === 'wsl') {
+ const wslHomeDirs = await resolveAiVaultWslHomeDirsForDistro(runtime.distro)
+ signal.throwIfAborted()
+ const claudeProjectsDirs =
+ (await getConfiguredAiVaultClaudeProjectsDirs({
+ runtime: 'wsl',
+ wslDistro: runtime.distro
+ })) ?? wslHomeDirs.map((home) => join(home, '.claude', 'projects'))
+ signal.throwIfAborted()
+ const primaryHome = wslHomeDirs[0]
+ if (!primaryHome) {
+ throw new Error('AI Vault WSL home is unavailable')
+ }
+ return {
+ wslHomeDirs,
+ claudeProjectsDirs,
+ codexSessionDirs: codexSessionRootDirs(
+ { codexSessionsDir: join(primaryHome, '.codex', 'sessions') },
+ wslHomeDirs
+ ),
+ additionalCodexSessionsDirs: []
+ }
+ }
+ const additionalCodexSessionsDirs = getConfiguredAiVaultAdditionalCodexSessionsDirs()
+ signal.throwIfAborted()
+ const claudeProjectsDirs =
+ (await getConfiguredAiVaultClaudeProjectsDirs({ runtime: 'host' })) ??
+ claudeProjectsRootDirs({})
+ signal.throwIfAborted()
+ return {
+ wslHomeDirs: [],
+ claudeProjectsDirs,
+ codexSessionDirs: codexSessionRootDirs({ additionalCodexSessionsDirs }, []),
+ additionalCodexSessionsDirs
+ }
+}
+
+function localInventoryRuntime(
+ args: LocalSpoolSessionInventoryPageArgs
+): { kind: 'host' } | { kind: 'wsl'; distro: string } {
+ const pathDistro = parseWslUncPath(args.worktreePath)?.distro ?? null
+ const configuredDistro = args.localWslDistro?.trim() || null
+ if (
+ pathDistro &&
+ configuredDistro &&
+ pathDistro.toLowerCase() !== configuredDistro.toLowerCase()
+ ) {
+ throw new Error('AI Vault WSL target is inconsistent')
+ }
+ const distro = pathDistro ?? configuredDistro
+ return distro ? { kind: 'wsl', distro } : { kind: 'host' }
+}
diff --git a/src/main/ai-vault/remote-session-inventory-budget.ts b/src/main/ai-vault/remote-session-inventory-budget.ts
new file mode 100644
index 00000000000..699d9e824f3
--- /dev/null
+++ b/src/main/ai-vault/remote-session-inventory-budget.ts
@@ -0,0 +1,43 @@
+import { SessionFileDiscoveryLimitError } from './session-scanner-discovery'
+import {
+ SPOOL_SESSION_INVENTORY_MAX_CANDIDATES,
+ SPOOL_SESSION_INVENTORY_MAX_PATH_BYTES,
+ SPOOL_SESSION_INVENTORY_MAX_TRAVERSAL_ENTRIES
+} from './spool-session-inventory-source-discovery'
+
+export type RemoteSessionInventoryBudget = {
+ entries: number
+ candidates: number
+ pathBytes: number
+}
+
+export function createRemoteSessionInventoryBudget(): RemoteSessionInventoryBudget {
+ return { entries: 0, candidates: 0, pathBytes: 0 }
+}
+
+export function remainingRemoteSessionInventoryEntries(
+ budget: RemoteSessionInventoryBudget
+): number {
+ return SPOOL_SESSION_INVENTORY_MAX_TRAVERSAL_ENTRIES - budget.entries
+}
+
+export function consumeRemoteSessionInventoryEntry(
+ path: string,
+ budget: RemoteSessionInventoryBudget
+): void {
+ budget.entries++
+ budget.pathBytes += Buffer.byteLength(path, 'utf8')
+ if (
+ budget.entries > SPOOL_SESSION_INVENTORY_MAX_TRAVERSAL_ENTRIES ||
+ budget.pathBytes > SPOOL_SESSION_INVENTORY_MAX_PATH_BYTES
+ ) {
+ throw new SessionFileDiscoveryLimitError()
+ }
+}
+
+export function consumeRemoteSessionInventoryCandidate(budget: RemoteSessionInventoryBudget): void {
+ budget.candidates++
+ if (budget.candidates > SPOOL_SESSION_INVENTORY_MAX_CANDIDATES) {
+ throw new SessionFileDiscoveryLimitError()
+ }
+}
diff --git a/src/main/ai-vault/remote-session-inventory-candidates.ts b/src/main/ai-vault/remote-session-inventory-candidates.ts
new file mode 100644
index 00000000000..f58bfbd6e52
--- /dev/null
+++ b/src/main/ai-vault/remote-session-inventory-candidates.ts
@@ -0,0 +1,88 @@
+import type { AiVaultSession } from '../../shared/ai-vault-types'
+import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types'
+import type { FileWithMtime } from './session-scanner-types'
+import type { RemoteScannerContext, RemoteSessionCandidate } from './remote-session-scanner-types'
+
+export async function statRemoteInventoryFile(
+ provider: IFilesystemProvider,
+ path: string,
+ signal?: AbortSignal
+): Promise {
+ try {
+ const stat = await provider.stat(path, { signal })
+ signal?.throwIfAborted()
+ const mtimeMs = remoteInventoryMtimeMs(stat)
+ return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() }
+ } catch (error) {
+ // Rotation between directory discovery and stat is benign; transport and
+ // permission failures must still invalidate the frozen inventory.
+ if (isRemotePathMissing(error)) {
+ return null
+ }
+ throw error
+ }
+}
+
+export async function parseRemoteInventoryCandidate(
+ candidate: RemoteSessionCandidate,
+ context: RemoteScannerContext,
+ signal: AbortSignal
+): Promise {
+ if (candidate.source.parseIncrementally && context.provider.consumeSessionInventoryJsonLines) {
+ try {
+ const session = await candidate.source.parseIncrementally(candidate.file, context, signal)
+ signal.throwIfAborted()
+ return withSubagentTranscriptCount(session, candidate.subagentTranscriptCount)
+ } catch (error) {
+ if (isRemotePathMissing(error)) {
+ return null
+ }
+ throw error
+ }
+ }
+ let read: FileReadResult
+ try {
+ read = await context.provider.readFile(candidate.file.path, { signal })
+ signal.throwIfAborted()
+ } catch (error) {
+ if (isRemotePathMissing(error)) {
+ return null
+ }
+ throw error
+ }
+ if (read.isBinary) {
+ return null
+ }
+ // Why: malformed transcripts return null, but transport/parser failures must
+ // abort rather than making a partial frozen inventory look authoritative.
+ const session = await candidate.source.parse(candidate.file, read.content, context)
+ signal.throwIfAborted()
+ return withSubagentTranscriptCount(session, candidate.subagentTranscriptCount)
+}
+
+export function isRemotePathMissing(error: unknown): boolean {
+ const code =
+ typeof error === 'object' && error !== null && 'code' in error
+ ? (error as { code?: unknown }).code
+ : null
+ if (code === 'ENOENT') {
+ return true
+ }
+ const message = error instanceof Error ? error.message : String(error)
+ return /\bENOENT\b|no such file or directory/i.test(message)
+}
+
+function withSubagentTranscriptCount(
+ session: AiVaultSession | null,
+ count: number | undefined
+): AiVaultSession | null {
+ const subagentTranscriptCount = count ?? 0
+ return session && subagentTranscriptCount > 0 ? { ...session, subagentTranscriptCount } : session
+}
+
+function remoteInventoryMtimeMs(stat: FileStat): number {
+ if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) {
+ return stat.mtimeMs
+ }
+ return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000
+}
diff --git a/src/main/ai-vault/remote-session-inventory.ts b/src/main/ai-vault/remote-session-inventory.ts
new file mode 100644
index 00000000000..cc8a1855039
--- /dev/null
+++ b/src/main/ai-vault/remote-session-inventory.ts
@@ -0,0 +1,262 @@
+import { extname } from 'node:path'
+import type { AiVaultSession } from '../../shared/ai-vault-types'
+import type { ExecutionHostId } from '../../shared/execution-host'
+import type { IFilesystemProvider } from '../providers/types'
+import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
+import { joinRemotePath } from '../ssh/ssh-remote-platform'
+import type {
+ AiVaultSessionInventorySlice,
+ AiVaultSessionInventorySnapshot
+} from './session-inventory-page-types'
+import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts'
+import type { FileWithMtime } from './session-scanner-types'
+import { remoteClaudeCodexSessionSources } from './remote-session-scanner-sources'
+import type {
+ RemoteScannerContext,
+ RemoteSessionCandidate,
+ RemoteSessionSource
+} from './remote-session-scanner-types'
+import {
+ consumeRemoteSessionInventoryCandidate,
+ consumeRemoteSessionInventoryEntry,
+ createRemoteSessionInventoryBudget,
+ remainingRemoteSessionInventoryEntries,
+ type RemoteSessionInventoryBudget
+} from './remote-session-inventory-budget'
+import { SessionFileDiscoveryLimitError } from './session-scanner-discovery'
+import {
+ isRemotePathMissing,
+ parseRemoteInventoryCandidate,
+ statRemoteInventoryFile
+} from './remote-session-inventory-candidates'
+
+const REMOTE_INVENTORY_CONCURRENCY = 8
+
+export type RemoteAiVaultSessionInventorySnapshot = AiVaultSessionInventorySnapshot & {
+ provider: IFilesystemProvider
+ targetId: string
+ executionHostId: ExecutionHostId
+ remoteHome: string
+ hostPlatform: RemoteHostPlatform
+ candidates: readonly RemoteSessionCandidate[]
+ context: RemoteScannerContext
+ // Why: batch publication coalesces equal offsets without retaining all remote file reads.
+ pageReads: Map>
+}
+
+export async function openRemoteAiVaultSessionInventory(args: {
+ provider: IFilesystemProvider
+ targetId: string
+ executionHostId: ExecutionHostId
+ remoteHome: string
+ hostPlatform: RemoteHostPlatform
+ signal?: AbortSignal
+}): Promise {
+ args.signal?.throwIfAborted()
+ const context: RemoteScannerContext = {
+ provider: args.provider,
+ executionHostId: args.executionHostId,
+ hostPlatform: args.hostPlatform,
+ titleCaches: new Map()
+ }
+ const budget = createRemoteSessionInventoryBudget()
+ const candidates: RemoteSessionCandidate[] = []
+ for (const source of remoteClaudeCodexSessionSources(args.remoteHome, args.hostPlatform)) {
+ args.signal?.throwIfAborted()
+ candidates.push(
+ ...(await discoverRemoteInventoryCandidates(source, context, budget, args.signal))
+ )
+ }
+ candidates.sort(compareRemoteInventoryCandidates)
+
+ return {
+ provider: args.provider,
+ targetId: args.targetId,
+ executionHostId: args.executionHostId,
+ remoteHome: args.remoteHome,
+ hostPlatform: args.hostPlatform,
+ candidates,
+ context,
+ pageReads: new Map(),
+ scannedAt: new Date().toISOString()
+ }
+}
+
+export async function readRemoteAiVaultSessionInventoryPage(
+ snapshot: RemoteAiVaultSessionInventorySnapshot,
+ offset: number,
+ pageSize: number,
+ signal: AbortSignal
+): Promise {
+ const key = `${offset}:${pageSize}`
+ const existing = snapshot.pageReads.get(key)
+ if (existing) {
+ return await existing
+ }
+ const read = readRemoteAiVaultSessionInventoryPageUncached(snapshot, offset, pageSize, signal)
+ snapshot.pageReads.set(key, read)
+ try {
+ return await read
+ } finally {
+ if (snapshot.pageReads.get(key) === read) {
+ snapshot.pageReads.delete(key)
+ }
+ }
+}
+
+async function readRemoteAiVaultSessionInventoryPageUncached(
+ snapshot: RemoteAiVaultSessionInventorySnapshot,
+ offset: number,
+ pageSize: number,
+ signal: AbortSignal
+): Promise {
+ const sessions: AiVaultSession[] = []
+ const nextOffset = Math.min(offset + pageSize, snapshot.candidates.length)
+ for (let index = offset; index < nextOffset; index += REMOTE_INVENTORY_CONCURRENCY) {
+ signal.throwIfAborted()
+ const batch = snapshot.candidates.slice(
+ index,
+ Math.min(index + REMOTE_INVENTORY_CONCURRENCY, nextOffset)
+ )
+ const parsed = await Promise.all(
+ batch.map((candidate) => parseRemoteInventoryCandidate(candidate, snapshot.context, signal))
+ )
+ signal.throwIfAborted()
+ for (const session of parsed) {
+ if (session) {
+ sessions.push(session)
+ }
+ }
+ }
+
+ return {
+ sessions,
+ nextOffset,
+ complete: nextOffset >= snapshot.candidates.length
+ }
+}
+
+async function discoverRemoteInventoryCandidates(
+ source: RemoteSessionSource,
+ context: RemoteScannerContext,
+ budget: RemoteSessionInventoryBudget,
+ signal?: AbortSignal
+): Promise {
+ const walked = await walkRemoteInventoryFiles(
+ source,
+ context.provider,
+ context.hostPlatform,
+ budget,
+ signal
+ )
+ const partition = source.collectSubagentSiblingCounts
+ ? partitionSubagentTranscriptPaths(walked)
+ : null
+ const paths = partition ? partition.sessionFilePaths : walked
+ const files = await mapRemoteInventoryConcurrently(
+ paths,
+ (path) => statRemoteInventoryFile(context.provider, path, signal),
+ signal
+ )
+ return files
+ .filter((file): file is FileWithMtime => file !== null)
+ .map((file) => ({
+ source,
+ file,
+ subagentTranscriptCount: partition?.subagentTranscriptCounts.get(file.path) ?? 0
+ }))
+}
+
+async function walkRemoteInventoryFiles(
+ source: RemoteSessionSource,
+ provider: IFilesystemProvider,
+ hostPlatform: RemoteHostPlatform,
+ budget: RemoteSessionInventoryBudget,
+ signal?: AbortSignal,
+ dirPath = source.rootDir,
+ isRoot = true
+): Promise {
+ let entries
+ try {
+ signal?.throwIfAborted()
+ const remainingEntries = remainingRemoteSessionInventoryEntries(budget)
+ if (remainingEntries < 0) {
+ throw new SessionFileDiscoveryLimitError()
+ }
+ entries = await provider.readDir(dirPath, {
+ limit: remainingEntries + 1,
+ signal
+ })
+ signal?.throwIfAborted()
+ if (entries.length > remainingEntries) {
+ throw new SessionFileDiscoveryLimitError()
+ }
+ } catch (error) {
+ if (isRoot && isRemotePathMissing(error)) {
+ return []
+ }
+ // Missing nested paths and transport failures invalidate the frozen inventory.
+ throw error
+ }
+
+ const extensions = new Set(source.extensions)
+ const files: string[] = []
+ for (const entry of entries) {
+ signal?.throwIfAborted()
+ const fullPath = joinRemotePath(hostPlatform, dirPath, entry.name)
+ consumeRemoteSessionInventoryEntry(fullPath, budget)
+ if (entry.isDirectory && !entry.isSymlink) {
+ files.push(
+ ...(await walkRemoteInventoryFiles(
+ source,
+ provider,
+ hostPlatform,
+ budget,
+ signal,
+ fullPath,
+ false
+ ))
+ )
+ continue
+ }
+ if (
+ !entry.isSymlink &&
+ extensions.has(extname(entry.name).toLowerCase()) &&
+ (source.filePredicate?.(fullPath) ?? true)
+ ) {
+ consumeRemoteSessionInventoryCandidate(budget)
+ files.push(fullPath)
+ }
+ }
+ return files
+}
+
+function compareRemoteInventoryCandidates(
+ left: RemoteSessionCandidate,
+ right: RemoteSessionCandidate
+): number {
+ return (
+ right.file.mtimeMs - left.file.mtimeMs ||
+ compareInventoryText(left.source.agent, right.source.agent) ||
+ compareInventoryText(left.file.path, right.file.path)
+ )
+}
+
+function compareInventoryText(left: string, right: string): number {
+ return left < right ? -1 : left > right ? 1 : 0
+}
+
+async function mapRemoteInventoryConcurrently(
+ items: readonly T[],
+ mapper: (item: T) => Promise,
+ signal?: AbortSignal
+): Promise {
+ const results: U[] = []
+ for (let index = 0; index < items.length; index += REMOTE_INVENTORY_CONCURRENCY) {
+ signal?.throwIfAborted()
+ const batch = items.slice(index, index + REMOTE_INVENTORY_CONCURRENCY)
+ results.push(...(await Promise.all(batch.map(mapper))))
+ signal?.throwIfAborted()
+ }
+ return results
+}
diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts
index c05261de31d..28bfd97f45d 100644
--- a/src/main/ai-vault/remote-session-scanner-sources.ts
+++ b/src/main/ai-vault/remote-session-scanner-sources.ts
@@ -20,6 +20,10 @@ import type {
RemoteScannerContext,
RemoteSessionSource
} from './remote-session-scanner-types'
+import {
+ parseRemoteClaudeSessionStream,
+ parseRemoteCodexSessionStream
+} from './remote-session-stream-parsers'
type RemoteContentParser = (
file: FileWithMtime,
@@ -33,22 +37,7 @@ export function remoteSessionSources(
hostPlatform: RemoteHostPlatform
): RemoteSessionSource[] {
return [
- ...remoteCodexSources(remoteHome, hostPlatform),
- {
- ...jsonlSource(
- 'claude',
- remoteHome,
- hostPlatform,
- ['.claude', 'projects'],
- parseClaudeSessionContent
- ),
- // The remote host owns the transcript disk, so the local readdir in the
- // Claude parser is skipped; the walked listing supplies the sibling
- // subagent counts instead. Partitioning also prunes the subagent
- // transcripts themselves, which would otherwise list as phantom
- // top-level sessions carrying the parent's sessionId.
- collectSubagentSiblingCounts: true
- },
+ ...remoteClaudeCodexSessionSources(remoteHome, hostPlatform),
source(
'gemini',
remoteHome,
@@ -108,6 +97,28 @@ export function remoteSessionSources(
]
}
+export function remoteClaudeCodexSessionSources(
+ remoteHome: string,
+ hostPlatform: RemoteHostPlatform
+): RemoteSessionSource[] {
+ return [
+ ...remoteCodexSources(remoteHome, hostPlatform),
+ {
+ ...jsonlSource(
+ 'claude',
+ remoteHome,
+ hostPlatform,
+ ['.claude', 'projects'],
+ parseClaudeSessionContent
+ ),
+ parseIncrementally: parseRemoteClaudeSessionStream,
+ // The remote host owns the transcript disk, so the walked listing must
+ // supply sibling counts and prune subagent transcripts from the inventory.
+ collectSubagentSiblingCounts: true
+ }
+ ]
+}
+
function source(
agent: AiVaultAgent,
remoteHome: string,
@@ -174,7 +185,9 @@ function remoteCodexSources(
titleCaches: context.titleCaches
})
).get(sessionId) ?? null
- })
+ }),
+ parseIncrementally: (file, context, signal) =>
+ parseRemoteCodexSessionStream(file, context, signal, codexHome, hostPlatform)
}))
}
diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts
index c65ef99aded..9728f3a3de3 100644
--- a/src/main/ai-vault/remote-session-scanner-types.ts
+++ b/src/main/ai-vault/remote-session-scanner-types.ts
@@ -29,6 +29,12 @@ export type RemoteSessionSource = {
content: string,
context: RemoteScannerContext
) => Promise
+ /** Why: SSH inventory cannot inherit the ordinary 10 MiB buffered preview limit. */
+ parseIncrementally?: (
+ file: FileWithMtime,
+ context: RemoteScannerContext,
+ signal: AbortSignal
+ ) => Promise
}
export type RemoteSessionCandidate = {
diff --git a/src/main/ai-vault/remote-session-stream-parsers.ts b/src/main/ai-vault/remote-session-stream-parsers.ts
new file mode 100644
index 00000000000..701d79811d9
--- /dev/null
+++ b/src/main/ai-vault/remote-session-stream-parsers.ts
@@ -0,0 +1,73 @@
+import type { AiVaultSession } from '../../shared/ai-vault-types'
+import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
+import {
+ consumeCodexSessionLine,
+ createCodexSessionParseState,
+ finalizeCodexSessionParseState
+} from './session-scanner-codex-parser'
+import {
+ consumeClaudeSessionLine,
+ createClaudeSessionParseState,
+ finalizeClaudeSessionParseState
+} from './session-scanner-primary-parsers'
+import type { FileWithMtime } from './session-scanner-types'
+import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index'
+import type { RemoteScannerContext } from './remote-session-scanner-types'
+
+export async function parseRemoteClaudeSessionStream(
+ file: FileWithMtime,
+ context: RemoteScannerContext,
+ signal: AbortSignal
+): Promise {
+ const state = createClaudeSessionParseState(file)
+ await consumeRemoteSessionInventoryLines(file, context, signal, (line) =>
+ consumeClaudeSessionLine(state, line)
+ )
+ signal.throwIfAborted()
+ return finalizeClaudeSessionParseState(state, context.hostPlatform.os, {
+ executionHostId: context.executionHostId,
+ executionHostPlatform: context.hostPlatform.os
+ })
+}
+
+export async function parseRemoteCodexSessionStream(
+ file: FileWithMtime,
+ context: RemoteScannerContext,
+ signal: AbortSignal,
+ codexHome: string,
+ hostPlatform: RemoteHostPlatform
+): Promise {
+ const state = createCodexSessionParseState(file)
+ await consumeRemoteSessionInventoryLines(file, context, signal, (line) =>
+ consumeCodexSessionLine(state, line)
+ )
+ signal.throwIfAborted()
+ return finalizeCodexSessionParseState(state, context.hostPlatform.os, {
+ codexHome,
+ executionHostId: context.executionHostId,
+ executionHostPlatform: context.hostPlatform.os,
+ titleReader: async (sessionId) =>
+ (
+ await remoteCodexIndexTitles({
+ provider: context.provider,
+ codexHome,
+ hostPlatform,
+ titleCaches: context.titleCaches
+ })
+ ).get(sessionId) ?? null
+ })
+}
+
+async function consumeRemoteSessionInventoryLines(
+ file: FileWithMtime,
+ context: RemoteScannerContext,
+ signal: AbortSignal,
+ consumeLine: (line: string) => void
+): Promise {
+ if (!context.provider.consumeSessionInventoryJsonLines) {
+ throw new Error('Remote session inventory streaming is unavailable')
+ }
+ signal.throwIfAborted()
+ await context.provider.consumeSessionInventoryJsonLines(file.path, consumeLine, { signal })
+ signal.throwIfAborted()
+}
diff --git a/src/main/ai-vault/session-inventory-abort.ts b/src/main/ai-vault/session-inventory-abort.ts
new file mode 100644
index 00000000000..7b084c26446
--- /dev/null
+++ b/src/main/ai-vault/session-inventory-abort.ts
@@ -0,0 +1,50 @@
+export function createSessionInventoryAbortController(
+ signals: readonly (AbortSignal | undefined)[]
+): { controller: AbortController; dispose: () => void } {
+ const controller = new AbortController()
+ const bindings: { signal: AbortSignal; listener: () => void }[] = []
+ for (const signal of signals) {
+ if (!signal) {
+ continue
+ }
+ const listener = (): void => controller.abort(signal.reason)
+ if (signal.aborted) {
+ listener()
+ break
+ }
+ signal.addEventListener('abort', listener, { once: true })
+ bindings.push({ signal, listener })
+ }
+ return {
+ controller,
+ dispose: () => {
+ for (const binding of bindings) {
+ binding.signal.removeEventListener('abort', binding.listener)
+ }
+ }
+ }
+}
+
+export function waitForSessionInventoryAbort(
+ promise: Promise,
+ signal: AbortSignal | undefined
+): Promise {
+ if (!signal) {
+ return promise
+ }
+ signal.throwIfAborted()
+ return new Promise((resolve, reject) => {
+ const aborted = (): void => reject(signal.reason)
+ signal.addEventListener('abort', aborted, { once: true })
+ void promise.then(
+ (value) => {
+ signal.removeEventListener('abort', aborted)
+ resolve(value)
+ },
+ (error) => {
+ signal.removeEventListener('abort', aborted)
+ reject(error)
+ }
+ )
+ })
+}
diff --git a/src/main/ai-vault/session-inventory-cursor-policy.ts b/src/main/ai-vault/session-inventory-cursor-policy.ts
new file mode 100644
index 00000000000..567491dbb5b
--- /dev/null
+++ b/src/main/ai-vault/session-inventory-cursor-policy.ts
@@ -0,0 +1,15 @@
+// Why: candidate parsing is bounded independently from the wire's 512-row session page.
+export const DEFAULT_SESSION_INVENTORY_PAGE_SIZE = 512
+// Why: each chain can retain a host inventory, so concurrent opens need a hard reservation cap.
+export const DEFAULT_MAX_ACTIVE_SESSION_INVENTORIES = 256
+// Why: callers explicitly release active chains; this timer only reclaims abandoned snapshots.
+export const DEFAULT_SESSION_INVENTORY_IDLE_TTL_MS = 15 * 60_000
+// Why: a small replay window tolerates lost replies without retaining unbounded cursor history.
+export const MAX_REPLAYABLE_SESSION_INVENTORY_PAGES = 4
+export const ACTIVE_SESSION_INVENTORY_EXPIRY_RECHECK_MS = 30_000
+
+export type AiVaultSessionInventoryCursorStoreOptions = {
+ pageSize?: number
+ maxActiveInventories?: number
+ idleTtlMs?: number
+}
diff --git a/src/main/ai-vault/session-inventory-cursor-store.ts b/src/main/ai-vault/session-inventory-cursor-store.ts
new file mode 100644
index 00000000000..6620afe5edf
--- /dev/null
+++ b/src/main/ai-vault/session-inventory-cursor-store.ts
@@ -0,0 +1,297 @@
+import { randomUUID } from 'node:crypto'
+import {
+ createSessionInventoryAbortController,
+ waitForSessionInventoryAbort
+} from './session-inventory-abort'
+import {
+ ACTIVE_SESSION_INVENTORY_EXPIRY_RECHECK_MS,
+ DEFAULT_MAX_ACTIVE_SESSION_INVENTORIES,
+ DEFAULT_SESSION_INVENTORY_IDLE_TTL_MS,
+ DEFAULT_SESSION_INVENTORY_PAGE_SIZE,
+ MAX_REPLAYABLE_SESSION_INVENTORY_PAGES,
+ type AiVaultSessionInventoryCursorStoreOptions
+} from './session-inventory-cursor-policy'
+import type {
+ AiVaultSessionInventoryPage,
+ AiVaultSessionInventorySlice,
+ AiVaultSessionInventorySnapshot
+} from './session-inventory-page-types'
+import { SessionInventoryOpeningRegistry } from './session-inventory-opening-registry'
+
+type InventoryChain = {
+ id: string
+ bindingKey: string
+ snapshot: TSnapshot
+ lastAccessedAt: number
+ activeReads: number
+ releaseRequested: boolean
+ abortController: AbortController
+ releaseSnapshot?: (snapshot: TSnapshot) => void
+ cursors: string[]
+}
+
+type CursorPage = {
+ chainId: string
+ offset: number
+ page: Promise | null
+}
+
+/** Owns opaque, replayable cursors while adapters own host-specific discovery and parsing. */
+export class AiVaultSessionInventoryCursorStore {
+ private readonly chains = new Map>()
+ private readonly pages = new Map()
+ private readonly openings = new SessionInventoryOpeningRegistry()
+ private readonly pageSize: number
+ private readonly maxActiveInventories: number
+ private readonly idleTtlMs: number
+ private openingInventories = 0
+ private expiryTimer: NodeJS.Timeout | null = null
+
+ constructor(options: AiVaultSessionInventoryCursorStoreOptions = {}) {
+ this.pageSize = options.pageSize ?? DEFAULT_SESSION_INVENTORY_PAGE_SIZE
+ this.maxActiveInventories =
+ options.maxActiveInventories ?? DEFAULT_MAX_ACTIVE_SESSION_INVENTORIES
+ this.idleTtlMs = options.idleTtlMs ?? DEFAULT_SESSION_INVENTORY_IDLE_TTL_MS
+ }
+
+ async readPage(options: {
+ bindingKey: string
+ cursor: string | null
+ signal?: AbortSignal
+ openSnapshot: (signal: AbortSignal) => Promise
+ readSnapshotPage: (
+ snapshot: TSnapshot,
+ offset: number,
+ pageSize: number,
+ signal: AbortSignal
+ ) => Promise
+ validateSnapshot?: (snapshot: TSnapshot) => void
+ releaseSnapshot?: (snapshot: TSnapshot) => void
+ }): Promise {
+ this.expireIdleChains()
+ if (options.cursor === null) {
+ return await this.readFirstPage(options)
+ }
+ const cursorPage = this.pages.get(options.cursor)
+ const chain = cursorPage ? this.chains.get(cursorPage.chainId) : null
+ if (!cursorPage || !chain || chain.bindingKey !== options.bindingKey) {
+ throw new Error('Unknown or mismatched AI Vault session inventory cursor')
+ }
+ chain.lastAccessedAt = Date.now()
+ chain.activeReads++
+ const read = createSessionInventoryAbortController([
+ chain.abortController.signal,
+ options.signal
+ ])
+ try {
+ read.controller.signal.throwIfAborted()
+ options.validateSnapshot?.(chain.snapshot)
+ cursorPage.page ??= this.projectPage(
+ chain,
+ cursorPage.offset,
+ options.readSnapshotPage,
+ read.controller.signal
+ )
+ return await waitForSessionInventoryAbort(cursorPage.page, read.controller.signal)
+ } catch (error) {
+ this.deleteChain(chain.id)
+ throw error
+ } finally {
+ read.dispose()
+ chain.activeReads--
+ chain.lastAccessedAt = Date.now()
+ if (chain.activeReads === 0 && chain.releaseRequested) {
+ this.deleteChain(chain.id)
+ } else {
+ this.scheduleExpiry()
+ }
+ }
+ }
+
+ release(bindingKey: string, cursor: string | null): void {
+ if (cursor === null) {
+ this.openings.abort(bindingKey)
+ return
+ }
+ const page = this.pages.get(cursor)
+ const chain = page ? this.chains.get(page.chainId) : null
+ if (chain?.bindingKey === bindingKey) {
+ if (chain.activeReads > 0) {
+ chain.releaseRequested = true
+ } else {
+ this.deleteChain(chain.id)
+ }
+ }
+ }
+
+ private async readFirstPage(options: {
+ bindingKey: string
+ signal?: AbortSignal
+ openSnapshot: (signal: AbortSignal) => Promise
+ readSnapshotPage: (
+ snapshot: TSnapshot,
+ offset: number,
+ pageSize: number,
+ signal: AbortSignal
+ ) => Promise
+ validateSnapshot?: (snapshot: TSnapshot) => void
+ releaseSnapshot?: (snapshot: TSnapshot) => void
+ }): Promise {
+ if (this.chains.size + this.openingInventories >= this.maxActiveInventories) {
+ throw new Error('AI Vault session inventory capacity exceeded')
+ }
+ this.openingInventories++
+ const opening = createSessionInventoryAbortController([options.signal])
+ this.openings.remember(options.bindingKey, opening.controller)
+ let unownedSnapshot: TSnapshot | null = null
+ try {
+ // openSnapshot receives the same signal and owns prompt cancellation;
+ // awaiting it directly lets this layer take cleanup ownership before checking abort.
+ const snapshot = await options.openSnapshot(opening.controller.signal)
+ unownedSnapshot = snapshot
+ opening.controller.signal.throwIfAborted()
+ options.validateSnapshot?.(snapshot)
+ const chain: InventoryChain = {
+ id: randomUUID(),
+ bindingKey: options.bindingKey,
+ snapshot,
+ lastAccessedAt: Date.now(),
+ activeReads: 1,
+ releaseRequested: false,
+ abortController: opening.controller,
+ releaseSnapshot: options.releaseSnapshot,
+ cursors: []
+ }
+ this.chains.set(chain.id, chain)
+ unownedSnapshot = null
+ this.scheduleExpiry()
+ try {
+ return await this.projectPage(chain, 0, options.readSnapshotPage, opening.controller.signal)
+ } catch (error) {
+ this.deleteChain(chain.id)
+ throw error
+ } finally {
+ chain.activeReads--
+ chain.lastAccessedAt = Date.now()
+ if (chain.activeReads === 0 && chain.releaseRequested) {
+ this.deleteChain(chain.id)
+ } else {
+ this.scheduleExpiry()
+ }
+ }
+ } finally {
+ if (unownedSnapshot) {
+ // Why: cancellation can win after cache acquisition but before a cursor chain owns the lease.
+ options.releaseSnapshot?.(unownedSnapshot)
+ }
+ this.openings.forget(options.bindingKey, opening.controller)
+ opening.dispose()
+ this.openingInventories--
+ }
+ }
+
+ private async projectPage(
+ chain: InventoryChain,
+ offset: number,
+ readSnapshotPage: (
+ snapshot: TSnapshot,
+ offset: number,
+ pageSize: number,
+ signal: AbortSignal
+ ) => Promise,
+ signal: AbortSignal
+ ): Promise {
+ signal.throwIfAborted()
+ const slice = await waitForSessionInventoryAbort(
+ readSnapshotPage(chain.snapshot, offset, this.pageSize, signal),
+ signal
+ )
+ signal.throwIfAborted()
+ if (chain.releaseRequested) {
+ throw new Error('AI Vault session inventory was released during a page read')
+ }
+ if (
+ slice.sessions.length > this.pageSize ||
+ slice.nextOffset < offset ||
+ (!slice.complete && slice.nextOffset <= offset)
+ ) {
+ throw new Error('Invalid AI Vault session inventory page')
+ }
+ const nextCursor = slice.complete ? null : this.createCursor(chain, slice.nextOffset)
+ chain.lastAccessedAt = Date.now()
+ const page = { sessions: slice.sessions, nextCursor, scannedAt: chain.snapshot.scannedAt }
+ if (slice.complete) {
+ // Why: a completed snapshot may hold thousands of paths; release it immediately.
+ // A lost final response restarts the read-only inventory instead of retaining that memory.
+ this.deleteChain(chain.id, false)
+ }
+ return page
+ }
+
+ private createCursor(chain: InventoryChain, offset: number): string {
+ const cursor = randomUUID()
+ this.pages.set(cursor, { chainId: chain.id, offset, page: null })
+ chain.cursors.push(cursor)
+ while (chain.cursors.length > MAX_REPLAYABLE_SESSION_INVENTORY_PAGES) {
+ const expired = chain.cursors.shift()
+ if (expired) {
+ this.pages.delete(expired)
+ }
+ }
+ return cursor
+ }
+
+ private expireIdleChains(): void {
+ const cutoff = Date.now() - this.idleTtlMs
+ for (const chain of this.chains.values()) {
+ if (chain.activeReads === 0 && chain.lastAccessedAt < cutoff) {
+ this.deleteChain(chain.id)
+ }
+ }
+ this.scheduleExpiry()
+ }
+
+ private deleteChain(chainId: string, abort = true): void {
+ const chain = this.chains.get(chainId)
+ if (!chain) {
+ return
+ }
+ this.chains.delete(chainId)
+ if (abort) {
+ chain.abortController.abort()
+ }
+ chain.releaseSnapshot?.(chain.snapshot)
+ for (const cursor of chain.cursors) {
+ this.pages.delete(cursor)
+ }
+ this.scheduleExpiry()
+ }
+
+ private scheduleExpiry(): void {
+ if (this.expiryTimer) {
+ clearTimeout(this.expiryTimer)
+ this.expiryTimer = null
+ }
+ if (this.chains.size === 0) {
+ return
+ }
+ const now = Date.now()
+ let nextExpiryAt = Number.POSITIVE_INFINITY
+ for (const chain of this.chains.values()) {
+ nextExpiryAt = Math.min(
+ nextExpiryAt,
+ chain.activeReads > 0
+ ? now + ACTIVE_SESSION_INVENTORY_EXPIRY_RECHECK_MS
+ : chain.lastAccessedAt + this.idleTtlMs
+ )
+ }
+ this.expiryTimer = setTimeout(
+ () => {
+ this.expiryTimer = null
+ this.expireIdleChains()
+ },
+ Math.max(1, nextExpiryAt - now)
+ )
+ this.expiryTimer.unref()
+ }
+}
diff --git a/src/main/ai-vault/session-inventory-opening-registry.ts b/src/main/ai-vault/session-inventory-opening-registry.ts
new file mode 100644
index 00000000000..3e4502728d3
--- /dev/null
+++ b/src/main/ai-vault/session-inventory-opening-registry.ts
@@ -0,0 +1,23 @@
+export class SessionInventoryOpeningRegistry {
+ private readonly controllersByBinding = new Map>()
+
+ remember(bindingKey: string, controller: AbortController): void {
+ const controllers = this.controllersByBinding.get(bindingKey) ?? new Set()
+ controllers.add(controller)
+ this.controllersByBinding.set(bindingKey, controllers)
+ }
+
+ forget(bindingKey: string, controller: AbortController): void {
+ const controllers = this.controllersByBinding.get(bindingKey)
+ controllers?.delete(controller)
+ if (controllers?.size === 0) {
+ this.controllersByBinding.delete(bindingKey)
+ }
+ }
+
+ abort(bindingKey: string): void {
+ for (const controller of this.controllersByBinding.get(bindingKey) ?? []) {
+ controller.abort()
+ }
+ }
+}
diff --git a/src/main/ai-vault/session-inventory-page-types.ts b/src/main/ai-vault/session-inventory-page-types.ts
new file mode 100644
index 00000000000..f0e8ab56595
--- /dev/null
+++ b/src/main/ai-vault/session-inventory-page-types.ts
@@ -0,0 +1,17 @@
+import type { AiVaultSession } from '../../shared/ai-vault-types'
+
+export type AiVaultSessionInventorySnapshot = {
+ scannedAt: string
+}
+
+export type AiVaultSessionInventorySlice = {
+ sessions: readonly AiVaultSession[]
+ nextOffset: number
+ complete: boolean
+}
+
+export type AiVaultSessionInventoryPage = {
+ sessions: readonly AiVaultSession[]
+ nextCursor: string | null
+ scannedAt: string
+}
diff --git a/src/main/ai-vault/session-inventory-snapshot-cache.ts b/src/main/ai-vault/session-inventory-snapshot-cache.ts
new file mode 100644
index 00000000000..d4332145676
--- /dev/null
+++ b/src/main/ai-vault/session-inventory-snapshot-cache.ts
@@ -0,0 +1,325 @@
+import { waitForSessionInventoryAbort } from './session-inventory-abort'
+
+const DEFAULT_MAX_SNAPSHOTS = 256
+const DEFAULT_IDLE_TTL_MS = 15 * 60_000
+
+type SnapshotEntry = {
+ key: string
+ snapshot: TSnapshot
+ lastAccessedAt: number
+ retainedBytes: number
+ activeLeases: number
+ cacheable: boolean
+}
+
+type SnapshotOpening = {
+ promise: Promise
+ controller: AbortController
+ generation: number
+ entry: SnapshotEntry | null
+ waiters: number
+ settled: boolean
+}
+
+export type SessionInventorySnapshotCacheOptions = {
+ maxSnapshots?: number
+ idleTtlMs?: number
+ maxRetainedBytes?: number
+ openingReservationBytes?: number
+ measureSnapshotBytes?: (snapshot: TSnapshot) => number
+}
+
+/** Shares one frozen host discovery while retaining active cursor leases within a byte budget. */
+export class SessionInventorySnapshotCache {
+ private readonly snapshots = new Map>()
+ private readonly entries = new Set>()
+ private readonly entryBySnapshot = new WeakMap>()
+ private readonly openings = new Map>()
+ private readonly pendingOpeningCounts = new Map()
+ private readonly generations = new Map()
+ private readonly maxSnapshots: number
+ private readonly idleTtlMs: number
+ private readonly maxRetainedBytes: number
+ private readonly openingReservationBytes: number
+ private readonly measureSnapshotBytes: (snapshot: TSnapshot) => number
+ private retainedBytes = 0
+ private pendingOpenings = 0
+ private expiryTimer: NodeJS.Timeout | null = null
+
+ constructor(options: SessionInventorySnapshotCacheOptions = {}) {
+ this.maxSnapshots = options.maxSnapshots ?? DEFAULT_MAX_SNAPSHOTS
+ this.idleTtlMs = options.idleTtlMs ?? DEFAULT_IDLE_TTL_MS
+ this.maxRetainedBytes = options.maxRetainedBytes ?? Number.POSITIVE_INFINITY
+ this.openingReservationBytes = options.openingReservationBytes ?? 0
+ this.measureSnapshotBytes = options.measureSnapshotBytes ?? (() => 0)
+ }
+
+ async resolve(
+ key: string,
+ open: (signal: AbortSignal) => Promise,
+ signal?: AbortSignal
+ ): Promise {
+ signal?.throwIfAborted()
+ this.expireIdle()
+ const cached = this.snapshots.get(key)
+ if (cached) {
+ return this.acquireEntry(cached)
+ }
+ let opening = this.openings.get(key)
+ if (!opening) {
+ opening = this.createOpening(key, open)
+ }
+ opening.waiters++
+ try {
+ const snapshot = await waitForSessionInventoryAbort(opening.promise, signal)
+ signal?.throwIfAborted()
+ return this.acquireSnapshot(snapshot)
+ } finally {
+ this.releaseOpeningWaiter(key, opening, signal?.aborted ?? false)
+ }
+ }
+
+ release(snapshot: TSnapshot): void {
+ const entry = this.entryBySnapshot.get(snapshot)
+ if (!entry || entry.activeLeases === 0) {
+ return
+ }
+ entry.activeLeases--
+ entry.lastAccessedAt = Date.now()
+ if (entry.activeLeases === 0 && !entry.cacheable) {
+ this.deleteEntry(entry)
+ }
+ this.scheduleExpiry()
+ }
+
+ invalidate(key: string): void {
+ this.generations.set(key, this.generation(key) + 1)
+ const opening = this.openings.get(key)
+ if (opening) {
+ this.openings.delete(key)
+ opening.controller.abort()
+ }
+ const entry = this.snapshots.get(key)
+ if (entry) {
+ entry.cacheable = false
+ this.snapshots.delete(key)
+ if (entry.activeLeases === 0) {
+ this.deleteEntry(entry)
+ }
+ }
+ this.deleteIdleGeneration(key)
+ this.scheduleExpiry()
+ }
+
+ private async openAndRetain(
+ key: string,
+ generation: number,
+ open: (signal: AbortSignal) => Promise,
+ signal: AbortSignal
+ ): Promise {
+ const snapshot = await open(signal)
+ signal.throwIfAborted()
+ if (generation !== this.generation(key)) {
+ throw new Error('AI Vault session inventory snapshot was invalidated during creation')
+ }
+ const retainedBytes = this.measureSnapshotBytes(snapshot)
+ if (!Number.isSafeInteger(retainedBytes) || retainedBytes < 0) {
+ throw new Error('AI Vault session inventory snapshot size is invalid')
+ }
+ this.requireRetainedCapacity(
+ retainedBytes + Math.max(0, this.pendingOpenings - 1) * this.openingReservationBytes
+ )
+ const entry: SnapshotEntry = {
+ key,
+ snapshot,
+ lastAccessedAt: Date.now(),
+ retainedBytes,
+ activeLeases: 0,
+ cacheable: true
+ }
+ this.snapshots.set(key, entry)
+ this.entries.add(entry)
+ this.entryBySnapshot.set(snapshot, entry)
+ this.retainedBytes += retainedBytes
+ this.scheduleExpiry()
+ return snapshot
+ }
+
+ private acquireSnapshot(snapshot: TSnapshot): TSnapshot {
+ const entry = this.entryBySnapshot.get(snapshot)
+ if (!entry?.cacheable || this.snapshots.get(entry.key) !== entry) {
+ throw new Error('AI Vault session inventory snapshot is no longer available')
+ }
+ return this.acquireEntry(entry)
+ }
+
+ private acquireEntry(entry: SnapshotEntry): TSnapshot {
+ entry.activeLeases++
+ entry.lastAccessedAt = Date.now()
+ this.scheduleExpiry()
+ return entry.snapshot
+ }
+
+ private requireSnapshotSlot(): void {
+ while (this.entries.size + this.pendingOpenings >= this.maxSnapshots) {
+ if (!this.evictOldestIdleEntry()) {
+ throw new Error('AI Vault session inventory snapshot capacity exceeded')
+ }
+ }
+ }
+
+ private requireRetainedCapacity(additionalBytes: number): void {
+ while (this.retainedBytes + additionalBytes > this.maxRetainedBytes) {
+ if (!this.evictOldestIdleEntry()) {
+ throw new Error('AI Vault session inventory snapshot memory capacity exceeded')
+ }
+ }
+ }
+
+ private evictOldestIdleEntry(): boolean {
+ let oldest: SnapshotEntry | null = null
+ for (const entry of this.entries) {
+ if (entry.activeLeases === 0 && (!oldest || entry.lastAccessedAt < oldest.lastAccessedAt)) {
+ oldest = entry
+ }
+ }
+ if (!oldest) {
+ return false
+ }
+ this.deleteEntry(oldest)
+ return true
+ }
+
+ private expireIdle(): void {
+ const cutoff = Date.now() - this.idleTtlMs
+ for (const entry of this.entries) {
+ if (entry.activeLeases === 0 && entry.lastAccessedAt < cutoff) {
+ this.deleteEntry(entry)
+ }
+ }
+ this.scheduleExpiry()
+ }
+
+ private deleteEntry(entry: SnapshotEntry): void {
+ if (!this.entries.delete(entry)) {
+ return
+ }
+ if (this.snapshots.get(entry.key) === entry) {
+ this.snapshots.delete(entry.key)
+ }
+ this.entryBySnapshot.delete(entry.snapshot)
+ this.retainedBytes -= entry.retainedBytes
+ }
+
+ private generation(key: string): number {
+ return this.generations.get(key) ?? 0
+ }
+
+ private deleteIdleGeneration(key: string): void {
+ // Why: random inventory scopes must not leave permanent generation tombstones.
+ if (!this.pendingOpeningCounts.has(key) && !this.snapshots.has(key)) {
+ this.generations.delete(key)
+ }
+ }
+
+ private createOpening(
+ key: string,
+ open: (signal: AbortSignal) => Promise
+ ): SnapshotOpening {
+ this.requireSnapshotSlot()
+ this.requireRetainedCapacity((this.pendingOpenings + 1) * this.openingReservationBytes)
+ const controller = new AbortController()
+ const opening: SnapshotOpening = {
+ promise: Promise.resolve(null as unknown as TSnapshot),
+ controller,
+ generation: this.generation(key),
+ entry: null,
+ waiters: 0,
+ settled: false
+ }
+ this.rememberPendingOpening(key)
+ opening.promise = (async () => {
+ try {
+ const snapshot = await this.openAndRetain(key, opening.generation, open, controller.signal)
+ opening.entry = this.entryBySnapshot.get(snapshot) ?? null
+ return snapshot
+ } finally {
+ opening.settled = true
+ this.forgetPendingOpening(key)
+ if (opening.waiters === 0 && this.openings.get(key) === opening) {
+ this.openings.delete(key)
+ }
+ this.deleteIdleGeneration(key)
+ }
+ })()
+ this.openings.set(key, opening)
+ return opening
+ }
+
+ private releaseOpeningWaiter(
+ key: string,
+ opening: SnapshotOpening,
+ abandoned: boolean
+ ): void {
+ opening.waiters--
+ if (opening.waiters > 0) {
+ return
+ }
+ const isCurrentOpening = this.openings.get(key) === opening
+ if (isCurrentOpening) {
+ this.openings.delete(key)
+ }
+ if (!opening.settled) {
+ // Why: an abandoned opening may finish late, but its result must never enter the cache.
+ if (isCurrentOpening) {
+ this.generations.set(key, this.generation(key) + 1)
+ opening.controller.abort()
+ }
+ } else if (abandoned) {
+ // Why: an invalidated opening can settle beside its replacement; only
+ // discard the entry produced by the abandoned opening itself.
+ const entry = opening.entry
+ if (entry?.activeLeases === 0) {
+ this.deleteEntry(entry)
+ }
+ }
+ this.deleteIdleGeneration(key)
+ }
+
+ private rememberPendingOpening(key: string): void {
+ this.pendingOpenings++
+ this.pendingOpeningCounts.set(key, (this.pendingOpeningCounts.get(key) ?? 0) + 1)
+ }
+
+ private forgetPendingOpening(key: string): void {
+ this.pendingOpenings--
+ const remaining = (this.pendingOpeningCounts.get(key) ?? 1) - 1
+ if (remaining === 0) {
+ this.pendingOpeningCounts.delete(key)
+ } else {
+ this.pendingOpeningCounts.set(key, remaining)
+ }
+ }
+
+ private scheduleExpiry(): void {
+ if (this.expiryTimer) {
+ clearTimeout(this.expiryTimer)
+ this.expiryTimer = null
+ }
+ const idleEntries = [...this.entries].filter((entry) => entry.activeLeases === 0)
+ if (idleEntries.length === 0) {
+ return
+ }
+ const nextExpiryAt = Math.min(
+ ...idleEntries.map((entry) => entry.lastAccessedAt + this.idleTtlMs)
+ )
+ this.expiryTimer = setTimeout(
+ () => {
+ this.expiryTimer = null
+ this.expireIdle()
+ },
+ Math.max(1, nextExpiryAt - Date.now())
+ )
+ this.expiryTimer.unref()
+ }
+}
diff --git a/src/main/ai-vault/session-root-configuration.ts b/src/main/ai-vault/session-root-configuration.ts
new file mode 100644
index 00000000000..37b3ed8e637
--- /dev/null
+++ b/src/main/ai-vault/session-root-configuration.ts
@@ -0,0 +1,44 @@
+import { join } from 'node:path'
+
+export type AiVaultSessionRuntimeTarget =
+ | { runtime: 'host' }
+ | { runtime: 'wsl'; wslDistro: string }
+
+export type AiVaultSessionSources = {
+ getAdditionalCodexHomePaths?: () => readonly string[]
+ resolveClaudeProjectsDirs?: (target: AiVaultSessionRuntimeTarget) => Promise
+}
+
+let sources: AiVaultSessionSources = {}
+
+export function configureAiVaultSessionSources(next: AiVaultSessionSources): void {
+ sources = next
+}
+
+export function getConfiguredAiVaultAdditionalCodexSessionsDirs(): string[] {
+ return sources.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? []
+}
+
+export async function getConfiguredAiVaultClaudeProjectsDirs(
+ target: AiVaultSessionRuntimeTarget
+): Promise {
+ const resolved = await sources.resolveClaudeProjectsDirs?.(target)
+ return resolved ? uniquePaths(resolved) : null
+}
+
+export function resetAiVaultSessionRootConfigurationForTests(): void {
+ sources = {}
+}
+
+function uniquePaths(paths: readonly string[]): string[] {
+ const seen = new Set()
+ const unique: string[] = []
+ for (const path of paths) {
+ const trimmed = path.trim()
+ if (trimmed && !seen.has(trimmed)) {
+ seen.add(trimmed)
+ unique.push(trimmed)
+ }
+ }
+ return unique
+}
diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts
index 5127720b141..f446a8745ee 100644
--- a/src/main/ai-vault/session-scanner-codex-parser.ts
+++ b/src/main/ai-vault/session-scanner-codex-parser.ts
@@ -72,7 +72,7 @@ export async function parseCodexSessionContent(args: {
})
}
-type CodexSessionParseState = {
+export type CodexSessionParseState = {
accumulator: SessionAccumulator
previousTotals: CodexUsageSnapshot | null
rejectedWorkerSession: boolean
@@ -82,7 +82,7 @@ type CodexSessionParseState = {
titleSource: 'meta' | 'user' | null
}
-function createCodexParseState(file: FileWithMtime): CodexSessionParseState {
+export function createCodexSessionParseState(file: FileWithMtime): CodexSessionParseState {
return {
accumulator: createAccumulator({
agent: 'codex',
@@ -104,7 +104,7 @@ function cloneCodexParseState(state: CodexSessionParseState): CodexSessionParseS
}
}
-function consumeCodexRecordLine(state: CodexSessionParseState, line: string): void {
+export function consumeCodexSessionLine(state: CodexSessionParseState, line: string): void {
if (state.rejectedWorkerSession) {
return
}
@@ -222,7 +222,7 @@ function consumeCodexRecordLine(state: CodexSessionParseState, line: string): vo
}
}
-async function finalizeCodexParseState(
+export async function finalizeCodexSessionParseState(
state: CodexSessionParseState,
platform: NodeJS.Platform,
args: {
@@ -257,8 +257,10 @@ export function createCodexSessionResumeState(
file: FileWithMtime,
codexHome: string | null
): ResumableSessionParseState {
- return codexResumeStateFromParseState(createCodexParseState(file), codexHome, (sessionId) =>
- readCodexSessionIndexTitle(file.path, codexHome, sessionId)
+ return codexResumeStateFromParseState(
+ createCodexSessionParseState(file),
+ codexHome,
+ (sessionId) => readCodexSessionIndexTitle(file.path, codexHome, sessionId)
)
}
@@ -268,14 +270,14 @@ function codexResumeStateFromParseState(
titleReader: (sessionId: string) => Promise
): ResumableSessionParseState {
return {
- consumeLine: (line) => consumeCodexRecordLine(state, line),
+ consumeLine: (line) => consumeCodexSessionLine(state, line),
clone: () =>
codexResumeStateFromParseState(cloneCodexParseState(state), codexHome, titleReader),
touchFile: (file) => {
state.accumulator.modifiedAt = file.modifiedAt
},
finalize: (platform, options?: ResumableParseFinalizeOptions) =>
- finalizeCodexParseState(state, platform, { codexHome, titleReader, ...options })
+ finalizeCodexSessionParseState(state, platform, { codexHome, titleReader, ...options })
}
}
@@ -288,15 +290,15 @@ async function parseCodexSessionLines(args: {
executionHostPlatform?: NodeJS.Platform | null
titleReader?: (sessionId: string) => Promise
}): Promise {
- const state = createCodexParseState(args.file)
+ const state = createCodexSessionParseState(args.file)
for await (const line of args.lines) {
- consumeCodexRecordLine(state, line)
+ consumeCodexSessionLine(state, line)
if (state.rejectedWorkerSession) {
// Worker transcripts are excluded outright; stop reading early.
return null
}
}
- return finalizeCodexParseState(state, args.platform, {
+ return finalizeCodexSessionParseState(state, args.platform, {
codexHome: args.codexHome,
titleReader: args.titleReader,
executionHostId: args.executionHostId,
diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts
index c176fb3c6e5..80dbf995159 100644
--- a/src/main/ai-vault/session-scanner-discovery.ts
+++ b/src/main/ai-vault/session-scanner-discovery.ts
@@ -1,27 +1,47 @@
-import { readdir, stat } from 'node:fs/promises'
+import { opendir, stat } from 'node:fs/promises'
import { basename, delimiter, extname, join } from 'node:path'
import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types'
import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
+export type SessionFileDiscoveryBudget = {
+ entries: number
+ candidates: number
+ pathBytes: number
+}
+
export async function discoverFiles(args: {
rootDir: string
- limit: number
+ limit: number | null
agent: AiVaultAgent
issues: AiVaultScanIssue[]
extensions: string[]
filePredicate?: (path: string) => boolean
directoryPredicate?: (name: string) => boolean
+ readErrorPolicy?: 'collect' | 'throw-except-missing'
+ maxEntries?: number
+ maxCandidates?: number
+ maxPathBytes?: number
+ budget?: SessionFileDiscoveryBudget
+ signal?: AbortSignal
}): Promise {
const paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, {
extensions: new Set(args.extensions),
filePredicate: args.filePredicate,
- directoryPredicate: args.directoryPredicate
+ directoryPredicate: args.directoryPredicate,
+ readErrorPolicy: args.readErrorPolicy,
+ maxEntries: args.maxEntries,
+ maxCandidates: args.maxCandidates,
+ maxPathBytes: args.maxPathBytes,
+ budget: args.budget,
+ signal: args.signal
})
const files: FileWithMtime[] = []
for (const path of paths) {
+ args.signal?.throwIfAborted()
try {
const fileStat = await stat(path)
+ args.signal?.throwIfAborted()
files.push({
path,
mtimeMs: fileStat.mtimeMs,
@@ -29,13 +49,17 @@ export async function discoverFiles(args: {
sizeBytes: fileStat.size
})
} catch (err) {
+ if (args.readErrorPolicy === 'throw-except-missing' && !isMissingPathError(err)) {
+ throw err
+ }
args.issues.push({ agent: args.agent, path, message: errorMessage(err) })
}
}
+ const sortedFiles = files.sort((left, right) => right.mtimeMs - left.mtimeMs)
return {
agent: args.agent,
rootDir: args.rootDir,
- files: files.sort((left, right) => right.mtimeMs - left.mtimeMs).slice(0, args.limit)
+ files: args.limit === null ? sortedFiles : sortedFiles.slice(0, args.limit)
}
}
@@ -73,33 +97,103 @@ export async function walkSessionFiles(
// Return false to skip descending into a directory (matched by its name),
// so pruned subtrees are never stat'd or parsed.
directoryPredicate?: (name: string) => boolean
+ readErrorPolicy?: 'collect' | 'throw-except-missing'
+ maxEntries?: number
+ maxCandidates?: number
+ maxPathBytes?: number
+ budget?: SessionFileDiscoveryBudget
+ signal?: AbortSignal
}
): Promise {
- let entries
+ options.budget ??= { entries: 0, candidates: 0, pathBytes: 0 }
+ options.signal?.throwIfAborted()
+ let directory
try {
- entries = await readdir(dirPath, { withFileTypes: true })
- } catch {
+ directory = await opendir(dirPath)
+ } catch (error) {
+ // Why: a paged inventory must fail instead of presenting an unreadable
+ // subtree as an authoritative end-of-list; missing roots remain optional.
+ if (options.readErrorPolicy === 'throw-except-missing' && !isMissingPathError(error)) {
+ throw error
+ }
return []
}
const files: string[] = []
- for (const entry of entries) {
- const fullPath = join(dirPath, entry.name)
- if (entry.isDirectory()) {
- // Skip whole subtrees an agent never wants (e.g. subagent transcripts),
- // avoiding the readdir cost of descending into them.
- if (options.directoryPredicate?.(entry.name) ?? true) {
- files.push(...(await walkSessionFiles(fullPath, agent, issues, options)))
+ try {
+ for await (const entry of directory) {
+ options.signal?.throwIfAborted()
+ const fullPath = join(dirPath, entry.name)
+ consumeDiscoveryEntry(fullPath, options)
+ if (entry.isDirectory()) {
+ // Skip whole subtrees an agent never wants (e.g. subagent transcripts),
+ // avoiding the readdir cost of descending into them.
+ if (options.directoryPredicate?.(entry.name) ?? true) {
+ files.push(...(await walkSessionFiles(fullPath, agent, issues, options)))
+ }
+ continue
}
- continue
+ if (
+ entry.isFile() &&
+ options.extensions.has(extname(entry.name).toLowerCase()) &&
+ (options.filePredicate?.(fullPath) ?? true)
+ ) {
+ consumeDiscoveryCandidate(options)
+ files.push(fullPath)
+ }
+ }
+ } catch (error) {
+ if (options.signal?.aborted) {
+ throw error
}
- if (
- entry.isFile() &&
- options.extensions.has(extname(entry.name).toLowerCase()) &&
- (options.filePredicate?.(fullPath) ?? true)
- ) {
- files.push(fullPath)
+ if (options.readErrorPolicy === 'throw-except-missing' && !isMissingPathError(error)) {
+ throw error
}
+ return []
}
return files
}
+
+export class SessionFileDiscoveryLimitError extends Error {
+ constructor() {
+ super('AI Vault session file discovery capacity exceeded')
+ this.name = 'SessionFileDiscoveryLimitError'
+ }
+}
+
+function consumeDiscoveryEntry(
+ path: string,
+ options: {
+ maxEntries?: number
+ maxCandidates?: number
+ maxPathBytes?: number
+ budget?: SessionFileDiscoveryBudget
+ }
+): void {
+ const budget = options.budget ?? { entries: 0, candidates: 0, pathBytes: 0 }
+ options.budget = budget
+ budget.entries++
+ budget.pathBytes += Buffer.byteLength(path, 'utf8')
+ if (
+ (options.maxEntries !== undefined && budget.entries > options.maxEntries) ||
+ (options.maxPathBytes !== undefined && budget.pathBytes > options.maxPathBytes)
+ ) {
+ throw new SessionFileDiscoveryLimitError()
+ }
+}
+
+function consumeDiscoveryCandidate(options: {
+ maxCandidates?: number
+ budget?: SessionFileDiscoveryBudget
+}): void {
+ const budget = options.budget ?? { entries: 0, candidates: 0, pathBytes: 0 }
+ options.budget = budget
+ budget.candidates++
+ if (options.maxCandidates !== undefined && budget.candidates > options.maxCandidates) {
+ throw new SessionFileDiscoveryLimitError()
+ }
+}
+
+function isMissingPathError(error: unknown): boolean {
+ return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
+}
diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts
index 70d0d311d6b..9cdb354cdd4 100644
--- a/src/main/ai-vault/session-scanner-parse-cache.ts
+++ b/src/main/ai-vault/session-scanner-parse-cache.ts
@@ -117,8 +117,10 @@ function storeEntry(path: string, entry: SessionParseCacheEntry): void {
export async function parseAgentSessionFileCached(
candidate: SessionFileCandidate,
platform: NodeJS.Platform,
- stats?: SessionParseStats
+ stats?: SessionParseStats,
+ signal?: AbortSignal
): Promise {
+ signal?.throwIfAborted()
const { file } = candidate
const entry = cache.get(file.path)
@@ -137,6 +139,7 @@ export async function parseAgentSessionFileCached(
// so refresh the cheap directory count on reuse.
if (entry.session && candidate.agent === 'claude' && entry.session.messageCount === 0) {
const subagentTranscriptCount = await countSubagentTranscripts(file.path)
+ signal?.throwIfAborted()
if (subagentTranscriptCount !== entry.session.subagentTranscriptCount) {
entry.session = { ...entry.session, subagentTranscriptCount }
}
@@ -152,7 +155,8 @@ export async function parseAgentSessionFileCached(
platform,
entry,
stats,
- stateFactory
+ stateFactory,
+ signal
})
storeEntry(file.path, parsed)
return parsed.session
@@ -163,6 +167,7 @@ export async function parseAgentSessionFileCached(
stats.bytesRead += file.sizeBytes ?? 0
}
const session = await parseAgentSessionFile(candidate, platform)
+ signal?.throwIfAborted()
storeEntry(file.path, {
mtimeMs: file.mtimeMs,
sizeBytes: file.sizeBytes ?? null,
@@ -179,6 +184,7 @@ async function parseResumableCandidate(args: {
entry: SessionParseCacheEntry | undefined
stats?: SessionParseStats
stateFactory: () => ResumableSessionParseState
+ signal?: AbortSignal
}): Promise {
const { file } = args.candidate
const resume = args.entry?.platform === args.platform ? args.entry.resume : null
@@ -188,6 +194,7 @@ async function parseResumableCandidate(args: {
typeof file.sizeBytes === 'number' &&
file.sizeBytes >= resume.byteOffset &&
(resume.byteOffset === 0 || (await endsWithNewlineAt(file.path, resume.byteOffset)))
+ args.signal?.throwIfAborted()
// Clone before consuming: a failed read must not corrupt the cached state,
// or the next resume would double-count the lines applied before the error.
@@ -204,7 +211,8 @@ async function parseResumableCandidate(args: {
const readResult = await consumeCompleteJsonlLines({
path: file.path,
start: startOffset,
- onLine: (line) => state.consumeLine(line)
+ onLine: (line) => state.consumeLine(line),
+ signal: args.signal
})
if (args.stats) {
args.stats.bytesRead += readResult.bytesRead
@@ -222,11 +230,13 @@ async function parseResumableCandidate(args: {
displayState.consumeLine(readResult.trailingPartialLine)
}
+ const session = await displayState.finalize(args.platform)
+ args.signal?.throwIfAborted()
return {
mtimeMs: file.mtimeMs,
sizeBytes: file.sizeBytes ?? null,
platform: args.platform,
- session: await displayState.finalize(args.platform),
+ session,
resume: { state, byteOffset: readResult.consumedThrough }
}
}
@@ -259,13 +269,15 @@ async function consumeCompleteJsonlLines(args: {
path: string
start: number
onLine: (line: string) => void
+ signal?: AbortSignal
}): Promise {
let consumedThrough = args.start
let bytesRead = 0
let remainder: Buffer | null = null
- const stream = createReadStream(args.path, { start: args.start })
+ const stream = createReadStream(args.path, { start: args.start, signal: args.signal })
for await (const chunk of stream as AsyncIterable) {
+ args.signal?.throwIfAborted()
bytesRead += chunk.length
const data = remainder ? Buffer.concat([remainder, chunk]) : chunk
let lineStart = 0
@@ -284,6 +296,8 @@ async function consumeCompleteJsonlLines(args: {
remainder = lineStart < data.length ? Buffer.from(data.subarray(lineStart)) : null
}
+ args.signal?.throwIfAborted()
+
return {
consumedThrough,
trailingPartialLine: remainder && remainder.length > 0 ? remainder.toString('utf-8') : null,
diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts
index f77404bfc5a..9c5a9875f69 100644
--- a/src/main/ai-vault/session-scanner-source-discovery.ts
+++ b/src/main/ai-vault/session-scanner-source-discovery.ts
@@ -56,17 +56,8 @@ export async function discoverAiVaultSessionSources(args: {
issues: AiVaultScanIssue[]
}): Promise {
const { options, limitPerAgent, issues } = args
- const wslHomeDirs = normalizedWslHomeDirs(options.wslHomeDirs)
- const codexSessionsDirs = uniqueCodexSessionsDirs([
- options.codexSessionsDir ?? CODEX_SESSIONS_DIR,
- ...wslHomeDirs.map((homeDir) => join(homeDir, '.codex', 'sessions')),
- // Why: Orca-launched WSL Codex sessions use an Orca-owned CODEX_HOME,
- // not the user's default ~/.codex history root.
- ...wslHomeDirs.map((homeDir) =>
- join(homeDir, '.local', 'share', 'orca', 'codex-runtime-home', 'home', 'sessions')
- ),
- ...(options.additionalCodexSessionsDirs ?? [])
- ])
+ const wslHomeDirs = normalizeAiVaultWslHomeDirs(options.wslHomeDirs)
+ const codexSessionsDirs = codexSessionRootDirs(options, wslHomeDirs)
return Promise.all([
// Why: OpenCode 1.17.x migrated sessions from per-session JSON files to a
@@ -117,6 +108,22 @@ function codexDiscoveries(
)
}
+export function codexSessionRootDirs(
+ options: AiVaultScanOptions,
+ wslHomeDirs: readonly string[]
+): string[] {
+ return uniqueCodexSessionsDirs([
+ options.codexSessionsDir ?? CODEX_SESSIONS_DIR,
+ ...wslHomeDirs.map((homeDir) => join(homeDir, '.codex', 'sessions')),
+ // Why: Orca-launched WSL Codex sessions use an Orca-owned CODEX_HOME,
+ // not the user's default ~/.codex history root.
+ ...wslHomeDirs.map((homeDir) =>
+ join(homeDir, '.local', 'share', 'orca', 'codex-runtime-home', 'home', 'sessions')
+ ),
+ ...(options.additionalCodexSessionsDirs ?? [])
+ ])
+}
+
function standardDiscoveries(
options: AiVaultScanOptions,
wslHomeDirs: readonly string[],
@@ -295,7 +302,7 @@ function openClawDiscovery(
})
}
-function normalizedWslHomeDirs(homeDirs: readonly string[] | undefined): string[] {
+export function normalizeAiVaultWslHomeDirs(homeDirs: readonly string[] | undefined): string[] {
const seen = new Set()
const unique: string[] = []
for (const homeDir of homeDirs ?? []) {
diff --git a/src/main/ai-vault/session-wsl-home-resolution.ts b/src/main/ai-vault/session-wsl-home-resolution.ts
new file mode 100644
index 00000000000..b9b2aeebbde
--- /dev/null
+++ b/src/main/ai-vault/session-wsl-home-resolution.ts
@@ -0,0 +1,18 @@
+import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
+
+export async function resolveAiVaultWslHomeDirsForDistro(distro: string): Promise {
+ if (process.platform !== 'win32') {
+ throw new Error('AI Vault WSL runtime is unavailable on this platform')
+ }
+ const matches = (await listWslDistrosAsync()).filter(
+ (candidate) => candidate.toLowerCase() === distro.trim().toLowerCase()
+ )
+ if (matches.length !== 1) {
+ throw new Error('AI Vault WSL distro is unavailable or ambiguous')
+ }
+ const home = await getWslHomeAsync(matches[0])
+ if (!home) {
+ throw new Error('AI Vault WSL home is unavailable')
+ }
+ return [home]
+}
diff --git a/src/main/ai-vault/spool-session-inventory-memory-budget.ts b/src/main/ai-vault/spool-session-inventory-memory-budget.ts
new file mode 100644
index 00000000000..092f8fc3dd9
--- /dev/null
+++ b/src/main/ai-vault/spool-session-inventory-memory-budget.ts
@@ -0,0 +1,25 @@
+import {
+ SPOOL_SESSION_INVENTORY_MAX_CANDIDATES,
+ SPOOL_SESSION_INVENTORY_MAX_PATH_BYTES
+} from './spool-session-inventory-source-discovery'
+
+const CANDIDATE_OBJECT_OVERHEAD_BYTES = 512
+
+export const SPOOL_SESSION_INVENTORY_SNAPSHOT_MAX_RETAINED_BYTES = 256 * 1024 * 1024
+// Why: an opening can hold both transcript and Codex-home paths before its exact size is known.
+export const SPOOL_SESSION_INVENTORY_SNAPSHOT_OPENING_RESERVATION_BYTES =
+ 2 * SPOOL_SESSION_INVENTORY_MAX_PATH_BYTES +
+ SPOOL_SESSION_INVENTORY_MAX_CANDIDATES * CANDIDATE_OBJECT_OVERHEAD_BYTES
+
+export function estimateSpoolSessionInventorySnapshotBytes(
+ candidates: readonly { file: { path: string }; codexHome?: string | null }[]
+): number {
+ let bytes = 0
+ for (const candidate of candidates) {
+ bytes += Buffer.byteLength(candidate.file.path, 'utf8') + CANDIDATE_OBJECT_OVERHEAD_BYTES
+ if (candidate.codexHome) {
+ bytes += Buffer.byteLength(candidate.codexHome, 'utf8')
+ }
+ }
+ return bytes
+}
diff --git a/src/main/ai-vault/spool-session-inventory-source-discovery.ts b/src/main/ai-vault/spool-session-inventory-source-discovery.ts
new file mode 100644
index 00000000000..3a1c0f3b09b
--- /dev/null
+++ b/src/main/ai-vault/spool-session-inventory-source-discovery.ts
@@ -0,0 +1,67 @@
+import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types'
+import { discoverFiles, type SessionFileDiscoveryBudget } from './session-scanner-discovery'
+import {
+ claudeProjectsRootDirs,
+ codexSessionRootDirs,
+ normalizeAiVaultWslHomeDirs
+} from './session-scanner-source-discovery'
+import { SUBAGENT_DIR_NAME } from './session-scanner-subagent-transcripts'
+import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
+
+export const SPOOL_SESSION_INVENTORY_MAX_CANDIDATES = 50_000
+export const SPOOL_SESSION_INVENTORY_MAX_TRAVERSAL_ENTRIES = 100_000
+export const SPOOL_SESSION_INVENTORY_MAX_PATH_BYTES = 64 * 1024 * 1024
+
+type InventoryRoot = {
+ agent: Extract
+ rootDir: string
+}
+
+export async function discoverSpoolSessionInventorySources(args: {
+ options: AiVaultScanOptions
+ issues: AiVaultScanIssue[]
+ claudeProjectsDirs?: readonly string[]
+ codexSessionDirs?: readonly string[]
+ signal?: AbortSignal
+}): Promise {
+ const wslHomeDirs = normalizeAiVaultWslHomeDirs(args.options.wslHomeDirs)
+ const roots: InventoryRoot[] = [
+ ...(
+ args.claudeProjectsDirs ??
+ claudeProjectsRootDirs({
+ claudeProjectsDir: args.options.claudeProjectsDir,
+ wslHomeDirs
+ })
+ ).map((rootDir) => ({ agent: 'claude' as const, rootDir })),
+ ...(args.codexSessionDirs ?? codexSessionRootDirs(args.options, wslHomeDirs)).map(
+ (rootDir) => ({
+ agent: 'codex' as const,
+ rootDir
+ })
+ )
+ ]
+ const discoveries: SessionFileDiscovery[] = []
+ const budget: SessionFileDiscoveryBudget = { entries: 0, candidates: 0, pathBytes: 0 }
+
+ // Why: exhaustive means fail closed at the resource ceiling, never slice a complete-looking prefix.
+ for (const root of roots) {
+ const discovery = await discoverFiles({
+ rootDir: root.rootDir,
+ limit: null,
+ agent: root.agent,
+ issues: args.issues,
+ extensions: ['.jsonl'],
+ ...(root.agent === 'claude'
+ ? { directoryPredicate: (name: string) => name !== SUBAGENT_DIR_NAME }
+ : {}),
+ readErrorPolicy: 'throw-except-missing',
+ maxEntries: SPOOL_SESSION_INVENTORY_MAX_TRAVERSAL_ENTRIES,
+ maxCandidates: SPOOL_SESSION_INVENTORY_MAX_CANDIDATES,
+ maxPathBytes: SPOOL_SESSION_INVENTORY_MAX_PATH_BYTES,
+ budget,
+ signal: args.signal
+ })
+ discoveries.push(discovery)
+ }
+ return discoveries
+}
diff --git a/src/main/azure-devops/azure-devops-api-request.ts b/src/main/azure-devops/azure-devops-api-request.ts
index 136b21ab0d1..ac4ac250211 100644
--- a/src/main/azure-devops/azure-devops-api-request.ts
+++ b/src/main/azure-devops/azure-devops-api-request.ts
@@ -1,5 +1,9 @@
import { Buffer } from 'node:buffer'
import type { AzureDevOpsRepoRef } from './repository-ref'
+import {
+ HostedReviewApiRequestError,
+ requestHostedReviewJson
+} from '../source-control/hosted-review-api-request'
const REQUEST_TIMEOUT_MS = 5000
@@ -13,6 +17,9 @@ type AzureDevOpsAuthConfig = {
export type AzureDevOpsRequestOptions = {
searchParams?: Record
timeoutMs?: number
+ throwOnError?: boolean
+ allowNotFound?: boolean
+ signal?: AbortSignal
}
function envValue(name: string): string | null {
@@ -76,18 +83,29 @@ export async function requestAzureDevOpsJsonAtBase(
): Promise {
const config = getAzureDevOpsAuthConfig()
try {
- const response = await fetch(apiUrl(baseUrl, path, options.searchParams), {
- headers: {
- Accept: 'application/json',
- ...authHeaders(config)
+ return await requestHostedReviewJson(
+ apiUrl(baseUrl, path, options.searchParams),
+ {
+ headers: {
+ Accept: 'application/json',
+ ...authHeaders(config)
+ }
},
- signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS)
- })
- if (!response.ok) {
+ options.timeoutMs ?? REQUEST_TIMEOUT_MS,
+ options.signal
+ )
+ } catch (error) {
+ options.signal?.throwIfAborted()
+ if (
+ options.allowNotFound &&
+ error instanceof HostedReviewApiRequestError &&
+ error.status === 404
+ ) {
return null
}
- return (await response.json()) as T
- } catch {
+ if (options.throwOnError) {
+ throw error
+ }
return null
}
}
diff --git a/src/main/azure-devops/client.ts b/src/main/azure-devops/client.ts
index 2bed23c2cd4..8b8c147dabc 100644
--- a/src/main/azure-devops/client.ts
+++ b/src/main/azure-devops/client.ts
@@ -10,6 +10,7 @@ import {
getHostedReviewLocalGitOptions,
type HostedReviewExecutionOptions
} from '../source-control/hosted-review-git-options'
+import type { HostedReviewLookupOptions } from '../source-control/hosted-review-lookup-options'
import {
azureDevOpsTokenConfigured,
getAzureDevOpsAuthConfig,
@@ -43,11 +44,14 @@ function encodePathSegment(value: string): string {
}
async function getRepository(
- repo: AzureDevOpsRepoRef
+ repo: AzureDevOpsRepoRef,
+ throwOnError = false,
+ signal?: AbortSignal
): Promise<{ idOrName: string; webBaseUrl: string } | null> {
const raw = await requestAzureDevOpsJson(
repo,
- `/_apis/git/repositories/${encodePathSegment(repo.repository)}`
+ `/_apis/git/repositories/${encodePathSegment(repo.repository)}`,
+ { throwOnError, signal }
)
if (!raw) {
return { idOrName: repo.repository, webBaseUrl: repo.webBaseUrl }
@@ -70,7 +74,9 @@ function readStatusList(
async function getPullRequestStatuses(
repo: AzureDevOpsRepoRef,
repoIdOrName: string,
- pr: RawAzureDevOpsPullRequest
+ pr: RawAzureDevOpsPullRequest,
+ signal?: AbortSignal,
+ throwOnError = false
): Promise {
const raw = await requestAzureDevOpsJson<
RawAzureDevOpsStatus[] | { value?: RawAzureDevOpsStatus[] }
@@ -78,7 +84,8 @@ async function getPullRequestStatuses(
repo,
`/_apis/git/repositories/${encodePathSegment(repoIdOrName)}/pullRequests/${encodePathSegment(
String(pr.pullRequestId)
- )}/statuses`
+ )}/statuses`,
+ { signal, throwOnError }
)
const prStatuses = readStatusList(raw)
if (prStatuses.length > 0) {
@@ -94,7 +101,8 @@ async function getPullRequestStatuses(
repo,
`/_apis/git/repositories/${encodePathSegment(repoIdOrName)}/commits/${encodePathSegment(
commitId
- )}/statuses`
+ )}/statuses`,
+ { signal, throwOnError }
)
return readStatusList(commitStatuses)
}
@@ -103,9 +111,11 @@ async function normalizePullRequest(
repo: AzureDevOpsRepoRef,
repoIdOrName: string,
webBaseUrl: string,
- raw: RawAzureDevOpsPullRequest
+ raw: RawAzureDevOpsPullRequest,
+ signal?: AbortSignal,
+ throwOnError = false
): Promise {
- const statuses = await getPullRequestStatuses(repo, repoIdOrName, raw)
+ const statuses = await getPullRequestStatuses(repo, repoIdOrName, raw, signal, throwOnError)
return mapAzureDevOpsPullRequest(raw, deriveAzureDevOpsStatus(statuses), webBaseUrl)
}
@@ -179,7 +189,7 @@ export async function getAzureDevOpsPullRequest(
connectionId,
getHostedReviewLocalGitOptions(options)
)
- const repository = repo ? await getRepository(repo) : null
+ const repository = repo ? await getRepository(repo, false, options.signal) : null
if (!repo || !repository) {
return null
}
@@ -187,9 +197,12 @@ export async function getAzureDevOpsPullRequest(
repo,
`/_apis/git/repositories/${encodePathSegment(repository.idOrName)}/pullRequests/${encodePathSegment(
String(prNumber)
- )}`
+ )}`,
+ { signal: options.signal }
)
- return raw ? normalizePullRequest(repo, repository.idOrName, repository.webBaseUrl, raw) : null
+ return raw
+ ? normalizePullRequest(repo, repository.idOrName, repository.webBaseUrl, raw, options.signal)
+ : null
}
export async function getAzureDevOpsPullRequestForBranch(
@@ -197,7 +210,7 @@ export async function getAzureDevOpsPullRequestForBranch(
branch: string,
linkedPRNumber?: number | null,
connectionId?: string | null,
- options: HostedReviewExecutionOptions = {}
+ options: HostedReviewLookupOptions = {}
): Promise {
const branchName = branch.replace(/^refs\/heads\//, '')
if (!branchName && linkedPRNumber == null) {
@@ -209,8 +222,14 @@ export async function getAzureDevOpsPullRequestForBranch(
connectionId,
getHostedReviewLocalGitOptions(options)
)
- const repository = repo ? await getRepository(repo) : null
+ options.signal?.throwIfAborted()
+ const repository = repo
+ ? await getRepository(repo, options.throwOnProviderError, options.signal)
+ : null
if (!repo || !repository) {
+ if (options.throwOnProviderError) {
+ throw new Error('Azure DevOps repository lookup became unavailable.')
+ }
return null
}
@@ -219,6 +238,8 @@ export async function getAzureDevOpsPullRequestForBranch(
repo,
`/_apis/git/repositories/${encodePathSegment(repository.idOrName)}/pullRequests`,
{
+ throwOnError: options.throwOnProviderError,
+ signal: options.signal,
searchParams: {
'searchCriteria.sourceRefName': `refs/heads/${branchName}`,
'searchCriteria.status': 'all',
@@ -228,7 +249,14 @@ export async function getAzureDevOpsPullRequestForBranch(
)
const raw = (list?.value ?? []).sort(sortPullRequestsForBranch)[0]
if (raw) {
- return normalizePullRequest(repo, repository.idOrName, repository.webBaseUrl, raw)
+ return normalizePullRequest(
+ repo,
+ repository.idOrName,
+ repository.webBaseUrl,
+ raw,
+ options.signal,
+ options.throwOnProviderError
+ )
}
}
@@ -239,9 +267,24 @@ export async function getAzureDevOpsPullRequestForBranch(
repo,
`/_apis/git/repositories/${encodePathSegment(repository.idOrName)}/pullRequests/${encodePathSegment(
String(linkedPRNumber)
- )}`
+ )}`,
+ // Why: only the durable exact-id lookup can interpret 404 as a deleted review.
+ {
+ allowNotFound: true,
+ throwOnError: options.throwOnProviderError,
+ signal: options.signal
+ }
)
- return raw ? normalizePullRequest(repo, repository.idOrName, repository.webBaseUrl, raw) : null
+ return raw
+ ? normalizePullRequest(
+ repo,
+ repository.idOrName,
+ repository.webBaseUrl,
+ raw,
+ options.signal,
+ options.throwOnProviderError
+ )
+ : null
}
export async function getAzureDevOpsRepoSlug(
diff --git a/src/main/azure-devops/repository-ref.ts b/src/main/azure-devops/repository-ref.ts
index 868d0d8c9bc..afe7fda8b98 100644
--- a/src/main/azure-devops/repository-ref.ts
+++ b/src/main/azure-devops/repository-ref.ts
@@ -12,6 +12,7 @@ export type AzureDevOpsRepoRef = {
type LocalGitExecOptions = {
wslDistro?: string
+ signal?: AbortSignal
}
const REPO_REF_CACHE_MAX_ENTRIES = 512
@@ -222,15 +223,21 @@ export async function getAzureDevOpsRepoRefForRemote(
return null
}
const { stdout } = sshGitProvider
- ? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath)
+ ? await sshGitProvider.exec(
+ ['remote', 'get-url', remoteName],
+ repoPath,
+ localGitOptions.signal ? { signal: localGitOptions.signal } : undefined
+ )
: await gitExecFileAsync(['remote', 'get-url', remoteName], {
cwd: repoPath,
- ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
+ ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
+ ...(localGitOptions.signal ? { signal: localGitOptions.signal } : {})
})
const result = parseAzureDevOpsRepoRef(stdout)
rememberRepoRefCacheEntry(cacheKey, result)
return result
} catch {
+ localGitOptions.signal?.throwIfAborted()
if (connectionId) {
// Why: SSH provider failures are often transient reconnect/tunnel states;
// caching them as "not Azure DevOps" would poison the repo for the session.
diff --git a/src/main/bitbucket/client.ts b/src/main/bitbucket/client.ts
index 5eea09b1662..f23146c5c69 100644
--- a/src/main/bitbucket/client.ts
+++ b/src/main/bitbucket/client.ts
@@ -12,6 +12,11 @@ import {
getHostedReviewLocalGitOptions,
type HostedReviewExecutionOptions
} from '../source-control/hosted-review-git-options'
+import {
+ HostedReviewApiRequestError,
+ requestHostedReviewJson
+} from '../source-control/hosted-review-api-request'
+import type { HostedReviewLookupOptions } from '../source-control/hosted-review-lookup-options'
const DEFAULT_API_BASE_URL = 'https://api.bitbucket.org/2.0'
const REQUEST_TIMEOUT_MS = 5000
@@ -33,6 +38,9 @@ export type BitbucketAuthStatus = {
type RequestOptions = {
searchParams?: Record
timeoutMs?: number
+ throwOnError?: boolean
+ allowNotFound?: boolean
+ signal?: AbortSignal
}
function envValue(name: string): string | null {
@@ -89,18 +97,29 @@ function apiUrl(path: string, searchParams?: RequestOptions['searchParams']): st
async function requestJson(path: string, options: RequestOptions = {}): Promise {
const config = getAuthConfig()
try {
- const response = await fetch(apiUrl(path, options.searchParams), {
- headers: {
- Accept: 'application/json',
- ...authHeaders(config)
+ return await requestHostedReviewJson(
+ new URL(apiUrl(path, options.searchParams)),
+ {
+ headers: {
+ Accept: 'application/json',
+ ...authHeaders(config)
+ }
},
- signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS)
- })
- if (!response.ok) {
+ options.timeoutMs ?? REQUEST_TIMEOUT_MS,
+ options.signal
+ )
+ } catch (error) {
+ options.signal?.throwIfAborted()
+ if (
+ options.allowNotFound &&
+ error instanceof HostedReviewApiRequestError &&
+ error.status === 404
+ ) {
return null
}
- return (await response.json()) as T
- } catch {
+ if (options.throwOnError) {
+ throw error
+ }
return null
}
}
@@ -119,24 +138,28 @@ function allStateFilter(): string {
async function getBuildStatus(
repo: BitbucketRepoRef,
- headSha: string | undefined
+ headSha: string | undefined,
+ signal?: AbortSignal,
+ throwOnError = false
): Promise {
if (!headSha) {
return 'neutral'
}
const data = await requestJson<{ values?: RawBitbucketBuildStatus[] }>(
`/repositories/${encodedRepoPath(repo)}/commit/${encodeURIComponent(headSha)}/statuses/build`,
- { searchParams: { pagelen: '100' } }
+ { searchParams: { pagelen: '100' }, signal, throwOnError }
)
return deriveBitbucketBuildStatus(data?.values ?? [])
}
async function normalizePullRequest(
repo: BitbucketRepoRef,
- raw: RawBitbucketPullRequest
+ raw: RawBitbucketPullRequest,
+ signal?: AbortSignal,
+ throwOnError = false
): Promise {
const headSha = raw.source?.commit?.hash?.trim()
- const status = await getBuildStatus(repo, headSha)
+ const status = await getBuildStatus(repo, headSha, signal, throwOnError)
return mapBitbucketPullRequest(raw, status)
}
@@ -172,9 +195,10 @@ export async function getBitbucketPullRequest(
return null
}
const raw = await requestJson(
- `/repositories/${encodedRepoPath(repo)}/pullrequests/${encodeURIComponent(String(prNumber))}`
+ `/repositories/${encodedRepoPath(repo)}/pullrequests/${encodeURIComponent(String(prNumber))}`,
+ { signal: options.signal }
)
- return raw ? normalizePullRequest(repo, raw) : null
+ return raw ? normalizePullRequest(repo, raw, options.signal) : null
}
export async function getBitbucketPullRequestForBranch(
@@ -182,7 +206,7 @@ export async function getBitbucketPullRequestForBranch(
branch: string,
linkedPRNumber?: number | null,
connectionId?: string | null,
- options: HostedReviewExecutionOptions = {}
+ options: HostedReviewLookupOptions = {}
): Promise {
const branchName = branch.replace(/^refs\/heads\//, '')
if (!branchName && linkedPRNumber == null) {
@@ -194,7 +218,11 @@ export async function getBitbucketPullRequestForBranch(
connectionId,
getHostedReviewLocalGitOptions(options)
)
+ options.signal?.throwIfAborted()
if (!repo) {
+ if (options.throwOnProviderError) {
+ throw new Error('Bitbucket repository lookup became unavailable.')
+ }
return null
}
@@ -206,6 +234,8 @@ export async function getBitbucketPullRequestForBranch(
const list = await requestJson<{ values?: RawBitbucketPullRequest[] }>(
`/repositories/${encodedRepoPath(repo)}/pullrequests`,
{
+ throwOnError: options.throwOnProviderError,
+ signal: options.signal,
searchParams: {
pagelen: '1',
sort: '-updated_on',
@@ -216,7 +246,7 @@ export async function getBitbucketPullRequestForBranch(
)
const raw = list?.values?.[0]
if (raw) {
- return normalizePullRequest(repo, raw)
+ return normalizePullRequest(repo, raw, options.signal, options.throwOnProviderError)
}
}
@@ -224,9 +254,15 @@ export async function getBitbucketPullRequestForBranch(
return null
}
const raw = await requestJson(
- `/repositories/${encodedRepoPath(repo)}/pullrequests/${encodeURIComponent(String(linkedPRNumber))}`
+ `/repositories/${encodedRepoPath(repo)}/pullrequests/${encodeURIComponent(String(linkedPRNumber))}`,
+ // Why: only the durable exact-id lookup can interpret 404 as a deleted review.
+ {
+ allowNotFound: true,
+ throwOnError: options.throwOnProviderError,
+ signal: options.signal
+ }
)
- return raw ? normalizePullRequest(repo, raw) : null
+ return raw ? normalizePullRequest(repo, raw, options.signal, options.throwOnProviderError) : null
}
export async function getBitbucketRepoSlug(
diff --git a/src/main/bitbucket/repository-ref.ts b/src/main/bitbucket/repository-ref.ts
index fb07c32d4bc..bef40af0f7e 100644
--- a/src/main/bitbucket/repository-ref.ts
+++ b/src/main/bitbucket/repository-ref.ts
@@ -8,6 +8,7 @@ export type BitbucketRepoRef = {
type LocalGitExecOptions = {
wslDistro?: string
+ signal?: AbortSignal
}
const REPO_REF_CACHE_MAX_ENTRIES = 512
@@ -97,15 +98,21 @@ export async function getBitbucketRepoRefForRemote(
return null
}
const { stdout } = sshGitProvider
- ? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath)
+ ? await sshGitProvider.exec(
+ ['remote', 'get-url', remoteName],
+ repoPath,
+ localGitOptions.signal ? { signal: localGitOptions.signal } : undefined
+ )
: await gitExecFileAsync(['remote', 'get-url', remoteName], {
cwd: repoPath,
- ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
+ ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
+ ...(localGitOptions.signal ? { signal: localGitOptions.signal } : {})
})
const result = parseBitbucketRepoRef(stdout)
rememberRepoRefCacheEntry(cacheKey, result)
return result
} catch {
+ localGitOptions.signal?.throwIfAborted()
if (connectionId) {
// Why: SSH provider failures are often transient reconnect/tunnel states;
// caching them as "not Bitbucket" would poison the repo for the session.
diff --git a/src/main/claude-accounts/runtime-auth-service.ts b/src/main/claude-accounts/runtime-auth-service.ts
index 72a29cfa6a9..0bf461ca185 100644
--- a/src/main/claude-accounts/runtime-auth-service.ts
+++ b/src/main/claude-accounts/runtime-auth-service.ts
@@ -7,6 +7,7 @@ import { dirname, join } from 'node:path'
import { app } from 'electron'
import type { ClaudeManagedAccount } from '../../shared/types'
import type { Store } from '../persistence'
+import type { AiVaultSessionRuntimeTarget } from '../ai-vault/session-root-configuration'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import type { ClaudeEnvPatch } from './environment'
import {
@@ -15,7 +16,7 @@ import {
writeClaudeManagedAuthFile
} from './managed-auth-path'
import { parseWslUncPath } from '../../shared/wsl-paths'
-import { getDefaultWslDistro, getWslHome, toWindowsWslPath } from '../wsl'
+import { getDefaultWslDistro, getWslHome, getWslHomeAsync, toWindowsWslPath } from '../wsl'
import { buildEncodedWslBashCommand } from '../wsl-bash-command'
import { hasLiveClaudePtys } from './live-pty-gate'
import { isOauthTokenExpiring, refreshClaudeOauthCredentials } from './oauth-refresh'
@@ -159,6 +160,33 @@ export class ClaudeRuntimeAuthService {
return this.pathResolver.getRuntimePaths().configDir
}
+ async resolveSessionProjectRoots(target: AiVaultSessionRuntimeTarget): Promise {
+ if (target.runtime === 'host') {
+ return [join(this.getRuntimeConfigDir(), 'projects')]
+ }
+ const distro = target.wslDistro.trim()
+ const home = distro ? await getWslHomeAsync(distro) : null
+ if (!home) {
+ throw new Error('Claude WSL session root is unavailable')
+ }
+ const roots = [join(home, '.claude', 'projects')]
+ for (const account of this.store.getSettings().claudeManagedAccounts) {
+ if (
+ account.managedAuthRuntime !== 'wsl' ||
+ account.wslDistro?.trim().toLowerCase() !== distro.toLowerCase()
+ ) {
+ continue
+ }
+ const managedAuthPath = this.getOwnedManagedAuthPath(account)
+ if (!managedAuthPath) {
+ // Why: omitting a configured managed root could permanently under-attest first publication.
+ throw new Error('Claude managed WSL session root is unavailable')
+ }
+ roots.push(join(managedAuthPath, 'projects'))
+ }
+ return [...new Set(roots)]
+ }
+
private initializeLastSyncedState(): void {
const settings = this.store.getSettings()
this.lastSyncedAccountId = getSelectedClaudeAccountIdForTarget(settings, { runtime: 'host' })
diff --git a/src/main/daemon/daemon-host-relocation.ts b/src/main/daemon/daemon-host-relocation.ts
index ac674fdd290..c37de6c1ca4 100644
--- a/src/main/daemon/daemon-host-relocation.ts
+++ b/src/main/daemon/daemon-host-relocation.ts
@@ -50,7 +50,7 @@ const MARKER_NAME = '.materialized.json'
// LOCAL appData, not the roaming userData dir, so a roaming profile or OneDrive
// Known-Folder-Move never syncs it (slow login/logout, sync bloat). This folder
// name is shared verbatim with the NSIS uninstall cleanup
-// (config/nsis/daemon-host-uninstall.nsh), which removes
+// (config/nsis/windows-installer-hooks.nsh), which removes
// %LOCALAPPDATA%\\daemon-host — keep the two in sync.
const LOCAL_HOST_ROOT_NAME = 'Orca'
diff --git a/src/main/git/repo-clone-path.ts b/src/main/git/repo-clone-path.ts
index a8204e333a8..cc3c5f98ca0 100644
--- a/src/main/git/repo-clone-path.ts
+++ b/src/main/git/repo-clone-path.ts
@@ -64,7 +64,7 @@ export function getClonePathComparisonKey(clonePath: string): string {
// Why: WSL UNC paths cross into a case-sensitive Linux filesystem, so only
// the Windows UNC server alias and distro segment should be case-folded.
const linuxPath = (wslUncMatch[2] ?? '').replace(/\/+$/, '')
- return `//wsl/${wslUncMatch[1].toLowerCase()}${linuxPath}`
+ return `//wsl.localhost/${wslUncMatch[1].toLowerCase()}${linuxPath}`
}
return normalizeRuntimePathForComparison(resolvedClonePath)
}
diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts
index 27d1e5837b9..81ea17eb6ac 100644
--- a/src/main/git/runner.ts
+++ b/src/main/git/runner.ts
@@ -1429,6 +1429,7 @@ export async function ghExecFileAsync(
let attemptedHostFallback = false
let attemptedDefaultWslFallback = false
for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) {
+ options.signal?.throwIfAborted()
try {
const { stdout, stderr } = await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
@@ -1437,7 +1438,8 @@ export async function ghExecFileAsync(
// Why: GitHub detail IPC powers PR cards, Tasks, and URL worktree
// creation; one stuck gh child must fail visibly, not wedge every lane.
timeout: options.timeout ?? defaultGhExecTimeoutMs(options.env),
- env: nonInteractiveGhEnv(options.env)
+ env: nonInteractiveGhEnv(options.env),
+ signal: options.signal
})
return { stdout: stdout as string, stderr: stderr as string }
} catch (err) {
@@ -1562,13 +1564,15 @@ export async function glabExecFileAsync(
let lastError: unknown
let attemptedDefaultWslFallback = false
for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) {
+ options.signal?.throwIfAborted()
try {
const { stdout, stderr } = await execFileCapture(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: (options.encoding ?? 'utf-8') as BufferEncoding,
maxBuffer: options.maxBuffer,
timeout: options.timeout,
- env: options.env
+ env: options.env,
+ signal: options.signal
})
return { stdout: stdout as string, stderr: stderr as string }
} catch (err) {
diff --git a/src/main/gitea/client.ts b/src/main/gitea/client.ts
index 30545f83ad7..5b45c6bba6b 100644
--- a/src/main/gitea/client.ts
+++ b/src/main/gitea/client.ts
@@ -11,6 +11,11 @@ import {
getHostedReviewLocalGitOptions,
type HostedReviewExecutionOptions
} from '../source-control/hosted-review-git-options'
+import {
+ HostedReviewApiRequestError,
+ requestHostedReviewJson
+} from '../source-control/hosted-review-api-request'
+import type { HostedReviewLookupOptions } from '../source-control/hosted-review-lookup-options'
const REQUEST_TIMEOUT_MS = 5000
// Why: self-hosted Forgejo can take ~5s to serve one /pulls page (it loads
@@ -36,6 +41,9 @@ export type GiteaAuthStatus = {
type RequestOptions = {
searchParams?: Record
timeoutMs?: number
+ throwOnError?: boolean
+ allowNotFound?: boolean
+ signal?: AbortSignal
}
function envValue(name: string): string | null {
@@ -81,18 +89,29 @@ async function requestJsonAtBase(
): Promise {
const config = getAuthConfig()
try {
- const response = await fetch(apiUrl(baseUrl, path, options.searchParams), {
- headers: {
- Accept: 'application/json',
- ...authHeaders(config)
+ return await requestHostedReviewJson(
+ apiUrl(baseUrl, path, options.searchParams),
+ {
+ headers: {
+ Accept: 'application/json',
+ ...authHeaders(config)
+ }
},
- signal: AbortSignal.timeout(options.timeoutMs ?? REQUEST_TIMEOUT_MS)
- })
- if (!response.ok) {
+ options.timeoutMs ?? REQUEST_TIMEOUT_MS,
+ options.signal
+ )
+ } catch (error) {
+ options.signal?.throwIfAborted()
+ if (
+ options.allowNotFound &&
+ error instanceof HostedReviewApiRequestError &&
+ error.status === 404
+ ) {
return null
}
- return (await response.json()) as T
- } catch {
+ if (options.throwOnError) {
+ throw error
+ }
return null
}
}
@@ -121,23 +140,28 @@ export function invalidateGiteaPullRequestScanForRepo(repo: GiteaRepoRef): void
async function getCommitStatus(
repo: GiteaRepoRef,
- headSha: string | undefined
+ headSha: string | undefined,
+ signal?: AbortSignal,
+ throwOnError = false
): Promise> {
if (!headSha) {
return 'neutral'
}
const data = await requestJson(
repo,
- `/repos/${encodedRepoPath(repo)}/commits/${encodeURIComponent(headSha)}/status`
+ `/repos/${encodedRepoPath(repo)}/commits/${encodeURIComponent(headSha)}/status`,
+ { signal, throwOnError }
)
return deriveGiteaCommitStatus(data)
}
async function normalizePullRequest(
repo: GiteaRepoRef,
- raw: RawGiteaPullRequest
+ raw: RawGiteaPullRequest,
+ signal?: AbortSignal,
+ throwOnError = false
): Promise {
- const status = await getCommitStatus(repo, raw.head?.sha?.trim())
+ const status = await getCommitStatus(repo, raw.head?.sha?.trim(), signal, throwOnError)
return mapGiteaPullRequest(raw, status)
}
@@ -215,9 +239,10 @@ export async function getGiteaPullRequest(
}
const raw = await requestJson(
repo,
- `/repos/${encodedRepoPath(repo)}/pulls/${encodeURIComponent(String(prNumber))}`
+ `/repos/${encodedRepoPath(repo)}/pulls/${encodeURIComponent(String(prNumber))}`,
+ { signal: options.signal }
)
- return raw ? normalizePullRequest(repo, raw) : null
+ return raw ? normalizePullRequest(repo, raw, options.signal) : null
}
export async function getGiteaPullRequestForBranch(
@@ -225,7 +250,7 @@ export async function getGiteaPullRequestForBranch(
branch: string,
linkedPRNumber?: number | null,
connectionId?: string | null,
- options: HostedReviewExecutionOptions = {}
+ options: HostedReviewLookupOptions = {}
): Promise {
const branchName = branch.replace(/^refs\/heads\//, '')
if (!branchName && linkedPRNumber == null) {
@@ -237,15 +262,24 @@ export async function getGiteaPullRequestForBranch(
connectionId,
getHostedReviewLocalGitOptions(options)
)
+ options.signal?.throwIfAborted()
if (!repo) {
+ if (options.throwOnProviderError) {
+ throw new Error('Gitea repository lookup became unavailable.')
+ }
return null
}
if (branchName) {
+ // Why: cancellable remote lookups cannot share an in-flight request whose
+ // AbortSignal belongs to another caller; ordinary sidebar scans still use
+ // the bounded repo cache to protect self-hosted Gitea instances.
const pullRequests = await scanGiteaPullRequests(
giteaPullRequestScanKey(repo),
(page) =>
requestJson(repo, `/repos/${encodedRepoPath(repo)}/pulls`, {
+ throwOnError: options.throwOnProviderError,
+ signal: options.signal,
searchParams: {
state: 'all',
sort: 'recentupdate',
@@ -255,11 +289,12 @@ export async function getGiteaPullRequestForBranch(
timeoutMs: PULL_REQUEST_LIST_TIMEOUT_MS
}),
PULL_REQUEST_PAGE_LIMIT,
- MAX_PULL_REQUEST_PAGES
+ MAX_PULL_REQUEST_PAGES,
+ !options.signal && !options.throwOnProviderError
)
const raw = pullRequests.find((item) => matchesBranch(item, branchName))
if (raw) {
- return normalizePullRequest(repo, raw)
+ return normalizePullRequest(repo, raw, options.signal, options.throwOnProviderError)
}
}
@@ -268,9 +303,15 @@ export async function getGiteaPullRequestForBranch(
}
const raw = await requestJson(
repo,
- `/repos/${encodedRepoPath(repo)}/pulls/${encodeURIComponent(String(linkedPRNumber))}`
+ `/repos/${encodedRepoPath(repo)}/pulls/${encodeURIComponent(String(linkedPRNumber))}`,
+ // Why: only the durable exact-id lookup can interpret 404 as a deleted review.
+ {
+ allowNotFound: true,
+ throwOnError: options.throwOnProviderError,
+ signal: options.signal
+ }
)
- return raw ? normalizePullRequest(repo, raw) : null
+ return raw ? normalizePullRequest(repo, raw, options.signal, options.throwOnProviderError) : null
}
export async function getGiteaRepoSlug(
diff --git a/src/main/gitea/pull-request-scan-cache.ts b/src/main/gitea/pull-request-scan-cache.ts
index 8864f087d6d..a395923e1d8 100644
--- a/src/main/gitea/pull-request-scan-cache.ts
+++ b/src/main/gitea/pull-request-scan-cache.ts
@@ -74,6 +74,27 @@ function reusableScanCacheEntry(repoKey: string): GiteaPullRequestScanEntry | nu
return entry
}
+async function collectGiteaPullRequestPages(
+ fetchPage: GiteaPullRequestPageFetcher,
+ pageLimit: number,
+ maxPages: number
+): Promise<{ pullRequests: RawGiteaPullRequest[]; completed: boolean }> {
+ const pullRequests: RawGiteaPullRequest[] = []
+ let completed = true
+ for (let page = 1; page <= maxPages; page++) {
+ const list = await fetchPage(page)
+ if (!list) {
+ completed = false
+ break
+ }
+ pullRequests.push(...list)
+ if (list.length < pageLimit) {
+ break
+ }
+ }
+ return { pullRequests, completed }
+}
+
/**
* Why: every worktree card resolves its branch by paginating the same
* /repos/{repo}/pulls listing — Gitea/Forgejo have no head-branch filter.
@@ -87,8 +108,12 @@ export async function scanGiteaPullRequests(
repoKey: string,
fetchPage: GiteaPullRequestPageFetcher,
pageLimit: number,
- maxPages: number
+ maxPages: number,
+ useCache = true
): Promise {
+ if (!useCache) {
+ return (await collectGiteaPullRequestPages(fetchPage, pageLimit, maxPages)).pullRequests
+ }
const cached = reusableScanCacheEntry(repoKey)
if (cached) {
return cached.pullRequests
@@ -100,19 +125,11 @@ export async function scanGiteaPullRequests(
const generation = scanGenerations.get(repoKey) ?? 0
activeScanCounts.set(repoKey, (activeScanCounts.get(repoKey) ?? 0) + 1)
const scan = (async () => {
- const pullRequests: RawGiteaPullRequest[] = []
- let completed = true
- for (let page = 1; page <= maxPages; page++) {
- const list = await fetchPage(page)
- if (!list) {
- completed = false
- break
- }
- pullRequests.push(...list)
- if (list.length < pageLimit) {
- break
- }
- }
+ const { pullRequests, completed } = await collectGiteaPullRequestPages(
+ fetchPage,
+ pageLimit,
+ maxPages
+ )
if ((scanGenerations.get(repoKey) ?? 0) === generation) {
rememberScanCacheEntry(repoKey, pullRequests, completed ? SCAN_TTL_MS : FAILED_SCAN_RETRY_MS)
}
diff --git a/src/main/gitea/repository-ref.ts b/src/main/gitea/repository-ref.ts
index f5a9acc8e5f..1e024262d8e 100644
--- a/src/main/gitea/repository-ref.ts
+++ b/src/main/gitea/repository-ref.ts
@@ -11,6 +11,7 @@ export type GiteaRepoRef = {
type LocalGitExecOptions = {
wslDistro?: string
+ signal?: AbortSignal
}
const KNOWN_NON_GITEA_HOSTS = new Set([
@@ -158,15 +159,21 @@ export async function getGiteaRepoRefForRemote(
return null
}
const { stdout } = sshGitProvider
- ? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath)
+ ? await sshGitProvider.exec(
+ ['remote', 'get-url', remoteName],
+ repoPath,
+ localGitOptions.signal ? { signal: localGitOptions.signal } : undefined
+ )
: await gitExecFileAsync(['remote', 'get-url', remoteName], {
cwd: repoPath,
- ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
+ ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
+ ...(localGitOptions.signal ? { signal: localGitOptions.signal } : {})
})
const result = parseGiteaRepoRef(stdout)
rememberRepoRefCacheEntry(cacheKey, result)
return result
} catch {
+ localGitOptions.signal?.throwIfAborted()
if (connectionId) {
// Why: SSH provider failures are often transient reconnect/tunnel states;
// caching them as "not Gitea" would poison the repo for the session.
diff --git a/src/main/github/client.ts b/src/main/github/client.ts
index 54572bd406f..df6bf8369af 100644
--- a/src/main/github/client.ts
+++ b/src/main/github/client.ts
@@ -108,7 +108,7 @@ import {
type RateLimitBucketKind
} from './rate-limit'
-type GhExecOptions = ReturnType
+type GhExecOptions = ReturnType & { signal?: AbortSignal }
type HostedReviewLocalGitOptions = ReturnType
const ORCA_REPO = 'stablyai/orca'
@@ -2945,10 +2945,15 @@ export async function getPRForBranchOutcome(
const localGitArgs = hostedReviewLocalGitOptionArgs(options)
const localGitOptions = localGitArgs[0] ?? {}
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
- const ghOptions = ghRepoExecOptions(context)
+ const ghOptions: GhExecOptions = {
+ ...ghRepoExecOptions(context),
+ ...(options.signal ? { signal: options.signal } : {})
+ }
+ options.signal?.throwIfAborted()
await acquire()
try {
+ options.signal?.throwIfAborted()
const { candidates, headRepo } = await resolvePRRepositoryCandidates(
repoPath,
connectionId,
@@ -3521,6 +3526,7 @@ async function getPRChecksViaRestFallback(
.map(mapRestCommitStatus)
.filter((check): check is PRCheckDetail => check !== null && !checkRunNames.has(check.name))
} catch (err) {
+ ghOptions.signal?.throwIfAborted()
// Why: the REST fallback is already degraded; keep richer check-run rows
// if the legacy-status enrichment fails.
console.warn('getPRChecks REST status fallback failed:', err)
@@ -3549,12 +3555,14 @@ async function getPRChecksViaRestFallback(
url: getPendingApprovalCheckSuiteUrl(ownerRepo, headSha, suite.id)
}))
} catch (err) {
+ ghOptions.signal?.throwIfAborted()
console.warn('getPRChecks REST check-suite fallback failed:', err)
}
const checks = [...checkRuns, ...legacyStatuses, ...pendingApprovalChecks]
return checks.length > 0 ? checks : null
} catch (err) {
+ ghOptions.signal?.throwIfAborted()
console.warn('getPRChecks via REST fallback failed, falling back to gh pr checks:', err)
return null
} finally {
@@ -3572,13 +3580,18 @@ export async function getPRChecks(
prNumber: number,
headSha?: string,
prRepo?: OwnerRepo | null,
- options?: { noCache?: boolean },
+ options?: { noCache?: boolean; signal?: AbortSignal },
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise {
void headSha
- const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
+ const ghOptions: GhExecOptions = {
+ ...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
+ ...(options?.signal ? { signal: options.signal } : {})
+ }
+ options?.signal?.throwIfAborted()
const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions))
+ options?.signal?.throwIfAborted()
const fallbackToPRChecks = async (): Promise => {
await assertRateLimitBudget('graphql')
await acquire()
@@ -3649,6 +3662,7 @@ export async function getPRChecks(
return checks
}
} catch (err) {
+ options?.signal?.throwIfAborted()
// Why: if GitHub's richer rollup query is unavailable, the older gh
// command still returns the combined check/status list for display.
console.warn('getPRChecks via GraphQL rollup failed, falling back to gh pr checks:', err)
diff --git a/src/main/gitlab/client.ts b/src/main/gitlab/client.ts
index b6b32dfb8c3..02a80344f90 100644
--- a/src/main/gitlab/client.ts
+++ b/src/main/gitlab/client.ts
@@ -47,6 +47,8 @@ import {
getHostedReviewLocalGitOptions,
type HostedReviewExecutionOptions
} from '../source-control/hosted-review-git-options'
+import type { HostedReviewLookupOptions } from '../source-control/hosted-review-lookup-options'
+import { extractExecError } from '../git/runner'
// Why: glab REST API addresses projects by URL-encoded path. Centralized
// so call sites don't forget the slash escapes for nested groups.
@@ -56,6 +58,7 @@ function encodedProject(projectPath: string): string {
const GITLAB_RATE_LIMIT_CACHE_TTL_MS = 30_000
const GITLAB_RATE_LIMIT_CACHE_MAX_ENTRIES = 64
+const GITLAB_BRANCH_LOOKUP_TIMEOUT_MS = 10_000
const gitLabRateLimitCache = new Map()
type HostedReviewLocalGitOptions = ReturnType
@@ -248,7 +251,9 @@ export async function getProjectSlug(
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise {
- const knownHosts = await getGlabKnownHosts(connectionId)
+ const knownHosts = options.signal
+ ? await getGlabKnownHosts(connectionId, { signal: options.signal })
+ : await getGlabKnownHosts(connectionId)
return getProjectRef(
repoPath,
knownHosts,
@@ -313,20 +318,38 @@ export async function getMergeRequestForBranch(
branch: string,
linkedMRIid?: number | null,
connectionId?: string | null,
- options: HostedReviewExecutionOptions = {}
+ options: HostedReviewLookupOptions = {}
): Promise {
const branchName = branch.replace(/^refs\/heads\//, '')
if (!branchName && linkedMRIid == null) {
return null
}
- const knownHosts = await getGlabKnownHosts(connectionId)
+ options.signal?.throwIfAborted()
+ const knownHosts = options.signal
+ ? await getGlabKnownHosts(connectionId, { signal: options.signal })
+ : await getGlabKnownHosts(connectionId)
const localGitArgs = hostedReviewLocalGitOptionArgs(options)
const localGitOptions = localGitArgs[0] ?? {}
const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs)
+ options.signal?.throwIfAborted()
if (!projectRef) {
+ if (options.throwOnProviderError) {
+ throw new Error('GitLab project lookup became unavailable.')
+ }
return null
}
- await acquire()
+ // Why: the GitLab client has only four shared CLI lanes; one stalled branch
+ // lookup must release its lane even when glab's own network timeout does not.
+ const timeoutSignal = AbortSignal.timeout(GITLAB_BRANCH_LOOKUP_TIMEOUT_MS)
+ const lookupSignal = options.signal
+ ? AbortSignal.any([options.signal, timeoutSignal])
+ : timeoutSignal
+ await acquire(lookupSignal)
+ let exactLinkedLookup = false
+ const lookupExecOptions = {
+ ...glabRepoExecOptions(repoPath, connectionId, localGitOptions),
+ signal: lookupSignal
+ }
try {
if (branchName) {
const { stdout } = await glabExecFileAsync(
@@ -335,7 +358,7 @@ export async function getMergeRequestForBranch(
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/merge_requests?source_branch=${encodeURIComponent(branchName)}&order_by=updated_at&sort=desc&per_page=1`
],
- glabRepoExecOptions(repoPath, connectionId, localGitOptions)
+ lookupExecOptions
)
const data = JSON.parse(stdout) as (Parameters[0] & {
head_pipeline?: { status?: string } | null
@@ -355,13 +378,14 @@ export async function getMergeRequestForBranch(
// Why: create-from-MR worktrees may use a fresh local branch name rather
// than the MR source branch. Fall back to the durable linked iid so the
// core review status still follows the workspace.
+ exactLinkedLookup = true
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/merge_requests/${linkedMRIid}`
],
- glabRepoExecOptions(repoPath, connectionId, localGitOptions)
+ lookupExecOptions
)
const raw = JSON.parse(stdout) as Parameters[0] & {
head_pipeline?: { status?: string } | null
@@ -369,13 +393,22 @@ export async function getMergeRequestForBranch(
}
const pipelineStatus = derivePipelineStatus(raw.head_pipeline ?? raw.pipeline ?? null)
return mapMRInfo(raw, pipelineStatus)
- } catch {
+ } catch (error) {
+ // Why: a linked exact-id 404 is a stale link; branch-list failures remain unavailable.
+ if (options.throwOnProviderError && !(exactLinkedLookup && isGitLabNotFoundError(error))) {
+ throw error
+ }
return null
} finally {
release()
}
}
+function isGitLabNotFoundError(error: unknown): boolean {
+ const output = extractExecError(error)
+ return classifyGlabError(`${output.stderr}\n${output.stdout}`).type === 'not_found'
+}
+
function mrListStateFlags(state: MRListState): string[] {
switch (state) {
case 'opened':
diff --git a/src/main/gitlab/gitlab-project-ref-resolution.ts b/src/main/gitlab/gitlab-project-ref-resolution.ts
index 1dba771d05e..fb858b310a1 100644
--- a/src/main/gitlab/gitlab-project-ref-resolution.ts
+++ b/src/main/gitlab/gitlab-project-ref-resolution.ts
@@ -15,9 +15,11 @@ export type { ProjectRef }
export type LocalGitExecOptions = {
wslDistro?: string
+ signal?: AbortSignal
}
const PROJECT_REF_CACHE_MAX_ENTRIES = 512
+const GLAB_AUTH_STATUS_TIMEOUT_MS = 10_000
const projectRefCache = new Map()
// Why: known hosts are cached PER connection. A repo on an SSH connection
@@ -72,6 +74,18 @@ export async function getProjectRefForRemote(
return projectRefCache.get(cacheKey)!
}
+ if (localGitOptions.signal) {
+ // Why: cancellation belongs to one Spool read; it must not abort a shared
+ // provider-detection probe that an unrelated local sidebar is awaiting.
+ return resolveProjectRefForRemote(
+ repoPath,
+ remoteName,
+ knownHosts,
+ connectionId,
+ cacheKey,
+ localGitOptions
+ )
+ }
return runProjectRefProbeOnce(cacheKey, () =>
resolveProjectRefForRemote(
repoPath,
@@ -98,10 +112,15 @@ async function resolveProjectRefForRemote(
return null
}
const { stdout } = sshGitProvider
- ? await sshGitProvider.exec(['remote', 'get-url', remoteName], repoPath)
+ ? await sshGitProvider.exec(
+ ['remote', 'get-url', remoteName],
+ repoPath,
+ localGitOptions.signal ? { signal: localGitOptions.signal } : undefined
+ )
: await gitExecFileAsync(['remote', 'get-url', remoteName], {
cwd: repoPath,
- ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
+ ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
+ ...(localGitOptions.signal ? { signal: localGitOptions.signal } : {})
})
const result = parseGitLabProjectRef(stdout, knownHosts)
if (result) {
@@ -123,6 +142,7 @@ async function resolveProjectRefForRemote(
return remoteCandidate
}
} catch {
+ localGitOptions.signal?.throwIfAborted()
if (connectionId) {
return null
}
@@ -214,12 +234,15 @@ export function glabRepoExecOptions(
repoPath: string,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
-): { cwd?: string; wslDistro?: string } {
+): { cwd?: string; wslDistro?: string; signal?: AbortSignal } {
return connectionId
- ? {}
+ ? localGitOptions.signal
+ ? { signal: localGitOptions.signal }
+ : {}
: {
cwd: repoPath,
- ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
+ ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
+ ...(localGitOptions.signal ? { signal: localGitOptions.signal } : {})
}
}
@@ -263,7 +286,10 @@ async function isGlabConfiguredForRemoteHost(
}
}
-export async function getGlabKnownHosts(connectionId?: string | null): Promise {
+export async function getGlabKnownHosts(
+ connectionId?: string | null,
+ options: { signal?: AbortSignal } = {}
+): Promise {
const key = connectionCacheKey(connectionId)
const cached = knownHostsCacheByConnection.get(key)
if (cached) {
@@ -275,12 +301,15 @@ export async function getGlabKnownHosts(connectionId?: string | null): Promise void)[] = []
+type QueuedAcquire = {
+ grant: () => void
+ abort?: () => void
+}
+const queue: QueuedAcquire[] = []
-export function acquire(): Promise {
+export function acquire(signal?: AbortSignal): Promise {
+ signal?.throwIfAborted()
if (running < MAX_CONCURRENT) {
running += 1
return Promise.resolve()
}
- return new Promise((resolve) =>
- queue.push(() => {
- running += 1
- resolve()
- })
- )
+ return new Promise((resolve, reject) => {
+ const queued: QueuedAcquire = {
+ grant: () => {
+ if (queued.abort) {
+ signal?.removeEventListener('abort', queued.abort)
+ }
+ running += 1
+ resolve()
+ }
+ }
+ queued.abort = () => {
+ // Why: an abandoned Spool read must leave the shared glab queue before
+ // it can later consume one of the four process lanes.
+ const index = queue.indexOf(queued)
+ if (index !== -1) {
+ queue.splice(index, 1)
+ }
+ reject(signal?.reason ?? new Error('aborted'))
+ }
+ queue.push(queued)
+ signal?.addEventListener('abort', queued.abort, { once: true })
+ if (signal?.aborted) {
+ queued.abort()
+ }
+ })
}
export function release(): void {
running -= 1
const next = queue.shift()
if (next) {
- next()
+ next.grant()
}
}
diff --git a/src/main/index.ts b/src/main/index.ts
index 21c4a9ab51e..7376b287932 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -29,6 +29,7 @@ import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-direct
import { registerCoreHandlers } from './ipc/register-core-handlers'
import { initObservability, shutdownObservability } from './observability'
import { registerMobileHandlers } from './ipc/mobile'
+import { registerSpoolSharingHandlers } from './ipc/spool-sharing'
import { initTelemetry, shutdownTelemetry, trackAppOpenedOnce, track } from './telemetry/client'
import { classifyError } from './telemetry/classify-error'
import { runManagedHookInstallers } from './agent-hooks/install-telemetry'
@@ -202,6 +203,11 @@ import { CliInstaller } from './cli/cli-installer'
import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher'
import { reconcileManagedWslCliRegistrations } from './cli/wsl-cli-registration-reconciliation'
import { selfHealRuntimeEnvironmentFocus } from './runtime-environment-focus-self-heal'
+import {
+ createSpoolDesktopComposition,
+ type SpoolDesktopComposition
+} from './spool/spool-desktop-composition'
+import { SpoolUnavailableDesktopService } from './spool/spool-unavailable-desktop-service'
let mainWindow: BrowserWindow | null = null
/** Whether a manual app.quit() (Cmd+Q, etc.) is in progress. Shared with the
@@ -222,6 +228,8 @@ let rateLimits: RateLimitService | null = null
let runtimeRpc: OrcaRuntimeRpcServer | null = null
let desktopRelayService: DesktopRelayService | null = null
let desktopRelayStatus: RelayBrokerStatus = 'offline'
+let spoolDesktop: SpoolDesktopComposition | null = null
+let unregisterSpoolSharingHandlers: (() => void) | null = null
// Why: set during early startup; gates whether headless serve installs the
// offscreen browser backend (and thus advertises browser pane support).
let headlessBrowserDisplayAvailable = false
@@ -982,6 +990,8 @@ function openMainWindow(): BrowserWindow {
{
getAdditionalAiVaultCodexHomePaths: () =>
codexRuntimeHome ? [codexRuntimeHome.getHostRuntimeHomePath()] : [],
+ resolveAiVaultClaudeProjectsDirs: (target) =>
+ claudeRuntimeAuth!.resolveSessionProjectRoots(target),
onBeforeRelaunch: async () => {
isQuitting = true
desktopRelayService?.fenceAndCloseNow()
@@ -1837,11 +1847,11 @@ app.whenReady().then(async () => {
// Why: hook-reported agent status is the same source the desktop sidebar
// reads. worktree.ps pulls it at query time so mobile shows the same agents.
getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot(),
- // Why: source codex-home here (runs in BOTH window and serve modes) so the
- // aiVault.listSessions RPC includes managed-Codex sessions on remote/SSH
- // hosts; the window-only registerCoreHandlers path never runs under serve.
+ // Why: Claude and Codex history roots must be wired before either desktop or serve mode starts.
getAdditionalAiVaultCodexHomePaths: () =>
codexRuntimeHome ? [codexRuntimeHome.getHostRuntimeHomePath()] : [],
+ resolveAiVaultClaudeProjectsDirs: (target) =>
+ claudeRuntimeAuth!.resolveSessionProjectRoots(target),
buildAgentHookPtyEnv: () =>
isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {}
})
@@ -2218,6 +2228,29 @@ app.whenReady().then(async () => {
return
}
+ try {
+ spoolDesktop = createSpoolDesktopComposition({
+ store,
+ runtime: runtimeService,
+ rateLimits,
+ userDataPath: getCanonicalUserDataPath(),
+ profileId: activeOrcaProfile.profile.id,
+ ownerRuntimeId: runtimeService.getRuntimeId(),
+ orcaVersion: app.getVersion(),
+ isPackaged: app.isPackaged,
+ executablePath: process.execPath,
+ osFamily:
+ process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : 'linux'
+ })
+ unregisterSpoolSharingHandlers = registerSpoolSharingHandlers(spoolDesktop.service)
+ } catch (error) {
+ // Why: corrupt sharing state or a missing platform dependency disables only Spool.
+ console.error('[spool] Failed to compose Desktop sharing:', error)
+ unregisterSpoolSharingHandlers = registerSpoolSharingHandlers(
+ new SpoolUnavailableDesktopService()
+ )
+ }
+
// Why: window creation and runtime RPC startup are independent. Local PTY
// spawns are gated inside registerPtyHandlers so RPC can bind immediately
// without racing the daemon provider swap.
@@ -2225,7 +2258,8 @@ app.whenReady().then(async () => {
Promise.resolve(openMainWindow()),
runtimeRpc.start().catch((error) => {
console.error('[runtime] Failed to start local RPC transport:', error)
- })
+ }),
+ spoolDesktop?.start()
])
const cloudAuth = getOrcaCloudAuthConfig()
@@ -2346,6 +2380,8 @@ app.on('will-quit', (e) => {
// app.quit() re-fires will-quit, but the second pass skips straight through.
if (!daemonDisconnectDone) {
e.preventDefault()
+ unregisterSpoolSharingHandlers?.()
+ unregisterSpoolSharingHandlers = null
// Why: capture ownership synchronously (before any await) so the guard
// still has the right pid/runtimeId to compare against if shutdown
// partially clears global state. Evaluating these inside .then() would
@@ -2373,6 +2409,11 @@ app.on('will-quit', (e) => {
console.error('[runtime] Failed to stop local RPC transport:', error)
})
: Promise.resolve()
+ const spoolStop = spoolDesktop
+ ? spoolDesktop.stop().catch((error) => {
+ console.error('[spool] Failed to stop Desktop sharing:', error)
+ })
+ : Promise.resolve()
// Why: Promise.allSettled — we need BOTH the daemon disconnect and the
// RPC stop + owned-metadata clear to complete before Electron exits.
// Using allSettled (not all) preserves the existing fail-open posture:
@@ -2386,7 +2427,13 @@ app.on('will-quit', (e) => {
// Why: normal quits preserve the detached daemon for warm reattach, but a
// dev parent dying means the temp/dev profile has no owner left to reattach.
const daemonTeardown = isDevParentShutdownRequested() ? shutdownDaemon() : disconnectDaemon()
- Promise.allSettled([daemonTeardown, rpcStopAndClear, watcherShutdown, emulatorShutdown])
+ Promise.allSettled([
+ daemonTeardown,
+ rpcStopAndClear,
+ spoolStop,
+ watcherShutdown,
+ emulatorShutdown
+ ])
.then(() => shutdownTelemetry())
.then(() => shutdownObservability())
.catch(() => {
diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts
index d04a84a506e..0896fe544d8 100644
--- a/src/main/ipc/pty.ts
+++ b/src/main/ipc/pty.ts
@@ -426,6 +426,80 @@ function getRelayPtyId(connectionId: string | null | undefined, ptyId: string):
return connectionId ? toRelaySshPtyId(connectionId, ptyId) : ptyId
}
+function normalizePtyWorktreeInstanceId(value: string | null | undefined): string | null {
+ const trimmed = value?.trim()
+ return trimmed && trimmed.length <= 512 && !trimmed.includes('\0') ? trimmed : null
+}
+
+function readPersistedPtyWorktreeInstanceId(
+ store: Store,
+ binding: {
+ worktreeId: string
+ tabId?: string
+ leafId?: string | null
+ ptyId: string
+ connectionId?: string | null
+ }
+): string | null {
+ const tab = binding.tabId
+ ? store
+ .getWorkspaceSession()
+ .tabsByWorktree[binding.worktreeId]?.find((candidate) => candidate.id === binding.tabId)
+ : undefined
+ const layoutPtyId =
+ tab && binding.leafId
+ ? store.getWorkspaceSession().terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId?.[binding.leafId]
+ : undefined
+ const tabInstanceId =
+ tab && (tab.ptyId === binding.ptyId || layoutPtyId === binding.ptyId)
+ ? normalizePtyWorktreeInstanceId(tab.worktreeInstanceId)
+ : null
+ const relayPtyId = getRelayPtyId(binding.connectionId, binding.ptyId)
+ const lease = binding.connectionId
+ ? store
+ .getSshRemotePtyLeases(binding.connectionId)
+ .find(
+ (candidate) =>
+ candidate.ptyId === relayPtyId &&
+ candidate.worktreeId === binding.worktreeId &&
+ (binding.tabId === undefined || candidate.tabId === binding.tabId) &&
+ (binding.leafId == null || candidate.leafId === binding.leafId)
+ )
+ : undefined
+ const leaseInstanceId = normalizePtyWorktreeInstanceId(lease?.worktreeInstanceId)
+ if (tabInstanceId && leaseInstanceId && tabInstanceId !== leaseInstanceId) {
+ return null
+ }
+ return tabInstanceId ?? leaseInstanceId
+}
+
+function resolveSpawnPtyWorktreeInstanceId(
+ store: Store | undefined,
+ binding: {
+ worktreeId?: string
+ tabId?: string
+ leafId?: string | null
+ ptyId: string
+ connectionId?: string | null
+ isReattach: boolean
+ }
+): string | null {
+ if (!store || !binding.worktreeId) {
+ return null
+ }
+ if (!binding.isReattach) {
+ return normalizePtyWorktreeInstanceId(store.getWorktreeMeta(binding.worktreeId)?.instanceId)
+ }
+ // Why: a surviving PTY must keep its original instance instead of inheriting a reused path.
+ return readPersistedPtyWorktreeInstanceId(store, {
+ worktreeId: binding.worktreeId,
+ tabId: binding.tabId,
+ leafId: binding.leafId,
+ ptyId: binding.ptyId,
+ connectionId: binding.connectionId
+ })
+}
+
function stripRemotePaneEnvWhenHooksDisabled(
connectionId: string | null | undefined,
env: Record | undefined
@@ -3241,6 +3315,14 @@ export function registerPtyHandlers(
trustedTerminalHandleEnv.delete(args.preAllocatedHandle)
}
}
+ const worktreeInstanceId = resolveSpawnPtyWorktreeInstanceId(store, {
+ ...(args.worktreeId ? { worktreeId: args.worktreeId } : {}),
+ ...(args.tabId ? { tabId: args.tabId } : {}),
+ ...(metadataLeafId ? { leafId: metadataLeafId } : {}),
+ ptyId: result.id,
+ connectionId: args.connectionId,
+ isReattach: result.isReattach === true
+ })
ptyOwnership.set(result.id, args.connectionId ?? null)
// Why: Phase-5 ConPTY DA1 — record the native-Windows-local-PTY
// determination from the spawn record before any byte reaches the
@@ -3265,6 +3347,7 @@ export function registerPtyHandlers(
targetId: args.connectionId,
ptyId: relayResultId,
...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}),
+ worktreeInstanceId,
...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
...(typeof args.leafId === 'string' && isTerminalLeafId(args.leafId)
? { leafId: args.leafId }
@@ -3284,6 +3367,7 @@ export function registerPtyHandlers(
try {
hostSessionBinding.store.persistPtyBinding({
worktreeId: hostSessionBinding.worktreeId,
+ worktreeInstanceId,
tabId: hostSessionBinding.tabId,
leafId: hostSessionBinding.leafId,
ptyId: result.id,
@@ -3323,7 +3407,8 @@ export function registerPtyHandlers(
: undefined,
!args.connectionId
? shouldSkipCodexHomeEnvForWindowsShell(daemonShellOverride, cwd)
- : undefined
+ : undefined,
+ worktreeInstanceId
)
}
// Why: arms main's per-PTY Command Code output detector from the launch
@@ -4234,6 +4319,14 @@ export function registerPtyHandlers(
trustedTerminalHandleEnv.delete(preAllocatedHandle)
}
}
+ const worktreeInstanceId = resolveSpawnPtyWorktreeInstanceId(store, {
+ ...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}),
+ ...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
+ ...(validatedLeafId ? { leafId: validatedLeafId } : {}),
+ ptyId: result.id,
+ connectionId: args.connectionId,
+ isReattach: result.isReattach === true
+ })
spawnTiming.log(result.id, {
daemon: isDaemonHostSpawn,
reattach: result.isReattach ?? false
@@ -4290,6 +4383,7 @@ export function registerPtyHandlers(
targetId: args.connectionId,
ptyId: relayResultId,
...(typeof args.worktreeId === 'string' ? { worktreeId: args.worktreeId } : {}),
+ worktreeInstanceId,
...(typeof args.tabId === 'string' ? { tabId: args.tabId } : {}),
...(validatedLeafId ? { leafId: validatedLeafId } : {}),
state: 'attached',
@@ -4316,6 +4410,7 @@ export function registerPtyHandlers(
try {
store.persistPtyBinding({
worktreeId: args.worktreeId,
+ worktreeInstanceId,
tabId: args.tabId,
leafId: validatedLeafId,
ptyId: result.id,
@@ -4424,7 +4519,8 @@ export function registerPtyHandlers(
: undefined,
!args.connectionId
? shouldSkipCodexHomeEnvForWindowsShell(effectiveShellOverride, cwd)
- : undefined
+ : undefined,
+ worktreeInstanceId
)
}
// Why: arms main's per-PTY Command Code output detector from the launch
diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts
index cd2dab472b7..54e10ea5065 100644
--- a/src/main/ipc/register-core-handlers.ts
+++ b/src/main/ipc/register-core-handlers.ts
@@ -73,6 +73,7 @@ import type { AutomationService } from '../automations/service'
import type { AgentAwakeService } from '../agent-awake-service'
import type { CrashReportStore } from '../crash-reporting/crash-report-store'
import type { KeybindingService } from '../keybindings/keybinding-service'
+import type { AiVaultSessionRuntimeTarget } from '../ai-vault/session-root-configuration'
import {
getSavedRuntimeAiVaultHostInfos,
scanRuntimeAiVaultSessions
@@ -85,6 +86,9 @@ type CoreHandlerLifecycleOptions = {
onOrcaProfileAuthMutation?: () => void
onBeforeOrcaProfileSignOut?: () => void
getAdditionalAiVaultCodexHomePaths?: () => readonly string[]
+ resolveAiVaultClaudeProjectsDirs?: (
+ target: AiVaultSessionRuntimeTarget
+ ) => Promise
}
export function registerCoreHandlers(
@@ -188,6 +192,7 @@ export function registerCoreHandlers(
registerEphemeralVmHandlers(store)
registerAiVaultHandlers({
getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths,
+ resolveClaudeProjectsDirs: lifecycleOptions.resolveAiVaultClaudeProjectsDirs,
getActiveRuntimeAiVaultHostInfos: () =>
getSavedRuntimeAiVaultHostInfos(app.getPath('userData')),
scanRuntimeAiVaultSessions: async (environmentId, args, options) =>
diff --git a/src/main/ipc/runtime-environment-existing-route.ts b/src/main/ipc/runtime-environment-existing-route.ts
new file mode 100644
index 00000000000..1a8f1cedd74
--- /dev/null
+++ b/src/main/ipc/runtime-environment-existing-route.ts
@@ -0,0 +1,117 @@
+import { getPreferredPairingOffer } from '../../shared/runtime-environments'
+import { markEnvironmentUsed, resolveEnvironment } from '../../shared/runtime-environment-store'
+import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client'
+import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope'
+import { enqueueRuntimeCall } from './runtime-environment-call-queue'
+import {
+ sendRemoteRuntimeExistingSharedControlRequest,
+ subscribeRemoteRuntimeExistingSharedControlRequest,
+ subscribeRemoteRuntimeRetainedExistingSharedControlRequest
+} from './runtime-environment-request-connections'
+
+const DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS = 15_000
+
+type RuntimeEnvironmentRouteEvent =
+ | { type: 'response'; response: RuntimeRpcResponse }
+ | { type: 'binary'; bytes: Uint8Array }
+ | { type: 'error'; code: string; message: string }
+ | { type: 'close' }
+
+type RuntimeEnvironmentRouteCallbacks = {
+ onEvent: (payload: RuntimeEnvironmentRouteEvent) => void
+ onClose: () => void
+}
+
+export async function callRuntimeEnvironmentExistingRoute(
+ userDataPath: string,
+ selector: string,
+ method: string,
+ params: unknown,
+ timeoutMs = DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS,
+ options: { beforeSend?: () => void | Promise; signal?: AbortSignal } = {}
+): Promise> {
+ const environment = resolveEnvironment(userDataPath, selector)
+ return enqueueRuntimeCall(environment.id, method, async () => {
+ const currentEnvironment = resolveEnvironment(userDataPath, environment.id)
+ const pairing = getPreferredPairingOffer(currentEnvironment)
+ const response = await sendRemoteRuntimeExistingSharedControlRequest(
+ currentEnvironment.id,
+ pairing,
+ method,
+ params,
+ timeoutMs,
+ options
+ )
+ if (response.ok) {
+ markEnvironmentUsed(userDataPath, currentEnvironment.id, {
+ runtimeId: response._meta.runtimeId
+ })
+ }
+ return response
+ })
+}
+
+export async function subscribeRuntimeEnvironmentExistingRoute(
+ userDataPath: string,
+ selector: string,
+ method: string,
+ params: unknown,
+ callbacks: RuntimeEnvironmentRouteCallbacks
+): Promise {
+ return subscribeRuntimeEnvironmentRoute(
+ subscribeRemoteRuntimeExistingSharedControlRequest,
+ userDataPath,
+ selector,
+ method,
+ params,
+ callbacks
+ )
+}
+
+/** Retains an owner-authorized ready route across transport recovery without opening it initially. */
+export async function subscribeRuntimeEnvironmentRetainedExistingRoute(
+ userDataPath: string,
+ selector: string,
+ method: string,
+ params: unknown,
+ callbacks: RuntimeEnvironmentRouteCallbacks
+): Promise {
+ return subscribeRuntimeEnvironmentRoute(
+ subscribeRemoteRuntimeRetainedExistingSharedControlRequest,
+ userDataPath,
+ selector,
+ method,
+ params,
+ callbacks
+ )
+}
+
+function subscribeRuntimeEnvironmentRoute(
+ subscribe: typeof subscribeRemoteRuntimeExistingSharedControlRequest,
+ userDataPath: string,
+ selector: string,
+ method: string,
+ params: unknown,
+ callbacks: RuntimeEnvironmentRouteCallbacks
+): Promise {
+ const environment = resolveEnvironment(userDataPath, selector)
+ const pairing = getPreferredPairingOffer(environment)
+ let markedUsed = false
+ return subscribe(environment.id, pairing, method, params, {
+ onResponse: (response) => {
+ if (response.ok && !markedUsed) {
+ markedUsed = true
+ markEnvironmentUsed(userDataPath, environment.id, {
+ runtimeId: response._meta.runtimeId
+ })
+ }
+ callbacks.onEvent({ type: 'response', response })
+ },
+ onBinary: (bytes) => callbacks.onEvent({ type: 'binary', bytes }),
+ onError: (error) => callbacks.onEvent({ type: 'error', ...error }),
+ onClose: () => {
+ callbacks.onEvent({ type: 'close' })
+ callbacks.onClose()
+ }
+ })
+}
diff --git a/src/main/ipc/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environment-request-connections.ts
index 6b226813e26..78292ccef01 100644
--- a/src/main/ipc/runtime-environment-request-connections.ts
+++ b/src/main/ipc/runtime-environment-request-connections.ts
@@ -6,6 +6,7 @@ import type {
RemoteRuntimeSharedConnectionDiagnostics,
RemoteRuntimeSharedSubscription
} from '../../shared/remote-runtime-shared-control-types'
+import { remoteRuntimeUnavailableError } from '../../shared/remote-runtime-request-frames'
type CachedRuntimeConnection = {
pairingKey: string
@@ -25,7 +26,8 @@ export function sendRemoteRuntimeConnectionRequest(
pairing: PairingOffer,
method: string,
params: unknown,
- timeoutMs: number
+ timeoutMs: number,
+ options: { beforeSend?: () => void | Promise; signal?: AbortSignal } = {}
): Promise> {
const pairingKey = getPairingKey(pairing)
let cached = requestConnections.get(environmentId)
@@ -37,7 +39,7 @@ export function sendRemoteRuntimeConnectionRequest(
}
requestConnections.set(environmentId, cached)
}
- return cached.connection.request(method, params, timeoutMs)
+ return cached.connection.request(method, params, timeoutMs, options)
}
export function closeRemoteRuntimeRequestConnection(environmentId: string): void {
@@ -61,9 +63,29 @@ export function sendRemoteRuntimeSharedControlRequest(
pairing: PairingOffer,
method: string,
params: unknown,
- timeoutMs: number
+ timeoutMs: number,
+ options: { beforeSend?: () => void | Promise; signal?: AbortSignal } = {}
): Promise> {
- return getSharedControlConnection(environmentId, pairing).request(method, params, timeoutMs)
+ return getSharedControlConnection(environmentId, pairing).request(
+ method,
+ params,
+ timeoutMs,
+ options
+ )
+}
+
+export function sendRemoteRuntimeExistingSharedControlRequest(
+ environmentId: string,
+ pairing: PairingOffer,
+ method: string,
+ params: unknown,
+ timeoutMs: number,
+ options: { beforeSend?: () => void | Promise; signal?: AbortSignal } = {}
+): Promise> {
+ const connection = getExistingSharedControlConnection(environmentId, pairing)
+ return connection
+ ? connection.existingRoute.request(method, params, timeoutMs, options)
+ : Promise.reject(remoteRuntimeUnavailableError())
}
export function subscribeRemoteRuntimeSharedControlRequest(
@@ -87,6 +109,42 @@ export function subscribeRemoteRuntimeSharedControlRequest(
)
}
+export function subscribeRemoteRuntimeExistingSharedControlRequest(
+ environmentId: string,
+ pairing: PairingOffer,
+ method: string,
+ params: unknown,
+ callbacks: {
+ onResponse: (response: RuntimeRpcResponse) => void
+ onBinary?: (bytes: Uint8Array) => void
+ onError: (error: { code: string; message: string }) => void
+ onClose?: () => void
+ }
+): Promise {
+ const connection = getExistingSharedControlConnection(environmentId, pairing)
+ return connection
+ ? connection.existingRoute.subscribe(method, params, callbacks)
+ : Promise.reject(remoteRuntimeUnavailableError())
+}
+
+export function subscribeRemoteRuntimeRetainedExistingSharedControlRequest(
+ environmentId: string,
+ pairing: PairingOffer,
+ method: string,
+ params: unknown,
+ callbacks: {
+ onResponse: (response: RuntimeRpcResponse) => void
+ onBinary?: (bytes: Uint8Array) => void
+ onError: (error: { code: string; message: string }) => void
+ onClose?: () => void
+ }
+): Promise {
+ const connection = getExistingSharedControlConnection(environmentId, pairing)
+ return connection
+ ? connection.existingRoute.subscribeRetained(method, params, callbacks)
+ : Promise.reject(remoteRuntimeUnavailableError())
+}
+
export function closeRemoteRuntimeSharedControlConnection(environmentId: string): void {
const cached = sharedControlConnections.get(environmentId)
sharedControlConnections.delete(environmentId)
@@ -116,6 +174,14 @@ function getSharedControlConnection(
return cached.connection
}
+function getExistingSharedControlConnection(
+ environmentId: string,
+ pairing: PairingOffer
+): RemoteRuntimeSharedControlConnection | null {
+ const cached = sharedControlConnections.get(environmentId)
+ return cached?.pairingKey === getPairingKey(pairing) ? cached.connection : null
+}
+
function getPairingKey(pairing: PairingOffer): string {
return [pairing.endpoint, pairing.deviceToken, pairing.publicKeyB64].join('\0')
}
diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts
index 0b4b268a2a3..baaa2b8a00a 100644
--- a/src/main/ipc/runtime-environment-transport-routing.ts
+++ b/src/main/ipc/runtime-environment-transport-routing.ts
@@ -97,7 +97,8 @@ export async function callRuntimeEnvironment(
selector: string,
method: string,
params: unknown,
- timeoutMs?: number
+ timeoutMs?: number,
+ options: { beforeSend?: () => void | Promise } = {}
): Promise> {
const environment = resolveEnvironment(userDataPath, selector)
// Why: connection failures reject (they don't resolve as ok:false), so the
@@ -112,14 +113,10 @@ export async function callRuntimeEnvironment(
const pairing = getPreferredPairingOffer(currentEnvironment)
endpoint = pairing.endpoint
const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS
+ const runtimeRequest = [pairing, method, params, effectiveTimeoutMs] as const
+ const connectionRequest = [currentEnvironment.id, ...runtimeRequest] as const
if (shouldUseCachedRequestConnection(method)) {
- const response = await sendRemoteRuntimeConnectionRequest(
- currentEnvironment.id,
- pairing,
- method,
- params,
- effectiveTimeoutMs
- )
+ const response = await sendRemoteRuntimeConnectionRequest(...connectionRequest, options)
markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response)
return response
}
@@ -127,19 +124,13 @@ export async function callRuntimeEnvironment(
method !== 'status.get' &&
(await supportsSharedControl(userDataPath, currentEnvironment, pairing, effectiveTimeoutMs))
) {
- const response = await sendRemoteRuntimeSharedControlRequest(
- currentEnvironment.id,
- pairing,
- method,
- params,
- effectiveTimeoutMs
- )
+ const response = await sendRemoteRuntimeSharedControlRequest(...connectionRequest, options)
markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response)
return response
}
// Why: startup/control-plane RPCs use the proven one-shot path so repo
// hydration cannot be coupled to a stale terminal-control connection.
- const response = await sendRemoteRuntimeRequest(pairing, method, params, effectiveTimeoutMs)
+ const response = await sendRemoteRuntimeRequest(...runtimeRequest, options)
markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response)
return response
})
diff --git a/src/main/ipc/spool-requester-subscriptions.ts b/src/main/ipc/spool-requester-subscriptions.ts
new file mode 100644
index 00000000000..4b5a3b3266b
--- /dev/null
+++ b/src/main/ipc/spool-requester-subscriptions.ts
@@ -0,0 +1,188 @@
+import type { WebContents } from 'electron'
+import {
+ isSpoolRequesterTransportErrorCode,
+ type SpoolRequesterSubscriptionArgs,
+ type SpoolRequesterSubscriptionEvent,
+ type SpoolRequesterSubscriptionStartResult,
+ type SpoolRequesterSubscriptionStopResult,
+ type SpoolRequesterTransportErrorCode
+} from '../../shared/spool/spool-ipc-contract'
+
+const SPOOL_REQUESTER_SUBSCRIPTION_EVENT_CHANNEL = 'spoolSharing:subscriptionEvent'
+
+export type SpoolSharingIpcSubscription = {
+ close(): void
+}
+
+export type SpoolSharingIpcSubscriptionSink = {
+ next(value: unknown): void
+ error(error: Error): void
+ complete(): void
+}
+
+export type SpoolRequesterSubscriptionController = {
+ subscribeRequester(
+ args: SpoolRequesterSubscriptionArgs,
+ sink: SpoolSharingIpcSubscriptionSink
+ ): SpoolSharingIpcSubscription
+}
+
+type RequesterSubscriptionOwner = {
+ sender: WebContents
+ subscriptionIds: Set
+ destroyedListener: () => void
+}
+
+type RetainedRequesterSubscription = {
+ owner: RequesterSubscriptionOwner
+ close(): void
+}
+
+/** Owns renderer stream lifetimes without exposing requester sockets to preload. */
+export class SpoolRequesterIpcSubscriptions {
+ private readonly subscriptions = new Map()
+ private readonly owners = new Map()
+
+ constructor(private readonly controller: SpoolRequesterSubscriptionController) {}
+
+ start(
+ sender: WebContents,
+ args: SpoolRequesterSubscriptionArgs
+ ): SpoolRequesterSubscriptionStartResult {
+ const subscriptionId = args.subscriptionId
+ if (this.subscriptions.has(subscriptionId)) {
+ throw new Error('resource_busy')
+ }
+ const owner = this.getOwner(sender)
+ if (owner.subscriptionIds.has(subscriptionId)) {
+ throw new Error('resource_busy')
+ }
+ let downstream: SpoolSharingIpcSubscription | null = null
+ let synchronousFailure: SpoolRequesterTransportErrorCode | null = null
+ const retained: RetainedRequesterSubscription = {
+ owner,
+ close: () => downstream?.close()
+ }
+ this.subscriptions.set(subscriptionId, retained)
+ owner.subscriptionIds.add(subscriptionId)
+ try {
+ downstream = this.controller.subscribeRequester(args, {
+ next: (value) => {
+ if (this.subscriptions.get(subscriptionId) === retained) {
+ this.send(retained, { subscriptionId, type: 'next', value })
+ }
+ },
+ error: (error) => {
+ synchronousFailure = projectSpoolRequesterTransportError(error)
+ if (this.subscriptions.get(subscriptionId) === retained) {
+ this.send(retained, {
+ subscriptionId,
+ type: 'error',
+ code: synchronousFailure
+ })
+ this.release(subscriptionId)
+ }
+ },
+ complete: () => {
+ if (this.subscriptions.get(subscriptionId) === retained) {
+ this.send(retained, { subscriptionId, type: 'complete' })
+ this.release(subscriptionId)
+ }
+ }
+ })
+ } catch (error) {
+ this.release(subscriptionId)
+ throw spoolRequesterTransportError(error)
+ }
+ if (this.subscriptions.get(subscriptionId) !== retained) {
+ downstream.close()
+ throw new Error(synchronousFailure ?? 'resource_unavailable')
+ }
+ return { subscriptionId }
+ }
+
+ stop(senderId: number, subscriptionId: string): SpoolRequesterSubscriptionStopResult {
+ const retained = this.subscriptions.get(subscriptionId)
+ return {
+ stopped: retained?.owner.sender.id === senderId ? this.release(subscriptionId) : false
+ }
+ }
+
+ close(): void {
+ for (const subscriptionId of this.subscriptions.keys()) {
+ this.release(subscriptionId)
+ }
+ for (const owner of this.owners.values()) {
+ if (!owner.sender.isDestroyed()) {
+ owner.sender.removeListener('destroyed', owner.destroyedListener)
+ }
+ }
+ this.owners.clear()
+ }
+
+ private release(subscriptionId: string): boolean {
+ const retained = this.subscriptions.get(subscriptionId)
+ if (!retained) {
+ return false
+ }
+ this.subscriptions.delete(subscriptionId)
+ retained.owner.subscriptionIds.delete(subscriptionId)
+ retained.close()
+ this.releaseOwnerIfIdle(retained.owner)
+ return true
+ }
+
+ private releaseOwnerIfIdle(owner: RequesterSubscriptionOwner): void {
+ if (owner.subscriptionIds.size > 0) {
+ return
+ }
+ this.owners.delete(owner.sender.id)
+ if (!owner.sender.isDestroyed()) {
+ owner.sender.removeListener('destroyed', owner.destroyedListener)
+ }
+ }
+
+ private closeOwnerSubscriptions(ownerWebContentsId: number): void {
+ const owner = this.owners.get(ownerWebContentsId)
+ if (!owner) {
+ return
+ }
+ for (const subscriptionId of owner.subscriptionIds) {
+ this.release(subscriptionId)
+ }
+ this.owners.delete(ownerWebContentsId)
+ }
+
+ private getOwner(sender: WebContents): RequesterSubscriptionOwner {
+ const existing = this.owners.get(sender.id)
+ if (existing) {
+ return existing
+ }
+ const owner: RequesterSubscriptionOwner = {
+ sender,
+ subscriptionIds: new Set(),
+ destroyedListener: () => this.closeOwnerSubscriptions(sender.id)
+ }
+ this.owners.set(sender.id, owner)
+ sender.once('destroyed', owner.destroyedListener)
+ return owner
+ }
+
+ private send(
+ retained: RetainedRequesterSubscription,
+ event: SpoolRequesterSubscriptionEvent
+ ): void {
+ if (!retained.owner.sender.isDestroyed()) {
+ retained.owner.sender.send(SPOOL_REQUESTER_SUBSCRIPTION_EVENT_CHANNEL, event)
+ }
+ }
+}
+
+export function spoolRequesterTransportError(error: unknown): Error {
+ return new Error(projectSpoolRequesterTransportError(error))
+}
+
+function projectSpoolRequesterTransportError(error: unknown): SpoolRequesterTransportErrorCode {
+ const candidate = error instanceof Error ? error.message : ''
+ return isSpoolRequesterTransportErrorCode(candidate) ? candidate : 'internal_error'
+}
diff --git a/src/main/ipc/spool-sharing.ts b/src/main/ipc/spool-sharing.ts
new file mode 100644
index 00000000000..b3b553a04b4
--- /dev/null
+++ b/src/main/ipc/spool-sharing.ts
@@ -0,0 +1,270 @@
+import { BrowserWindow, ipcMain, type IpcMainInvokeEvent } from 'electron'
+import type {
+ SpoolDecideControlArgs,
+ SpoolRequestControlArgs,
+ SpoolRequesterInvokeArgs,
+ SpoolRequesterSubscriptionArgs,
+ SpoolRequesterSubscriptionStopArgs,
+ SpoolRevokeControlArgs,
+ SpoolSetProjectVisibilityArgs,
+ SpoolSetWorktreeVisibilityArgs,
+ SpoolSharingSnapshot
+} from '../../shared/spool/spool-ipc-contract'
+import type {
+ SpoolWindowsFirewallRepairResult,
+ SpoolWindowsFirewallStatus
+} from '../../shared/spool/spool-windows-firewall-contract'
+import {
+ isSpoolRequesterInvokeMethod,
+ isSpoolRequesterSubscriptionMethod
+} from '../../shared/spool/spool-ipc-contract'
+import {
+ SpoolRequesterIpcSubscriptions,
+ spoolRequesterTransportError,
+ type SpoolSharingIpcSubscription,
+ type SpoolSharingIpcSubscriptionSink
+} from './spool-requester-subscriptions'
+
+const SPOOL_SHARING_CHANGED_CHANNEL = 'spoolSharing:changed'
+const SPOOL_SUBSCRIPTION_ID_PATTERN =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
+
+export type SpoolSharingIpcController = {
+ snapshot(): SpoolSharingSnapshot
+ subscribe(listener: (snapshot: SpoolSharingSnapshot) => void): () => void
+ setWorktreeVisibility(args: SpoolSetWorktreeVisibilityArgs): Promise
+ setProjectVisibility(args: SpoolSetProjectVisibilityArgs): Promise
+ requestControl(args: SpoolRequestControlArgs): Promise
+ decideControl(args: SpoolDecideControlArgs): Promise
+ revokeControl(args: SpoolRevokeControlArgs): Promise
+ getWindowsFirewallStatus(): Promise
+ repairWindowsFirewall(): Promise
+ retryAvailability(): Promise
+ invokeRequester(args: SpoolRequesterInvokeArgs): Promise
+ subscribeRequester(
+ args: SpoolRequesterSubscriptionArgs,
+ sink: SpoolSharingIpcSubscriptionSink
+ ): SpoolSharingIpcSubscription
+}
+
+export function registerSpoolSharingHandlers(controller: SpoolSharingIpcController): () => void {
+ const requesterSubscriptions = new SpoolRequesterIpcSubscriptions(controller)
+
+ ipcMain.handle('spoolSharing:getSnapshot', (event) => {
+ requireWindowRenderer(event)
+ return controller.snapshot()
+ })
+ ipcMain.handle('spoolSharing:setWorktreeVisibility', (event, value: unknown) => {
+ requireWindowRenderer(event)
+ return controller.setWorktreeVisibility(readVisibilityArgs(value, 'worktreeId'))
+ })
+ ipcMain.handle('spoolSharing:setProjectVisibility', (event, value: unknown) => {
+ requireWindowRenderer(event)
+ return controller.setProjectVisibility(readVisibilityArgs(value, 'projectId'))
+ })
+ ipcMain.handle('spoolSharing:requestControl', (event, value: unknown) => {
+ requireWindowRenderer(event)
+ return controller.requestControl(readRequestControlArgs(value))
+ })
+ ipcMain.handle('spoolSharing:decideControl', (event, value: unknown) => {
+ requireWindowRenderer(event)
+ return controller.decideControl(readDecisionArgs(value))
+ })
+ ipcMain.handle('spoolSharing:revokeControl', (event, value: unknown) => {
+ requireWindowRenderer(event)
+ return controller.revokeControl({ grantId: readIdentifier(value, 'grantId') })
+ })
+ ipcMain.handle('spoolSharing:getWindowsFirewallStatus', (event, ...values: unknown[]) => {
+ requireWindowRenderer(event)
+ requireNoArguments(values)
+ return controller.getWindowsFirewallStatus()
+ })
+ ipcMain.handle('spoolSharing:repairWindowsFirewall', (event, ...values: unknown[]) => {
+ requireWindowRenderer(event)
+ requireNoArguments(values)
+ return controller.repairWindowsFirewall()
+ })
+ ipcMain.handle('spoolSharing:retryAvailability', (event, ...values: unknown[]) => {
+ requireWindowRenderer(event)
+ requireNoArguments(values)
+ return controller.retryAvailability()
+ })
+ ipcMain.handle('spoolSharing:invoke', async (event, value: unknown): Promise => {
+ requireWindowRenderer(event)
+ try {
+ return await controller.invokeRequester(readRequesterInvokeArgs(value))
+ } catch (error) {
+ throw spoolRequesterTransportError(error)
+ }
+ })
+ ipcMain.handle('spoolSharing:startSubscription', (event, value: unknown) => {
+ requireWindowRenderer(event)
+ try {
+ return requesterSubscriptions.start(event.sender, readRequesterSubscriptionArgs(value))
+ } catch (error) {
+ throw spoolRequesterTransportError(error)
+ }
+ })
+ ipcMain.handle('spoolSharing:stopSubscription', (event, value: unknown) => {
+ requireWindowRenderer(event)
+ const args = readRequesterSubscriptionStopArgs(value)
+ return requesterSubscriptions.stop(event.sender.id, args.subscriptionId)
+ })
+ const unsubscribe = controller.subscribe((snapshot) => {
+ for (const window of BrowserWindow.getAllWindows()) {
+ if (!window.isDestroyed()) {
+ window.webContents.send(SPOOL_SHARING_CHANGED_CHANNEL, snapshot)
+ }
+ }
+ })
+ return () => {
+ unsubscribe()
+ requesterSubscriptions.close()
+ for (const channel of SPOOL_HANDLER_CHANNELS) {
+ ipcMain.removeHandler(channel)
+ }
+ }
+}
+
+function requireWindowRenderer(event: IpcMainInvokeEvent): void {
+ if (event.sender.isDestroyed() || event.sender.getType() !== 'window') {
+ throw new Error('unauthorized')
+ }
+}
+
+const SPOOL_HANDLER_CHANNELS = [
+ 'spoolSharing:getSnapshot',
+ 'spoolSharing:setWorktreeVisibility',
+ 'spoolSharing:setProjectVisibility',
+ 'spoolSharing:requestControl',
+ 'spoolSharing:decideControl',
+ 'spoolSharing:revokeControl',
+ 'spoolSharing:getWindowsFirewallStatus',
+ 'spoolSharing:repairWindowsFirewall',
+ 'spoolSharing:retryAvailability',
+ 'spoolSharing:invoke',
+ 'spoolSharing:startSubscription',
+ 'spoolSharing:stopSubscription'
+] as const
+
+function requireNoArguments(values: readonly unknown[]): void {
+ if (values.length !== 0) {
+ throw new Error('invalid_spool_arguments')
+ }
+}
+
+function readVisibilityArgs(
+ value: unknown,
+ key: 'worktreeId' | 'projectId'
+): SpoolSetWorktreeVisibilityArgs & SpoolSetProjectVisibilityArgs {
+ const record = asRecord(value)
+ const visibility = record.visibility
+ if (visibility !== 'public' && visibility !== 'private') {
+ throw new Error('invalid_spool_visibility')
+ }
+ const identifier = readIdentifier(value, key)
+ return { worktreeId: identifier, projectId: identifier, visibility }
+}
+
+function readRequestControlArgs(value: unknown): SpoolRequestControlArgs {
+ return {
+ desktopRef: readIdentifier(value, 'desktopRef'),
+ worktreeRef: readIdentifier(value, 'worktreeRef')
+ }
+}
+
+function readDecisionArgs(value: unknown): SpoolDecideControlArgs {
+ const record = asRecord(value)
+ if (record.decision !== 'allow' && record.decision !== 'deny') {
+ throw new Error('invalid_spool_decision')
+ }
+ return { requestId: readIdentifier(value, 'requestId'), decision: record.decision }
+}
+
+function readRequesterInvokeArgs(value: unknown): SpoolRequesterInvokeArgs {
+ const record = asRecord(value)
+ const method = readMethod(record)
+ if (!isSpoolRequesterInvokeMethod(method)) {
+ throw new Error('method_not_found')
+ }
+ return {
+ desktopRef: readIdentifier(value, 'desktopRef'),
+ connectionEpoch: readConnectionEpoch(record.connectionEpoch),
+ method,
+ params: readOpaqueParams(record.params)
+ }
+}
+
+function readRequesterSubscriptionArgs(value: unknown): SpoolRequesterSubscriptionArgs {
+ const record = asRecord(value)
+ requireExactKeys(record, ['subscriptionId', 'desktopRef', 'connectionEpoch', 'method', 'params'])
+ const method = readMethod(record)
+ if (!isSpoolRequesterSubscriptionMethod(method)) {
+ throw new Error('method_not_found')
+ }
+ return {
+ subscriptionId: readSubscriptionId(record.subscriptionId),
+ desktopRef: readIdentifier(value, 'desktopRef'),
+ connectionEpoch: readConnectionEpoch(record.connectionEpoch),
+ method,
+ params: readOpaqueParams(record.params)
+ }
+}
+
+function readRequesterSubscriptionStopArgs(value: unknown): SpoolRequesterSubscriptionStopArgs {
+ const record = asRecord(value)
+ requireExactKeys(record, ['subscriptionId'])
+ return { subscriptionId: readSubscriptionId(record.subscriptionId) }
+}
+
+function readSubscriptionId(value: unknown): string {
+ if (typeof value !== 'string' || !SPOOL_SUBSCRIPTION_ID_PATTERN.test(value)) {
+ throw new Error('invalid_spool_subscription_id')
+ }
+ return value
+}
+
+function requireExactKeys(record: Record, expectedKeys: readonly string[]): void {
+ const keys = Object.keys(record)
+ const expected = new Set(expectedKeys)
+ if (keys.length !== expected.size || keys.some((key) => !expected.has(key))) {
+ throw new Error('invalid_spool_arguments')
+ }
+}
+
+function readMethod(record: Record): string {
+ const method = record.method
+ if (typeof method !== 'string' || method.length === 0 || method.length > 128) {
+ throw new Error('invalid_argument')
+ }
+ return method
+}
+
+function readConnectionEpoch(value: unknown): number {
+ if (!Number.isSafeInteger(value) || Number(value) < 0) {
+ throw new Error('invalid_argument')
+ }
+ return Number(value)
+}
+
+function readOpaqueParams(value: unknown): unknown {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ throw new Error('invalid_argument')
+ }
+ return value
+}
+
+function readIdentifier(value: unknown, key: string): string {
+ const identifier = asRecord(value)[key]
+ if (typeof identifier !== 'string' || identifier.length === 0 || identifier.length > 2048) {
+ throw new Error('invalid_spool_identifier')
+ }
+ return identifier
+}
+
+function asRecord(value: unknown): Record {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ throw new Error('invalid_spool_arguments')
+ }
+ return value as Record
+}
diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts
index affb1aba004..c6a8169239e 100644
--- a/src/main/ipc/ssh.ts
+++ b/src/main/ipc/ssh.ts
@@ -142,6 +142,11 @@ export function getActiveSshAiVaultHostInfo(targetId: string): SshRelayAiVaultHo
return activeSessions.get(targetId)?.getAiVaultHostInfo() ?? null
}
+/** Owner-only Spool lookup; runtime-owned targets stay hidden from every renderer API. */
+export function getActiveSshSpoolHostInfo(targetId: string): SshRelayAiVaultHostInfo | null {
+ return activeSessions.get(targetId)?.getAiVaultHostInfo() ?? null
+}
+
export function getActiveSshAiVaultHostInfos(): SshRelayAiVaultHostInfo[] {
return [...activeSessions.values()].flatMap((session) => {
if (isRuntimeOwnedSshTargetId(session.targetId)) {
diff --git a/src/main/persistence.ts b/src/main/persistence.ts
index d407b9362d3..27e08c2b038 100644
--- a/src/main/persistence.ts
+++ b/src/main/persistence.ts
@@ -1579,6 +1579,11 @@ function normalizeSshRemotePtyLease(value: unknown): SshRemotePtyLease | null {
targetId: raw.targetId,
ptyId: raw.ptyId,
...(typeof raw.worktreeId === 'string' ? { worktreeId: raw.worktreeId } : {}),
+ ...(typeof raw.worktreeInstanceId === 'string' &&
+ raw.worktreeInstanceId.trim() &&
+ raw.worktreeInstanceId.length <= 512
+ ? { worktreeInstanceId: raw.worktreeInstanceId }
+ : {}),
...(typeof raw.tabId === 'string' ? { tabId: raw.tabId } : {}),
...(typeof raw.leafId === 'string' && raw.leafId.length <= 256 ? { leafId: raw.leafId } : {}),
state,
@@ -2417,6 +2422,7 @@ function makeProjectHostSetupId(
function createMinimalPersistedTerminalTab(args: {
worktreeId: string
+ worktreeInstanceId?: string | null
tabId: string
ptyId: string
existingTabCount: number
@@ -2428,6 +2434,7 @@ function createMinimalPersistedTerminalTab(args: {
id: args.tabId,
ptyId: args.ptyId,
worktreeId: args.worktreeId,
+ ...(args.worktreeInstanceId ? { worktreeInstanceId: args.worktreeInstanceId } : {}),
title: defaultTitle,
defaultTitle,
customTitle: null,
@@ -2624,6 +2631,25 @@ export type StoreOptions = {
dataFile?: string
}
+type SpoolVisibilityCommitBase = {
+ worktreeId: string
+ expectedInstanceId: string
+}
+
+export type SpoolVisibilityCommitChange = SpoolVisibilityCommitBase &
+ (
+ | {
+ visibility: 'public'
+ spoolIncarnationId: string
+ nextInstanceId?: never
+ }
+ | {
+ visibility: 'private'
+ spoolIncarnationId?: string
+ nextInstanceId?: string
+ }
+ )
+
export class Store {
private state: PersistedState
private readonly dataFile: string
@@ -4963,6 +4989,95 @@ export class Store {
return updated
}
+ commitSpoolVisibility(changes: readonly SpoolVisibilityCommitChange[]): readonly WorktreeMeta[] {
+ if (changes.length === 0) {
+ return []
+ }
+ if (this.writesFrozen) {
+ throw new Error('spool_visibility_store_frozen')
+ }
+ const previousMeta = this.state.worktreeMeta
+ const previousWorktreeLineage = this.state.worktreeLineageById
+ const previousWorkspaceLineage = this.state.workspaceLineageByChildKey
+ const nextMeta = { ...previousMeta }
+ const nextWorktreeLineage = { ...previousWorktreeLineage }
+ const nextWorkspaceLineage = { ...previousWorkspaceLineage }
+ const committed: WorktreeMeta[] = []
+ const changedWorktreeIds = new Set()
+ const existingInstanceIds = new Set(
+ Object.values(previousMeta).flatMap((meta) => (meta.instanceId ? [meta.instanceId] : []))
+ )
+ const nextInstanceIds = new Set()
+
+ for (const change of changes) {
+ if (changedWorktreeIds.has(change.worktreeId)) {
+ throw new Error('spool_visibility_duplicate_change')
+ }
+ changedWorktreeIds.add(change.worktreeId)
+ const existing = nextMeta[change.worktreeId]
+ if (!existing || existing.instanceId !== change.expectedInstanceId) {
+ throw new Error('spool_visibility_stale_instance')
+ }
+ if (change.visibility === 'public' && !change.spoolIncarnationId?.trim()) {
+ throw new Error('spool_visibility_missing_incarnation')
+ }
+ if (
+ change.nextInstanceId !== undefined &&
+ (!change.nextInstanceId.trim() ||
+ existingInstanceIds.has(change.nextInstanceId) ||
+ nextInstanceIds.has(change.nextInstanceId))
+ ) {
+ throw new Error('spool_visibility_invalid_next_instance')
+ }
+ if (change.nextInstanceId) {
+ nextInstanceIds.add(change.nextInstanceId)
+ // Why: path reuse creates a new authorization identity; retaining
+ // lineage would let the replacement inherit provenance from the old instance.
+ for (const [worktreeId, lineage] of Object.entries(nextWorktreeLineage)) {
+ if (
+ lineage.worktreeInstanceId === change.expectedInstanceId ||
+ lineage.parentWorktreeInstanceId === change.expectedInstanceId
+ ) {
+ delete nextWorktreeLineage[worktreeId]
+ }
+ }
+ for (const [workspaceKey, lineage] of Object.entries(nextWorkspaceLineage)) {
+ if (
+ lineage.childInstanceId === change.expectedInstanceId ||
+ lineage.parentInstanceId === change.expectedInstanceId
+ ) {
+ delete nextWorkspaceLineage[workspaceKey as WorkspaceKey]
+ }
+ }
+ }
+ const updated: WorktreeMeta = {
+ ...existing,
+ spoolVisibility: change.visibility,
+ ...(change.spoolIncarnationId === undefined
+ ? {}
+ : { spoolIncarnationId: change.spoolIncarnationId }),
+ ...(change.nextInstanceId === undefined ? {} : { instanceId: change.nextInstanceId })
+ }
+ nextMeta[change.worktreeId] = updated
+ committed.push(updated)
+ }
+
+ this.state.worktreeMeta = nextMeta
+ this.state.worktreeLineageById = nextWorktreeLineage
+ this.state.workspaceLineageByChildKey = nextWorkspaceLineage
+ try {
+ // Why: Public/Private is an authorization boundary, so callers must not
+ // observe success before the complete batch is durably replaced on disk.
+ this.flushOrThrow()
+ return committed
+ } catch (error) {
+ this.state.worktreeMeta = previousMeta
+ this.state.worktreeLineageById = previousWorktreeLineage
+ this.state.workspaceLineageByChildKey = previousWorkspaceLineage
+ throw error
+ }
+ }
+
removeWorktreeMeta(worktreeId: string): void {
delete this.state.worktreeMeta[worktreeId]
delete this.state.worktreeLineageById[worktreeId]
@@ -5769,11 +5884,9 @@ export class Store {
continue
}
for (const tab of tabs) {
- if (tab.ptyId) {
- continue
- }
const priorTab = priorList.find((t) => t.id === tab.id)
if (
+ !tab.ptyId &&
priorTab?.ptyId &&
this.isRestorablePtyBinding({
ptyId: priorTab.ptyId,
@@ -5784,6 +5897,21 @@ export class Store {
) {
tab.ptyId = priorTab.ptyId
}
+ if (
+ !tab.worktreeInstanceId &&
+ priorTab?.worktreeInstanceId &&
+ priorTab.ptyId &&
+ tab.ptyId === priorTab.ptyId &&
+ this.isRestorablePtyBinding({
+ ptyId: priorTab.ptyId,
+ worktreeId,
+ targetId: this.getConnectionIdForWorktree(worktreeId),
+ tabId: tab.id
+ })
+ ) {
+ // Why: a stale renderer snapshot must not erase the spawn-time safety binding.
+ tab.worktreeInstanceId = priorTab.worktreeInstanceId
+ }
}
}
const priorLayouts = prior.terminalLayoutsByTabId ?? {}
@@ -6015,6 +6143,7 @@ export class Store {
// spawn-success without the binding already being durable on disk.
persistPtyBinding(args: {
worktreeId: string
+ worktreeInstanceId?: string | null
tabId: string
leafId: string
ptyId: string
@@ -6029,6 +6158,13 @@ export class Store {
const tab = tabs?.find((t) => t.id === args.tabId)
if (tab) {
tab.ptyId = args.ptyId
+ if (args.worktreeInstanceId !== undefined) {
+ if (args.worktreeInstanceId === null) {
+ delete tab.worktreeInstanceId
+ } else {
+ tab.worktreeInstanceId = args.worktreeInstanceId
+ }
+ }
} else {
// Why: pty:spawn can beat the debounced session writer for a newly
// created tab. Persist a minimal tab so hydration does not prune the
@@ -6357,11 +6493,13 @@ export class Store {
}
upsertSshRemotePtyLease(
- lease: Omit &
- Partial>
+ lease: Omit & {
+ worktreeInstanceId?: string | null
+ } & Partial>
): void {
this.state.sshRemotePtyLeases ??= []
- const normalizedLease = { ...lease }
+ const { worktreeInstanceId, ...normalizedLease } = lease
+ const clearWorktreeInstanceId = worktreeInstanceId === null
if (normalizedLease.leafId !== undefined && !isTerminalLeafId(normalizedLease.leafId)) {
delete normalizedLease.leafId
}
@@ -6380,9 +6518,14 @@ export class Store {
const next: SshRemotePtyLease = {
...existing,
...normalizedLease,
+ ...(worktreeInstanceId ? { worktreeInstanceId } : {}),
createdAt: existing?.createdAt ?? normalizedLease.createdAt ?? now,
updatedAt: normalizedLease.updatedAt ?? now
}
+ if (clearWorktreeInstanceId) {
+ // Why: unknown or conflicting reattach evidence must not revive a prior trusted binding.
+ delete next.worktreeInstanceId
+ }
if (existingIndex >= 0) {
this.state.sshRemotePtyLeases[existingIndex] = next
} else {
diff --git a/src/main/providers/git-provider-mutation-contract.ts b/src/main/providers/git-provider-mutation-contract.ts
new file mode 100644
index 00000000000..0a3be77ab72
--- /dev/null
+++ b/src/main/providers/git-provider-mutation-contract.ts
@@ -0,0 +1,24 @@
+export type GitProviderMutationOptions = {
+ signal?: AbortSignal
+}
+
+/** Mutation methods that support a final pre-spawn cancellation check. */
+export type IGitMutationProvider = {
+ commit(
+ worktreePath: string,
+ message: string,
+ options?: GitProviderMutationOptions
+ ): Promise<{ success: boolean; error?: string }>
+ stageFile(worktreePath: string, filePath: string): Promise
+ unstageFile(worktreePath: string, filePath: string): Promise
+ bulkStageFiles(
+ worktreePath: string,
+ filePaths: string[],
+ options?: GitProviderMutationOptions
+ ): Promise
+ bulkUnstageFiles(
+ worktreePath: string,
+ filePaths: string[],
+ options?: GitProviderMutationOptions
+ ): Promise
+}
diff --git a/src/main/providers/spool-verified-filesystem-types.ts b/src/main/providers/spool-verified-filesystem-types.ts
new file mode 100644
index 00000000000..ef281b91ac6
--- /dev/null
+++ b/src/main/providers/spool-verified-filesystem-types.ts
@@ -0,0 +1,84 @@
+export type SpoolVerifiedRemoteExistingPath = {
+ path: string
+ expectedRealPath: string
+ expectedStatIdentity: string
+}
+
+export type SpoolVerifiedRemoteFileRead = {
+ bytes: Uint8Array
+ totalBytes: number
+}
+
+export type SpoolVerifiedRemoteDirectoryEntry = {
+ name: string
+ kind: 'file' | 'directory' | 'symlink'
+}
+
+export type SpoolVerifiedRemoteDirectoryPage = {
+ entries: readonly SpoolVerifiedRemoteDirectoryEntry[]
+ nextOffset: number | null
+}
+
+export type SpoolVerifiedRemoteDirectoryIdentity = {
+ canonicalPath: string
+ deviceId: string
+ inodeId: string
+}
+
+export type SpoolVerifiedRemoteFileWrite =
+ | {
+ mode: 'create'
+ targetPath: string
+ parent: SpoolVerifiedRemoteExistingPath
+ bytes: Uint8Array
+ }
+ | {
+ mode: 'replace'
+ target: SpoolVerifiedRemoteExistingPath
+ parent: SpoolVerifiedRemoteExistingPath
+ bytes: Uint8Array
+ }
+
+export type SpoolVerifiedRemoteFilesystem = {
+ inspectDirectoryIdentity(
+ directoryPath: string,
+ signal?: AbortSignal
+ ): Promise
+ readOrCreateIncarnationMarker(
+ directoryPath: string,
+ filename: string,
+ proposedMarkerId: string,
+ signal?: AbortSignal
+ ): Promise
+ list(
+ target: SpoolVerifiedRemoteExistingPath,
+ offset: number,
+ limit: number,
+ signal?: AbortSignal
+ ): Promise
+ read(
+ target: SpoolVerifiedRemoteExistingPath,
+ offset: number,
+ maxBytes: number,
+ signal?: AbortSignal
+ ): Promise
+ write(request: SpoolVerifiedRemoteFileWrite, signal?: AbortSignal): Promise
+ createDirectory(
+ targetPath: string,
+ parent: SpoolVerifiedRemoteExistingPath,
+ signal?: AbortSignal
+ ): Promise
+ rename(
+ source: SpoolVerifiedRemoteExistingPath,
+ sourceParent: SpoolVerifiedRemoteExistingPath,
+ destinationPath: string,
+ destinationParent: SpoolVerifiedRemoteExistingPath,
+ signal?: AbortSignal
+ ): Promise
+ delete(
+ target: SpoolVerifiedRemoteExistingPath,
+ parent: SpoolVerifiedRemoteExistingPath,
+ recursive: boolean,
+ signal?: AbortSignal
+ ): Promise
+}
diff --git a/src/main/providers/ssh-filesystem-file-reader.ts b/src/main/providers/ssh-filesystem-file-reader.ts
new file mode 100644
index 00000000000..59627314ee8
--- /dev/null
+++ b/src/main/providers/ssh-filesystem-file-reader.ts
@@ -0,0 +1,29 @@
+import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
+import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader'
+import type { FileReadResult } from './types'
+
+const warnedLegacyRelays = new WeakSet()
+
+export async function readSshFilesystemFile(
+ mux: SshChannelMultiplexer,
+ filePath: string,
+ signal?: AbortSignal
+): Promise {
+ try {
+ return await readFileViaStream(mux, filePath, signal)
+ } catch (error) {
+ if (!isMethodNotFoundError(error)) {
+ throw error
+ }
+ if (!warnedLegacyRelays.has(mux)) {
+ warnedLegacyRelays.add(mux)
+ console.warn(
+ '[ssh-fs] Relay does not implement fs.readFileStream; falling back to fs.readFile (10 MB cap)'
+ )
+ }
+ const result = signal
+ ? await mux.request('fs.readFile', { filePath }, { signal })
+ : await mux.request('fs.readFile', { filePath })
+ return result as FileReadResult
+ }
+}
diff --git a/src/main/providers/ssh-filesystem-metadata-reader.ts b/src/main/providers/ssh-filesystem-metadata-reader.ts
new file mode 100644
index 00000000000..6b485523d14
--- /dev/null
+++ b/src/main/providers/ssh-filesystem-metadata-reader.ts
@@ -0,0 +1,51 @@
+import type { DirEntry } from '../../shared/types'
+import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
+import { isMethodNotFoundError } from '../ssh/ssh-filesystem-stream-reader'
+import type { SftpFactory } from './ssh-filesystem-file-upload'
+import { lstatViaSftp } from './ssh-filesystem-provider-sftp'
+import type { FileStat } from './types'
+
+export async function readSshDirectory(
+ mux: SshChannelMultiplexer,
+ dirPath: string,
+ options: { limit?: number; signal?: AbortSignal }
+): Promise {
+ return (await mux.request(
+ 'fs.readDir',
+ { dirPath, ...(options.limit !== undefined ? { limit: options.limit } : {}) },
+ { signal: options.signal }
+ )) as DirEntry[]
+}
+
+export async function readSshFileStat(
+ mux: SshChannelMultiplexer,
+ filePath: string,
+ signal?: AbortSignal
+): Promise {
+ return (await mux.request('fs.stat', { filePath }, { signal })) as FileStat
+}
+
+export async function readSshFileLstat(
+ mux: SshChannelMultiplexer,
+ filePath: string,
+ createSftp?: SftpFactory
+): Promise {
+ try {
+ return (await mux.request('fs.lstat', { filePath })) as FileStat
+ } catch (error) {
+ if (!isMethodNotFoundError(error)) {
+ throw error
+ }
+ if (!createSftp) {
+ throw new Error('remote_lstat_unavailable')
+ }
+ const sftp = await createSftp()
+ try {
+ // Why: older relays predate fs.lstat, but SFTP can still preserve
+ // symlink identity for orphaned-worktree safety checks.
+ return await lstatViaSftp(sftp, filePath)
+ } finally {
+ sftp.end()
+ }
+ }
+}
diff --git a/src/main/providers/ssh-filesystem-provider.ts b/src/main/providers/ssh-filesystem-provider.ts
index 507bfd98fdb..12f2c94fc7a 100644
--- a/src/main/providers/ssh-filesystem-provider.ts
+++ b/src/main/providers/ssh-filesystem-provider.ts
@@ -1,18 +1,29 @@
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
-import { isMethodNotFoundError, readFileViaStream } from '../ssh/ssh-filesystem-stream-reader'
+import {
+ consumeSessionInventoryJsonLines,
+ isMethodNotFoundError
+} from '../ssh/ssh-filesystem-stream-reader'
import { uploadBuffer } from '../ssh/sftp-upload'
-import { fastGetViaSftp, lstatViaSftp } from './ssh-filesystem-provider-sftp'
+import { fastGetViaSftp } from './ssh-filesystem-provider-sftp'
import {
openSshFileUploadSession,
type SftpFactory,
type SshRawTransferOptions
} from './ssh-filesystem-file-upload'
+import { readSshFilesystemFile } from './ssh-filesystem-file-reader'
import {
closeSshFilesystemWatch,
registerSshFilesystemWatch,
stopSshFilesystemWatchRegistration,
type WatchRegistration
} from './ssh-filesystem-provider-watch'
+import {
+ readSshDirectory,
+ readSshFileLstat,
+ readSshFileStat
+} from './ssh-filesystem-metadata-reader'
+import { createSshSpoolVerifiedFilesystem } from './ssh-spool-verified-filesystem'
+import type { SpoolVerifiedRemoteFilesystem } from './spool-verified-filesystem-types'
import type {
IFilesystemProvider,
FileStat,
@@ -26,13 +37,13 @@ import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-s
const WORKSPACE_SPACE_SCAN_TIMEOUT_MS = 130_000
export class SshFilesystemProvider implements IFilesystemProvider {
+ readonly spoolVerifiedFiles: SpoolVerifiedRemoteFilesystem
private connectionId: string
private mux: SshChannelMultiplexer
private watchListeners = new Map()
private unsubscribeNotifications: (() => void) | null = null
private tempDirPromise: Promise | null = null
private disposed = false
- private loggedStreamFallback = false
constructor(
connectionId: string,
@@ -42,6 +53,7 @@ export class SshFilesystemProvider implements IFilesystemProvider {
) {
this.connectionId = connectionId
this.mux = mux
+ this.spoolVerifiedFiles = createSshSpoolVerifiedFilesystem(mux)
this.unsubscribeNotifications = mux.onNotification((method, params) =>
routeSshFilesystemWatchNotification(this.watchListeners, method, params)
@@ -67,30 +79,28 @@ export class SshFilesystemProvider implements IFilesystemProvider {
return this.connectionId
}
- async readDir(dirPath: string): Promise {
- return (await this.mux.request('fs.readDir', { dirPath })) as DirEntry[]
+ async readDir(
+ dirPath: string,
+ options: { limit?: number; signal?: AbortSignal } = {}
+ ): Promise {
+ return readSshDirectory(this.mux, dirPath, options)
}
- async readFile(filePath: string): Promise {
- // Why: streaming is the default path so previews above the legacy single-
- // frame budget (~12 MB after base64) don't hit MAX_MESSAGE_SIZE. Old relays
- // that don't implement fs.readFileStream surface as MethodNotFound; we fall
- // back to the legacy single-shot fs.readFile (which retains the old 10 MB
- // cap on those hosts).
- try {
- return await readFileViaStream(this.mux, filePath)
- } catch (err) {
- if (isMethodNotFoundError(err)) {
- if (!this.loggedStreamFallback) {
- this.loggedStreamFallback = true
- console.warn(
- '[ssh-fs] Relay does not implement fs.readFileStream; falling back to fs.readFile (10 MB cap)'
- )
- }
- return (await this.mux.request('fs.readFile', { filePath })) as FileReadResult
- }
- throw err
- }
+ async readFile(
+ filePath: string,
+ options: { signal?: AbortSignal } = {}
+ ): Promise {
+ return readSshFilesystemFile(this.mux, filePath, options.signal)
+ }
+
+ async consumeSessionInventoryJsonLines(
+ filePath: string,
+ consumeLine: (line: string) => void,
+ options: { signal?: AbortSignal } = {}
+ ): Promise {
+ // Why: falling back to fs.readFile would reintroduce both the legacy 10 MB
+ // cap and whole-transcript buffering; stale relays fail closed instead.
+ await consumeSessionInventoryJsonLines(this.mux, filePath, consumeLine, options.signal)
}
async readTerminalArtifact(
@@ -210,29 +220,12 @@ export class SshFilesystemProvider implements IFilesystemProvider {
}
}
- async stat(filePath: string): Promise {
- return (await this.mux.request('fs.stat', { filePath })) as FileStat
+ async stat(filePath: string, options: { signal?: AbortSignal } = {}): Promise {
+ return readSshFileStat(this.mux, filePath, options.signal)
}
async lstat(filePath: string): Promise {
- try {
- return (await this.mux.request('fs.lstat', { filePath })) as FileStat
- } catch (err) {
- if (!isMethodNotFoundError(err)) {
- throw err
- }
- if (!this.createSftp) {
- throw new Error('remote_lstat_unavailable')
- }
- const sftp = await this.createSftp()
- try {
- // Why: older relays predate fs.lstat, but SFTP can still preserve
- // symlink identity for orphaned-worktree safety checks.
- return await lstatViaSftp(sftp, filePath)
- } finally {
- sftp.end()
- }
- }
+ return readSshFileLstat(this.mux, filePath, this.createSftp)
}
async scanWorkspaceSpace(
diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts
index 1aa1cd271a2..dfd508a2a81 100644
--- a/src/main/providers/ssh-git-provider.ts
+++ b/src/main/providers/ssh-git-provider.ts
@@ -3,7 +3,8 @@
indirection — every method is a 1:1 forwarder to a relay RPC plus a
small amount of param plumbing. */
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
-import type { GitProviderStatusOptions, IGitProvider } from './types'
+import type { GitProviderMutationOptions, IGitProvider } from './types'
+import type { GitProviderStatusOptions } from './git-provider-status-options'
import type {
GitStatusResult,
GitDiffResult,
@@ -164,14 +165,16 @@ export class SshGitProvider implements IGitProvider {
async commit(
worktreePath: string,
- message: string
+ message: string,
+ options?: GitProviderMutationOptions
): Promise<{ success: boolean; error?: string }> {
return this.runWithDiffDedupeClear(
async () =>
- (await this.mux.request('git.commit', {
- worktreePath,
- message
- })) as { success: boolean; error?: string }
+ (await this.mux.request(
+ 'git.commit',
+ { worktreePath, message },
+ options?.signal ? { signal: options.signal } : undefined
+ )) as { success: boolean; error?: string }
)
}
@@ -404,19 +407,35 @@ export class SshGitProvider implements IGitProvider {
}
}
- async bulkStageFiles(worktreePath: string, filePaths: string[]): Promise {
+ async bulkStageFiles(
+ worktreePath: string,
+ filePaths: string[],
+ options?: GitProviderMutationOptions
+ ): Promise {
this.gitDiffReadDedupe.clear()
try {
- await this.mux.request('git.bulkStage', { worktreePath, filePaths })
+ await this.mux.request(
+ 'git.bulkStage',
+ { worktreePath, filePaths },
+ options?.signal ? { signal: options.signal } : undefined
+ )
} finally {
this.gitDiffReadDedupe.clear()
}
}
- async bulkUnstageFiles(worktreePath: string, filePaths: string[]): Promise {
+ async bulkUnstageFiles(
+ worktreePath: string,
+ filePaths: string[],
+ options?: GitProviderMutationOptions
+ ): Promise