From ffc331212c38e9d94af09df74f03afa6e63a0717 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Mon, 14 Sep 2026 17:13:25 -0700
Subject: [PATCH 01/34] Fix PTY child process verdict to preserve unverifiable
state (#20729)
* fix(pty): preserve unverifiable local child reads
* fix(pty): make child-process inspection synchronous
Separate foreground and child-process sampling. Sample child processes
synchronously after confirming foreground availability, returning
unverifiable verdicts when pty reads fail. Handle both transport loss
and local read failures uniformly in the completion coordinator.
---
.../local-pty-child-process-verdict.test.ts | 142 ++++++++++++++++++
.../local-pty-foreground-inspection.ts | 16 +-
src/main/providers/local-pty-provider.ts | 14 +-
...letion-coordinator-process-cadence.test.ts | 27 +++-
.../agent-completion-inspection-result.ts | 7 +-
5 files changed, 191 insertions(+), 15 deletions(-)
create mode 100644 src/main/providers/local-pty-child-process-verdict.test.ts
diff --git a/src/main/providers/local-pty-child-process-verdict.test.ts b/src/main/providers/local-pty-child-process-verdict.test.ts
new file mode 100644
index 00000000000..b36f19fef50
--- /dev/null
+++ b/src/main/providers/local-pty-child-process-verdict.test.ts
@@ -0,0 +1,142 @@
+import type * as pty from 'node-pty'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { resolveForegroundMock } = vi.hoisted(() => ({ resolveForegroundMock: vi.fn() }))
+
+vi.mock('./agent-foreground-process', () => ({
+ resolveAgentForegroundProcessWithAvailability: resolveForegroundMock,
+ confirmShellForegroundProcess: vi.fn()
+}))
+import {
+ hasLocalPtyChildProcesses,
+ inspectLocalPtyChildProcesses
+} from './local-pty-foreground-inspection'
+import { LocalPtyProvider } from './local-pty-provider'
+import { ptyProcesses, ptyShellName } from './local-pty-provider-state'
+import { inspectPtyProviderProcess } from './pty-process-inspection'
+
+function registerPane(id: string, foreground: string | (() => string), shell?: string): void {
+ const pane: pty.IPty = {
+ pid: 4242,
+ cols: 80,
+ rows: 24,
+ get process(): string {
+ return typeof foreground === 'function' ? foreground() : foreground
+ },
+ handleFlowControl: false,
+ onData: () => ({ dispose() {} }),
+ onExit: () => ({ dispose() {} }),
+ resize() {},
+ clear() {},
+ write() {},
+ kill() {},
+ pause() {},
+ resume() {}
+ }
+ ptyProcesses.set(id, pane)
+ if (shell) {
+ ptyShellName.set(id, shell)
+ }
+}
+
+beforeEach(() => {
+ resolveForegroundMock.mockReset()
+ resolveForegroundMock.mockResolvedValue({ available: true, processName: '/bin/zsh' })
+})
+
+afterEach(() => {
+ ptyProcesses.clear()
+ ptyShellName.clear()
+})
+
+describe('inspectLocalPtyChildProcesses', () => {
+ it('reports unverifiable when the pty fd cannot be read', () => {
+ registerPane(
+ 'pty-closed',
+ () => {
+ throw new Error('EBADF: bad file descriptor')
+ },
+ '/bin/zsh'
+ )
+
+ // Not `no-children`: the close guard reads that as "nothing is running here" and kills the pane.
+ expect(inspectLocalPtyChildProcesses('pty-closed')).toBe('unverifiable')
+ })
+
+ it('still answers no-children when the shell itself is in the foreground', () => {
+ registerPane('pty-idle', '/bin/zsh', '/bin/zsh')
+ expect(inspectLocalPtyChildProcesses('pty-idle')).toBe('no-children')
+ })
+
+ it('answers children when something else is in the foreground', () => {
+ registerPane('pty-busy', 'vim', '/bin/zsh')
+ expect(inspectLocalPtyChildProcesses('pty-busy')).toBe('children')
+ })
+
+ it('treats a pane this provider does not hold as a real negative', () => {
+ expect(inspectLocalPtyChildProcesses('pty-absent')).toBe('no-children')
+ })
+
+ it('collapses uncertainty to false only in the boolean adapter', async () => {
+ registerPane(
+ 'pty-closed',
+ () => {
+ throw new Error('EBADF: bad file descriptor')
+ },
+ '/bin/zsh'
+ )
+
+ // The adapter exists for `IPtyProvider.hasChildProcesses`, which has no third slot.
+ await expect(hasLocalPtyChildProcesses('pty-closed')).resolves.toBe(false)
+ })
+})
+
+describe('inspectPtyProviderProcess child-process evidence', () => {
+ const provider = new LocalPtyProvider()
+
+ it('carries unverifiable evidence when the child read fails after foreground inspection', async () => {
+ let reads = 0
+ registerPane(
+ 'pty-closing',
+ () => {
+ reads += 1
+ if (reads > 1) {
+ throw new Error('EBADF: bad file descriptor')
+ }
+ return '/bin/zsh'
+ },
+ '/bin/zsh'
+ )
+
+ await expect(inspectPtyProviderProcess(provider, 'pty-closing')).resolves.toEqual({
+ foregroundProcess: '/bin/zsh',
+ hasChildProcesses: false,
+ childProcessEvidence: 'unverifiable'
+ })
+ })
+
+ it('samples child evidence after foreground inspection', async () => {
+ let reads = 0
+ registerPane('pty-became-busy', () => (reads++ === 0 ? '/bin/zsh' : 'vim'), '/bin/zsh')
+
+ const inspection = await inspectPtyProviderProcess(provider, 'pty-became-busy')
+ expect(inspection.hasChildProcesses).toBe(true)
+ expect(inspection.childProcessEvidence).toBe('children')
+ })
+
+ it('carries no-children evidence from the local inspectProcess operation', async () => {
+ registerPane('pty-idle', '/bin/zsh', '/bin/zsh')
+
+ const inspection = await inspectPtyProviderProcess(provider, 'pty-idle')
+ expect(inspection.hasChildProcesses).toBe(false)
+ expect(inspection.childProcessEvidence).toBe('no-children')
+ })
+
+ it('carries children evidence from the local inspectProcess operation', async () => {
+ registerPane('pty-busy', 'vim', '/bin/zsh')
+
+ const inspection = await inspectPtyProviderProcess(provider, 'pty-busy')
+ expect(inspection.hasChildProcesses).toBe(true)
+ expect(inspection.childProcessEvidence).toBe('children')
+ })
+})
diff --git a/src/main/providers/local-pty-foreground-inspection.ts b/src/main/providers/local-pty-foreground-inspection.ts
index d4a717a9de2..eec6a19a621 100644
--- a/src/main/providers/local-pty-foreground-inspection.ts
+++ b/src/main/providers/local-pty-foreground-inspection.ts
@@ -1,3 +1,4 @@
+import type { PtyChildProcessVerdict } from '../../shared/terminal-process-inspection'
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import { getCheapProcessTableSnapshot } from '../../shared/cheap-process-table-snapshot-reader'
import { getProcessTableSnapshot } from '../../shared/process-table-snapshot-reader'
@@ -21,23 +22,28 @@ import {
import { readWindowsConsoleAttachedProcessIds } from './windows-console-attached-processes'
import { isWindowsPtyJobReadable, readWindowsPtyJobProcessIds } from './windows-pty-job-membership'
-export async function hasLocalPtyChildProcesses(id: string): Promise {
+export function inspectLocalPtyChildProcesses(id: string): PtyChildProcessVerdict {
const proc = ptyProcesses.get(id)
if (!proc) {
- return false
+ return 'no-children'
}
try {
const foreground = proc.process
const shell = ptyShellName.get(id)
if (!shell) {
- return true
+ return 'children'
}
- return foreground !== shell
+ return foreground === shell ? 'no-children' : 'children'
} catch {
- return false
+ // An unreadable PTY is not evidence that its children exited.
+ return 'unverifiable'
}
}
+export async function hasLocalPtyChildProcesses(id: string): Promise {
+ return inspectLocalPtyChildProcesses(id) === 'children'
+}
+
/**
* POSIX twin of the Windows job-membership short-circuit below: a pane that already holds a
* recognized agent re-proves it from the cheap `ps` tier when the subtree fingerprint is
diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts
index f50ad36d34c..056a829d1dd 100644
--- a/src/main/providers/local-pty-provider.ts
+++ b/src/main/providers/local-pty-provider.ts
@@ -9,9 +9,11 @@ import {
confirmLocalPtyForegroundProcess,
confirmLocalPtyShellForeground,
getLocalPtyForegroundProcess,
- hasLocalPtyChildProcesses
+ hasLocalPtyChildProcesses,
+ inspectLocalPtyChildProcesses
} from './local-pty-foreground-inspection'
import type { LocalPtyProviderOptions } from './local-pty-provider-types'
+import type { PtyProcessInspection } from './pty-process-inspection'
import {
advanceLoadGeneration,
clearPtyState,
@@ -127,6 +129,16 @@ export class LocalPtyProvider implements IPtyProvider {
return hasLocalPtyChildProcesses(id)
}
+ async inspectProcess(id: string): Promise {
+ const foregroundProcess = await getLocalPtyForegroundProcess(id)
+ const childProcessEvidence = inspectLocalPtyChildProcesses(id)
+ return {
+ foregroundProcess,
+ hasChildProcesses: childProcessEvidence === 'children',
+ childProcessEvidence
+ }
+ }
+
getForegroundProcess(id: string): Promise {
return getLocalPtyForegroundProcess(id)
}
diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts
index e9d60cabace..9579ea2ba2c 100644
--- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts
+++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts
@@ -290,7 +290,25 @@ describe('agent completion coordinator', () => {
expect(dispatchCompletion).toHaveBeenCalledExactlyOnceWith('done')
})
- it('resets exit confirmation across an unavailable inspection', async () => {
+ it.each([
+ {
+ label: 'client-only uncertainty',
+ result: {
+ foregroundProcess: null,
+ hasChildProcesses: false,
+ verdict: 'unverifiable',
+ reason: 'transport_loss'
+ } satisfies RuntimeTerminalProcessInspection
+ },
+ {
+ label: 'host child-process uncertainty',
+ result: {
+ foregroundProcess: '/bin/zsh',
+ hasChildProcesses: false,
+ childProcessEvidence: 'unverifiable'
+ } satisfies RuntimeTerminalProcessInspection
+ }
+ ])('resets exit confirmation across $label', async ({ result: unavailableResult }) => {
let result: RuntimeTerminalProcessInspection = processResult('codex')
const dispatchCompletion = vi.fn()
const coordinator = createAgentCompletionCoordinator({
@@ -306,12 +324,7 @@ describe('agent completion coordinator', () => {
await vi.advanceTimersByTimeAsync(2_000)
result = processResult(null, false)
await vi.advanceTimersByTimeAsync(750)
- result = {
- foregroundProcess: null,
- hasChildProcesses: false,
- verdict: 'unverifiable',
- reason: 'transport_loss'
- }
+ result = unavailableResult
await vi.advanceTimersByTimeAsync(750)
result = processResult(null, false)
await vi.advanceTimersByTimeAsync(1_500)
diff --git a/src/renderer/src/components/terminal-pane/agent-completion-inspection-result.ts b/src/renderer/src/components/terminal-pane/agent-completion-inspection-result.ts
index f27960361bb..b1a74972311 100644
--- a/src/renderer/src/components/terminal-pane/agent-completion-inspection-result.ts
+++ b/src/renderer/src/components/terminal-pane/agent-completion-inspection-result.ts
@@ -53,13 +53,16 @@ export function handleAgentCompletionInspectionResult(args: {
dispatchCompletion,
remoteInspection
} = args
- if (isClientOnlyUnverifiableInspection(result)) {
+ const remote = options.isRemotePtyId?.(options.getPtyId() ?? '') === true
+ if (
+ isClientOnlyUnverifiableInspection(result) ||
+ (!remote && result.childProcessEvidence === 'unverifiable')
+ ) {
state.pendingProcessExitAgent = null
state.consecutiveInspectionErrors += 1
scheduleNextPoll()
return false
}
- const remote = options.isRemotePtyId?.(options.getPtyId() ?? '') === true
if (remote) {
const evidence = result.foregroundProcessEvidence
// Remote identity is host-authoritative. Compatibility names and unverifiable observations
From 20794ee785b826480a927d06f2d134d806ddeae4 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Mon, 14 Sep 2026 17:32:33 -0700
Subject: [PATCH 02/34] ci: keep the baseline build off the compatibility
matrix lanes (#20733)
The compatibility gate started the pinned 2.25.5 source build inside the same
step that runs the three measured lanes, so `make -j$(nproc)` competed with two
container lanes whose wall clock is container starts, not Git. A boundary case
that costs ~1.5s stretched past Vitest's 30s timeout and failed the job.
Build the binary in its own step before the matrix, and pull both images before
any lane starts so a lazy pull cannot stall whichever test its sibling is timing.
---
.github/workflows/pr.yml | 57 ++++++++-----
...git-binary-compatibility-workflow.test.mjs | 79 ++++++++++++-------
docs/reference/git-compatibility.md | 6 ++
3 files changed, 94 insertions(+), 48 deletions(-)
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 398bd61bd04..2f54f2ae785 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -326,40 +326,59 @@ jobs:
# Why: the 2.25.5 lane is a source build of a pinned tarball, so it produced the
# same binary on every PR for minutes of runner time. The key carries the version
# because that is the only input; the sha256 assertion below still guards the
- # tarball on the miss path that actually builds.
+ # tarball on the miss path that actually builds. Only this PR's own later pushes
+ # can restore it — GitHub scopes a cache written from a pull_request run to that
+ # ref — so a first push always takes the build path below.
- name: Cache baseline Git build
uses: actions/cache@v5
with:
path: ~/.cache/orca-git-compat/git-2.25.5
key: git-compat-baseline-${{ runner.os }}-${{ runner.arch }}-2.25.5
+ # Why its own step: this is `make -j$(nproc)` on every core, and the lanes below
+ # spend their wall clock waiting on container starts, not on Git. Sharing a runner
+ # with the build stretched one ~1.5s boundary case past Vitest's 30s timeout, so
+ # the build has to finish before anything timed starts.
+ - name: Build the baseline Git binary
+ run: |
+ archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
+ source="$HOME/.cache/orca-git-compat/git-2.25.5"
+ if [ -x "$source/git" ]; then
+ exit 0
+ fi
+ curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
+ echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
+ | sha256sum --check
+ mkdir -p "$source"
+ tar -xzf "$archive" -C "$source" --strip-components=1
+ make -C "$source" -j"$(nproc)" \
+ NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
+ # Why: the linked binaries are what the next run needs; the objects that
+ # produced them are most of the tree and would bloat the cache entry.
+ find "$source" -name '*.o' -delete
+
- name: Verify Git binary compatibility matrix
run: |
+ specs=(
+ "alpine/git:edge-2.38.1|2.38.1"
+ "alpine/git:v2.49.1|2.49.1"
+ )
+ # Why pull up front: a lane's first `docker run` otherwise pulls its image
+ # while the sibling lane is mid-test, and that stall is charged to the test.
+ for spec in "${specs[@]}"; do
+ docker pull --quiet "${spec%%|*}"
+ done
+
pids=()
(
- archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
- source="$HOME/.cache/orca-git-compat/git-2.25.5"
- if [ ! -x "$source/git" ]; then
- curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
- echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
- | sha256sum --check
- mkdir -p "$source"
- tar -xzf "$archive" -C "$source" --strip-components=1
- make -C "$source" -j"$(nproc)" \
- NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
- # Why: the linked binaries are what the next run needs; the objects that
- # produced them are most of the tree and would bloat the cache entry.
- find "$source" -name '*.o' -delete
- fi
- ORCA_GIT_COMPAT_BINARY="$source/git" ORCA_GIT_COMPAT_VERSION="2.25.5" \
+ ORCA_GIT_COMPAT_BINARY="$HOME/.cache/orca-git-compat/git-2.25.5/git" \
+ ORCA_GIT_COMPAT_VERSION="2.25.5" \
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts
) &
pids+=("$!")
- for spec in \
- "alpine/git:edge-2.38.1|2.38.1" \
- "alpine/git:v2.49.1|2.49.1"; do
+ for spec in "${specs[@]}"; do
(
image="${spec%%|*}"
version="${spec#*|}"
diff --git a/config/scripts/git-binary-compatibility-workflow.test.mjs b/config/scripts/git-binary-compatibility-workflow.test.mjs
index afe5615bb44..35d2b5c60dc 100644
--- a/config/scripts/git-binary-compatibility-workflow.test.mjs
+++ b/config/scripts/git-binary-compatibility-workflow.test.mjs
@@ -2,42 +2,63 @@ import { readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
+const BASELINE_DIR = '~/.cache/orca-git-compat/git-2.25.5'
+
+const gateSteps = () =>
+ parse(readFileSync('.github/workflows/pr.yml', 'utf8')).jobs.git_compatibility.steps
+
+const stepNamed = (name) => gateSteps().find((step) => step.name === name)
+
describe('Git binary compatibility PR gate', () => {
it('runs the real-binary contract at each compatibility boundary', () => {
- const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
- const step = workflow.jobs.git_compatibility.steps.find(
- (candidate) => candidate.name === 'Verify Git binary compatibility matrix'
- )
+ const run = stepNamed('Verify Git binary compatibility matrix')?.run
- expect(step?.run).toContain('git-2.25.5.tar.gz')
+ expect(run).toContain('ORCA_GIT_COMPAT_BINARY="$HOME/.cache/orca-git-compat/git-2.25.5/git"')
+ expect(run).toContain('alpine/git:edge-2.38.1|2.38.1')
+ expect(run).toContain('alpine/git:v2.49.1|2.49.1')
+ expect(run).toContain('ORCA_GIT_COMPAT_IMAGE="$image"')
+ expect(run).toContain('src/shared/git-binary-compatibility.test.ts')
+ expect(run).toContain('pids+=("$!")')
+ expect(run).toContain('wait "$pid" || status=1')
+ })
+
+ it('builds the pinned baseline tarball into the cached directory', () => {
+ const run = stepNamed('Build the baseline Git binary')?.run
+
+ expect(run).toContain('git-2.25.5.tar.gz')
// Why asserted: the sha256 check only runs on the build path, so a cached binary
// must come from a key that pins the same version the tarball line declares.
- expect(step?.run).toContain('if [ ! -x "$source/git" ]; then')
- expect(step?.run).toContain('41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf')
- expect(step?.run).toContain('ORCA_GIT_COMPAT_BINARY="$source/git"')
- expect(step?.run).toContain('alpine/git:edge-2.38.1|2.38.1')
- expect(step?.run).toContain('alpine/git:v2.49.1|2.49.1')
- expect(step?.run).toContain('ORCA_GIT_COMPAT_IMAGE="$image"')
- expect(step?.run).toContain('src/shared/git-binary-compatibility.test.ts')
- expect(step?.run).toContain('-j"$(nproc)"')
- expect(step?.run).toContain('pids+=("$!")')
- expect(step?.run).toContain('wait "$pid" || status=1')
- })
-
- it('restores the baseline Git build before the matrix runs', () => {
- const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
- const steps = workflow.jobs.git_compatibility.steps
- const cacheIndex = steps.findIndex((step) => step.name === 'Cache baseline Git build')
- const matrixIndex = steps.findIndex(
- (step) => step.name === 'Verify Git binary compatibility matrix'
- )
-
- expect(cacheIndex).toBeGreaterThanOrEqual(0)
- expect(cacheIndex).toBeLessThan(matrixIndex)
+ expect(run).toContain('if [ -x "$source/git" ]; then')
+ expect(run).toContain('41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf')
+ expect(run).toContain('-j"$(nproc)"')
// The cached path and the build path must be the same directory or the guard
// above would rebuild on every run while still reporting a cache hit.
- expect(steps[cacheIndex].with.path).toBe('~/.cache/orca-git-compat/git-2.25.5')
- expect(steps[matrixIndex].run).toContain('source="$HOME/.cache/orca-git-compat/git-2.25.5"')
+ expect(run).toContain('source="$HOME/.cache/orca-git-compat/git-2.25.5"')
+ })
+
+ it('finishes the baseline build before the timed lanes start', () => {
+ const steps = gateSteps()
+ const names = steps.map((step) => step.name)
+ const cacheIndex = names.indexOf('Cache baseline Git build')
+ const buildIndex = names.indexOf('Build the baseline Git binary')
+ const matrixIndex = names.indexOf('Verify Git binary compatibility matrix')
+
+ expect(cacheIndex).toBeGreaterThanOrEqual(0)
+ expect(cacheIndex).toBeLessThan(buildIndex)
+ expect(buildIndex).toBeLessThan(matrixIndex)
+ // Why asserted: each lane is bounded by Vitest's per-test timeout while it waits on
+ // container starts, so a `make -j$(nproc)` sharing the runner shows up as a timeout
+ // in whichever boundary case is running rather than as a slow build.
+ expect(steps[matrixIndex].run).not.toContain('make -C')
+ expect(steps[cacheIndex].with.path).toBe(BASELINE_DIR)
expect(steps[cacheIndex].with.key).toContain('2.25.5')
})
+
+ it('pulls every matrix image before any lane runs', () => {
+ const run = stepNamed('Verify Git binary compatibility matrix')?.run
+ // A lazy pull inside one lane stalls whatever test the sibling lane is timing.
+ const [beforeLanes] = run.split('pids=()')
+
+ expect(beforeLanes).toContain('docker pull --quiet "${spec%%|*}"')
+ })
})
diff --git a/docs/reference/git-compatibility.md b/docs/reference/git-compatibility.md
index 1e19860385e..d537c4d94de 100644
--- a/docs/reference/git-compatibility.md
+++ b/docs/reference/git-compatibility.md
@@ -69,6 +69,12 @@ PR checks run the capability contract against real Git 2.25.5, 2.38.1, and
2.49.1 binaries. This spans the pre-2.29 serialized `FETCH_HEAD` fallback, the transitional
`merge-tree --write-tree` behavior before `--merge-base`, and current Git.
+The three lanes run in parallel and each Git call in the container lanes costs a
+container start, so their wall clock is runner contention, not Git. Build the
+2.25.5 binary and pull the images before the lanes start: anything heavy left
+running alongside them is charged to whichever boundary case is in flight and
+surfaces as a Vitest timeout rather than as a slow setup step.
+
Keep the unit tests alongside that matrix. They cover concurrent probes,
native/WSL/SSH/relay isolation, and error-stream shapes that a single real
binary invocation cannot exercise deterministically.
From 2b34255d9657dc63830978f4e4433a422ec30765 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Mon, 14 Sep 2026 17:47:11 -0700
Subject: [PATCH 03/34] fix(ci): stop defining pilot mutant tests inside a
conditional (#20755)
`vitest/no-conditional-tests` fires on the `if (mutation) { it(...) }` inside
the pilot loop, and `audit:code-quality:native` runs oxlint with
`--deny-warnings`, so main's "Enforce focused code-quality plugins" step exits
1 and blocks every open PR.
Pair each pilot with its pinned mutant and reference state before the loops, so
every iteration defines exactly one test unconditionally. Same 14 tests, same
names: 11 mutant-kill tests and the 3 reference tests that `skipIf` still gates
on RPC_FOUNDATION_REFERENCE_ROOT.
---
.../mutants/pilot-mutants.test.ts | 49 ++++++++++---------
1 file changed, 27 insertions(+), 22 deletions(-)
diff --git a/mobile/src/test-support/rpc-recording/mutants/pilot-mutants.test.ts b/mobile/src/test-support/rpc-recording/mutants/pilot-mutants.test.ts
index a81a48238f5..acd9840e4f0 100644
--- a/mobile/src/test-support/rpc-recording/mutants/pilot-mutants.test.ts
+++ b/mobile/src/test-support/rpc-recording/mutants/pilot-mutants.test.ts
@@ -59,30 +59,35 @@ function visibleState(recording: Recording): RecordedValue {
return recording.checkpoints.at(-1)!.observation.state
}
+// Pair pilots with their pinned mutant/reference up front so each loop below defines exactly one test.
+const pilots = pilotGoldens(input.scenarios)
+const mutantPilots = pilots.flatMap((pilot) => {
+ const mutation = mutants[pilot.id]
+ return mutation ? [{ ...pilot, mutation }] : []
+})
+const referencePilots = pilots.flatMap((pilot) => {
+ const reference = referenceStates[pilot.id]
+ return reference ? [{ ...pilot, reference }] : []
+})
+
describe('RPC main recording mutants', () => {
- for (const pilot of pilotGoldens(input.scenarios)) {
- const { id, scenario } = pilot
- const mutation = mutants[id]
- if (mutation) {
- it(`${id}: kills ${mutation}`, async () => {
- const { adapters, assertMutationApplied } = pilotMountAdapters(root, {
- mutation: operationMutation(mutation)
- })
- const result = await runRecordingMutant(
- scenario,
- adapters[scenario.operation],
- vitestRecordingScheduler(),
- readGolden(goldens, id).recording,
- visibleState
- )
- assertMutationApplied()
- expect(result.verdict).toBe('killed')
+ for (const { id, scenario, mutation } of mutantPilots) {
+ it(`${id}: kills ${mutation}`, async () => {
+ const { adapters, assertMutationApplied } = pilotMountAdapters(root, {
+ mutation: operationMutation(mutation)
})
- }
- const reference = referenceStates[id]
- if (!reference) {
- continue
- }
+ const result = await runRecordingMutant(
+ scenario,
+ adapters[scenario.operation],
+ vitestRecordingScheduler(),
+ readGolden(goldens, id).recording,
+ visibleState
+ )
+ assertMutationApplied()
+ expect(result.verdict).toBe('killed')
+ })
+ }
+ for (const { id, scenario, reference } of referencePilots) {
it.skipIf(!process.env.RPC_FOUNDATION_REFERENCE_ROOT)(`${id}: rejects bcba08b3e4`, async () => {
const { adapters } = pilotMountAdapters(process.env.RPC_FOUNDATION_REFERENCE_ROOT!, {
reference: true
From db09a7bd508fded16cd44a1925d432cdfc22131c Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Mon, 14 Sep 2026 17:47:19 -0700
Subject: [PATCH 04/34] fix(native-chat): let a reader park just above the
latest message (#20709)
* fix(native-chat): let a reader park just above the latest message
A reader who scrolled up by less than the bottom threshold was still
classified as being at the end, so follow stayed armed and the next chunk
of stream carried them back down. One constant was answering two
different questions: how close to the end still counts as pinned, and
whether a reader's own scroll meant to stay there.
The first wants slack, because a streaming last message jitters in height
by tens of pixels. The second wants almost none, because it is a
statement of intent. Give it its own, far stricter band, and move the
choice of band into the decision rather than leaving it to the call site,
which is where the two got conflated.
Re-arming follow now requires the reader to be within 4px of the end:
enough for fractional-pixel and zoom rounding, well inside one line of
prose. The pin and the jump-to-latest affordance keep their 48px band.
* fix(native-chat): make transcript intent own end following
---
.../native-chat/NativeChatMessageList.tsx | 5 +-
.../NativeChatMessageList.windowing.test.tsx | 185 +++++++++++++++++-
.../native-chat-autoscroll.test.ts | 52 ++++-
.../native-chat/native-chat-autoscroll.ts | 17 +-
.../use-native-chat-transcript-scroll.ts | 3 +-
...ve-chat-transcript-window.options.test.tsx | 6 +-
.../use-native-chat-transcript-window.ts | 17 +-
7 files changed, 248 insertions(+), 37 deletions(-)
diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx
index 108a188c4ab..f7462e07434 100644
--- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx
@@ -256,10 +256,7 @@ export function NativeChatMessageList({
// Named so measurement can find the scroll root without depending on
// which utility class happens to make it scroll.
data-native-chat-scroll
- // `overflow-anchor:none`: the transcript decides whether an offset
- // it did not write is the reader moving, so the engine adjusting
- // scrollTop under a settling row would read as a departure. The
- // virtualizer does its own end anchoring, so this is redundant here.
+ // Browser anchoring would add unattributed movement beside the virtualizer's anchor.
className="scrollbar-sleek relative h-full overflow-y-auto [overflow-anchor:none] [scrollbar-gutter:stable_both-edges]"
// Why: `zoom` scales the chat transcript's text and layout together,
// scoped to this pane so the rest of the app is untouched. It sits on
diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx
index 8d9f3ffa83b..e4debc8e676 100644
--- a/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatMessageList.windowing.test.tsx
@@ -12,7 +12,10 @@ import { projectStructuredItemsToNativeChat } from '../../../../shared/structure
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import type { NativeChatLiveSession } from './use-native-chat-live-session'
import { NativeChatMessageList } from './NativeChatMessageList'
-import { NATIVE_CHAT_BOTTOM_THRESHOLD_PX } from './native-chat-autoscroll'
+import {
+ NATIVE_CHAT_BOTTOM_THRESHOLD_PX,
+ NATIVE_CHAT_FOLLOW_REARM_PX
+} from './native-chat-autoscroll'
import {
estimateNativeChatRowHeight,
NATIVE_CHAT_ROW_GAP_PX,
@@ -469,11 +472,8 @@ describe('transcript with a hidden scroll root', () => {
// arrive at their final height and are a different case; this is the one where
// the row the reader is looking at keeps changing size underneath them.
//
-// Two mechanisms are supposed to hold the pin, and both are exercised here: the
-// list's own resize observer on the transcript column (which re-runs
-// `scrollToBottom` against the document) and the virtualizer's end anchor (which
-// compensates `scrollTop` by the growth when the view was already at the end).
-describe('a row growing in place while the view is pinned to the bottom', () => {
+// Exercise the real virtualizer together with the transcript's follow owner.
+describe('transcript follow ownership across growth and appends', () => {
const TAIL_INDEX = TRANSCRIPT_LENGTH - 1
const GROWTH_STEPS = 24
const LINES_PER_STEP = 12
@@ -490,6 +490,13 @@ describe('a row growing in place while the view is pinned to the bottom', () =>
const transcript = Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) => marker(index))
+ function appendedTranscript(count: number): NativeChatMessage[] {
+ return [
+ ...transcript,
+ ...Array.from({ length: count }, (_, index) => marker(TRANSCRIPT_LENGTH + index))
+ ]
+ }
+
function tailHeightAt(step: number): number {
return Math.max(ROW_PX, (1 + step * LINES_PER_STEP) * STREAM_LINE_PX)
}
@@ -642,6 +649,170 @@ describe('a row growing in place while the view is pinned to the bottom', () =>
expect(screen.getByRole('button', { name: /jump to latest/i })).toBeInTheDocument()
})
+ it.each([0, 100])(
+ 'keeps a reader parked above a growing row with a %i px initial measurement delta',
+ (measurementDelta) => {
+ setMeasuredTail(4)
+ measuredRowHeights = measuredRowHeights.map((height, index) =>
+ index === TAIL_INDEX ? height + measurementDelta : height
+ )
+ const { container, rerender } = render(streamingList(4))
+ paint(container)
+ const scroller = scrollRoot(container)
+
+ const parkGapPx = NATIVE_CHAT_BOTTOM_THRESHOLD_PX - 8
+ const parkedAt = scroller.scrollHeight - scroller.clientHeight - parkGapPx
+ scrollTranscript(container, parkedAt)
+ expect(distanceFromBottom(container)).toBe(parkGapPx)
+ // Not the "scrolled far away" case above: the latest message is still on
+ // screen, so there is nothing to offer a way back to yet.
+ expect(screen.queryByRole('button', { name: /jump to latest/i })).toBeNull()
+
+ setMeasuredTail(5)
+ rerender(streamingList(5))
+ paint(container)
+ expect(scroller.scrollTop).toBe(parkedAt)
+
+ let previousDistance = distanceFromBottom(container)
+ for (let step = 6; step <= GROWTH_STEPS; step += 1) {
+ setMeasuredTail(step)
+ rerender(streamingList(step))
+ paint(container)
+
+ // The offset stops moving at all...
+ expect(scroller.scrollTop).toBe(parkedAt)
+ // ...so the end runs away from the reader instead of carrying them along.
+ const distance = distanceFromBottom(container)
+ expect(distance).toBeGreaterThan(previousDistance)
+ previousDistance = distance
+ }
+
+ expect(previousDistance).toBeGreaterThan(VIEWPORT_PX)
+ expect(screen.getByRole('button', { name: /jump to latest/i })).toBeInTheDocument()
+ }
+ )
+
+ it('leaves a parked reader in place through repeated appends', () => {
+ const { container, rerender } = render(list(transcript))
+ paint(container)
+ const scroller = scrollRoot(container)
+ const parkedAt = scroller.scrollHeight - scroller.clientHeight - 40
+ scrollTranscript(container, parkedAt)
+
+ for (let count = 1; count <= 8; count += 1) {
+ rerender(list(appendedTranscript(count)))
+ paint(container)
+ expect(scroller.scrollTop).toBe(parkedAt)
+ expect(windowState(container).indexes.length).toBeLessThan(TRANSCRIPT_LENGTH / 4)
+ }
+ expect(screen.getByRole('button', { name: /jump to latest/i })).toBeInTheDocument()
+ })
+
+ it('follows repeated appends until the reader detaches', () => {
+ const { container, rerender } = render(list(transcript))
+ paint(container)
+ const scroller = scrollRoot(container)
+ for (let count = 1; count <= 8; count += 1) {
+ rerender(list(appendedTranscript(count)))
+ paint(container)
+ expect(distanceFromBottom(container)).toBeLessThanOrEqual(NATIVE_CHAT_FOLLOW_REARM_PX)
+ fireEvent.scroll(scroller)
+ }
+
+ const parkedAt = scroller.scrollTop - 22
+ scrollTranscript(container, parkedAt)
+ rerender(list(appendedTranscript(9)))
+ paint(container)
+ expect(scroller.scrollTop).toBe(parkedAt)
+ })
+
+ it('follows an empty transcript through underflow into scrollable output', () => {
+ const { container, rerender } = render(list([]))
+ paint(container)
+ expect(scrollRoot(container).scrollTop).toBe(0)
+ rerender(list(transcript.slice(0, 1)))
+ paint(container)
+ expect(scrollRoot(container).scrollTop).toBe(0)
+ fireEvent.scroll(scrollRoot(container))
+ rerender(list(transcript))
+ paint(container)
+ expect(distanceFromBottom(container)).toBeLessThanOrEqual(NATIVE_CHAT_FOLLOW_REARM_PX)
+ expect(windowState(container).indexes.length).toBeLessThan(TRANSCRIPT_LENGTH / 4)
+ })
+
+ it.each(['reader', 'jump'] as const)('rearms growth and append following via %s', (rearm) => {
+ setMeasuredTail(4)
+ const { container, rerender } = render(streamingList(4))
+ paint(container)
+ const scroller = scrollRoot(container)
+ fireEvent.scroll(scroller)
+ const parkedAt = scroller.scrollTop - 22
+ scrollTranscript(container, parkedAt)
+ setMeasuredTail(5)
+ rerender(streamingList(5))
+ paint(container)
+ expect(scroller.scrollTop).toBe(parkedAt)
+
+ if (rearm === 'reader') {
+ scrollTranscript(
+ container,
+ scroller.scrollHeight - scroller.clientHeight - NATIVE_CHAT_FOLLOW_REARM_PX
+ )
+ } else {
+ fireEvent.click(screen.getByRole('button', { name: /jump to latest/i }))
+ }
+ paint(container)
+ expect(screen.queryByRole('button', { name: /jump to latest/i })).toBeNull()
+ for (let step = 6; step <= 8; step += 1) {
+ setMeasuredTail(step)
+ rerender(streamingList(step))
+ paint(container)
+ expect(distanceFromBottom(container)).toBeLessThanOrEqual(NATIVE_CHAT_FOLLOW_REARM_PX)
+ expect(windowState(container).indexes.length).toBeLessThan(TRANSCRIPT_LENGTH / 4)
+ }
+ rerender(list([...transcriptAt(8), marker(TRANSCRIPT_LENGTH)]))
+ paint(container)
+ expect(distanceFromBottom(container)).toBeLessThanOrEqual(NATIVE_CHAT_FOLLOW_REARM_PX)
+ })
+
+ it('preserves the visible row anchor across prepends while detached', () => {
+ const { container, rerender } = render(list(transcript))
+ paint(container)
+ const readingAt = 2000
+ scrollTranscript(container, readingAt)
+ paint(container)
+
+ const earlier = Array.from({ length: 10 }, (_, index) => marker(index - 10))
+ rerender(list([...earlier, ...transcript]))
+ paint(container)
+ expect(scrollRoot(container).scrollTop).toBe(readingAt + earlier.length * ROW_PITCH_PX)
+ expect(windowState(container).indexes.length).toBeLessThan(TRANSCRIPT_LENGTH / 4)
+ expect(screen.getByRole('button', { name: /jump to latest/i })).toBeInTheDocument()
+ })
+
+ it('compensates a measurement entirely above the viewport without reattaching', () => {
+ const { container, rerender } = render(list(transcript))
+ paint(container)
+ const scroller = scrollRoot(container)
+ fireEvent.scroll(scroller)
+ const readingAt = 2000
+ scrollTranscript(container, readingAt)
+ paint(container)
+ const aboveIndex = windowState(container).indexes[0]!
+ expect((aboveIndex + 1) * ROW_PITCH_PX).toBeLessThan(readingAt)
+ for (const growth of [100, 200]) {
+ measuredRowHeights = Array.from({ length: TRANSCRIPT_LENGTH }, (_, index) =>
+ index === aboveIndex ? ROW_PX + growth : ROW_PX
+ )
+ paint(container)
+ expect(scroller.scrollTop).toBe(readingAt + growth)
+ }
+ rerender(list(appendedTranscript(1)))
+ paint(container)
+ expect(scroller.scrollTop).toBe(readingAt + 200)
+ expect(windowState(container).indexes.length).toBeLessThan(TRANSCRIPT_LENGTH / 4)
+ })
+
it('keeps following when a pin echo arrives after the document grows', () => {
setMeasuredTail(0)
const { container } = render(streamingList(0))
@@ -657,6 +828,8 @@ describe('a row growing in place while the view is pinned to the bottom', () =>
expect(scroller.scrollTop).toBe(pinnedAt)
expect(screen.queryByRole('button', { name: /jump to latest/i })).toBeNull()
+ paint(container)
+ expect(distanceFromBottom(container)).toBeLessThanOrEqual(NATIVE_CHAT_FOLLOW_REARM_PX)
})
it('settles a pending end reconcile after the reader keeps scrolling away', async () => {
diff --git a/src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts b/src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts
index fa23669e931..0dc34b7eb30 100644
--- a/src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts
+++ b/src/renderer/src/components/native-chat/native-chat-autoscroll.test.ts
@@ -5,13 +5,23 @@ import {
nextFollowingEnd,
shouldLoadEarlier,
shouldShowJumpToLatest,
- NATIVE_CHAT_BOTTOM_THRESHOLD_PX
+ NATIVE_CHAT_BOTTOM_THRESHOLD_PX,
+ NATIVE_CHAT_FOLLOW_REARM_PX
} from './native-chat-autoscroll'
const atBottom = { scrollTop: 952, scrollHeight: 1000, clientHeight: 48 }
const scrolledUp = { scrollTop: 0, scrollHeight: 1000, clientHeight: 48 }
const noOverflow = { scrollTop: 0, scrollHeight: 48, clientHeight: 48 }
+/** A view parked exactly `distance` px above the end of the same document. */
+function parkedAbove(distance: number): {
+ scrollTop: number
+ scrollHeight: number
+ clientHeight: number
+} {
+ return { scrollTop: 952 - distance, scrollHeight: 1000, clientHeight: 48 }
+}
+
describe('distanceFromBottom', () => {
it('is zero at the exact bottom and never negative', () => {
expect(distanceFromBottom(atBottom)).toBe(0)
@@ -48,7 +58,8 @@ describe('shouldShowJumpToLatest', () => {
// The browser reports application writes as ordinary scroll events. Explicit
// marks distinguish their delayed echoes from reader movement after growth.
describe('nextFollowingEnd', () => {
- const following = { following: true, programmatic: false, atEnd: true }
+ const following = { following: true, programmatic: false, geometry: parkedAbove(0) }
+ const wellAway = parkedAbove(400)
it('follows when the reader reaches the end', () => {
expect(nextFollowingEnd(following)).toBe(true)
@@ -58,15 +69,44 @@ describe('nextFollowingEnd', () => {
// the end runs away from an offset the transcript itself pinned. That is not a
// reader leaving, and treating it as one strands them mid-transcript.
it('keeps following when a delayed application scroll arrives after growth', () => {
- expect(nextFollowingEnd({ ...following, programmatic: true, atEnd: false })).toBe(true)
+ expect(nextFollowingEnd({ ...following, programmatic: true, geometry: wellAway })).toBe(true)
})
it('treats an unmarked offset away from the end as the reader leaving', () => {
- expect(nextFollowingEnd({ ...following, atEnd: false })).toBe(false)
+ expect(nextFollowingEnd({ ...following, geometry: wellAway })).toBe(false)
})
- it('does not re-attach a detached reader from an application write', () => {
- expect(nextFollowingEnd({ following: false, programmatic: true, atEnd: false })).toBe(false)
+ it.each([0, NATIVE_CHAT_FOLLOW_REARM_PX, 400])(
+ 'does not reattach a detached reader from an application write %i px from the end',
+ (distance) => {
+ expect(
+ nextFollowingEnd({ following: false, programmatic: true, geometry: parkedAbove(distance) })
+ ).toBe(false)
+ }
+ )
+
+ // The jump affordance's wider band must not decide whether a reader follows.
+ it('lets the reader park just inside the near-bottom band', () => {
+ expect(NATIVE_CHAT_FOLLOW_REARM_PX).toBeLessThan(NATIVE_CHAT_BOTTOM_THRESHOLD_PX)
+ const parked = parkedAbove(NATIVE_CHAT_BOTTOM_THRESHOLD_PX - 1)
+ expect(nextFollowingEnd({ ...following, geometry: parked })).toBe(false)
+ expect(isNearBottom(parked)).toBe(true)
+ expect(shouldShowJumpToLatest(false, parked)).toBe(false)
+ })
+
+ it('re-arms at the band and not one pixel past it', () => {
+ const detached = { following: false, programmatic: false }
+ expect(
+ nextFollowingEnd({ ...detached, geometry: parkedAbove(NATIVE_CHAT_FOLLOW_REARM_PX) })
+ ).toBe(true)
+ expect(
+ nextFollowingEnd({ ...detached, geometry: parkedAbove(NATIVE_CHAT_FOLLOW_REARM_PX + 1) })
+ ).toBe(false)
+ })
+
+ // Sub-pixel and zoom rounding put the true end a fraction short of exact.
+ it('holds follow through rounding noise at the end', () => {
+ expect(nextFollowingEnd({ ...following, geometry: parkedAbove(1.5) })).toBe(true)
})
})
diff --git a/src/renderer/src/components/native-chat/native-chat-autoscroll.ts b/src/renderer/src/components/native-chat/native-chat-autoscroll.ts
index f07aeeb6671..a8f2e54b22f 100644
--- a/src/renderer/src/components/native-chat/native-chat-autoscroll.ts
+++ b/src/renderer/src/components/native-chat/native-chat-autoscroll.ts
@@ -11,9 +11,7 @@ export type ScrollGeometry = {
clientHeight: number
}
-/** Pixels from the bottom within which we treat the view as "at the bottom" and
- * keep it pinned as content arrives. A small slack absorbs sub-pixel rounding
- * and the height jitter of a streaming last message. */
+/** Hide the jump affordance while the latest output is still nearby. */
export const NATIVE_CHAT_BOTTOM_THRESHOLD_PX = 48
/** Distance in px from the bottom edge of the scroll range. */
@@ -21,8 +19,7 @@ export function distanceFromBottom(geometry: ScrollGeometry): number {
return Math.max(0, geometry.scrollHeight - geometry.clientHeight - geometry.scrollTop)
}
-/** True when the viewport is close enough to the bottom that new content should
- * keep it pinned (auto-scroll "attached"). */
+/** Whether the viewport is inside the requested distance from the bottom. */
export function isNearBottom(
geometry: ScrollGeometry,
threshold: number = NATIVE_CHAT_BOTTOM_THRESHOLD_PX
@@ -43,22 +40,26 @@ export function shouldShowJumpToLatest(
return distanceFromBottom(geometry) > threshold
}
+/** Allow bottom rounding noise without following a reader who moved up a line. */
+export const NATIVE_CHAT_FOLLOW_REARM_PX = 4
+
export type FollowIntent = {
following: boolean
/** Whether the scroll event matches an offset the application registered. */
programmatic: boolean
- atEnd: boolean
+ geometry: ScrollGeometry
}
/** Whether the transcript should still follow the end after this offset.
*
* Application writes preserve intent even when their delayed events arrive
- * after the end moved. Reader events detach away from the end and reattach at it. */
+ * after the end moved. Reader events detach away from the end and reattach at
+ * it — against the re-arm band, never the wider near-bottom one. */
export function nextFollowingEnd(intent: FollowIntent): boolean {
if (intent.programmatic) {
return intent.following
}
- return intent.atEnd
+ return isNearBottom(intent.geometry, NATIVE_CHAT_FOLLOW_REARM_PX)
}
/** Distance from the top within which the transcript pages in older history. */
diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts b/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts
index 0f690bce57e..c54cbf54e85 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-scroll.ts
@@ -21,7 +21,6 @@ import {
type UIEventHandler
} from 'react'
import {
- isNearBottom,
nextFollowingEnd,
shouldLoadEarlier,
shouldShowJumpToLatest,
@@ -89,7 +88,7 @@ export function useNativeChatTranscriptScroll({
const following = nextFollowingEnd({
following: followingRef.current,
programmatic,
- atEnd: isNearBottom(geometry)
+ geometry
})
followingRef.current = following
if (!programmatic) {
diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx
index da19a51a9c3..82166952670 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx
+++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.options.test.tsx
@@ -67,7 +67,7 @@ afterEach(() => {
})
describe('native chat transcript virtualizer contract', () => {
- it('configures prepend anchoring and matching bottom-follow behavior', () => {
+ it('retains prepend anchoring without independently following the end', () => {
renderHook(() =>
useNativeChatTranscriptWindow({
scrollRef: { current: null },
@@ -78,8 +78,8 @@ describe('native chat transcript virtualizer contract', () => {
expect(virtualizerMock.options.current).toMatchObject({
anchorTo: 'end',
- followOnAppend: true,
- scrollEndThreshold: 48
+ followOnAppend: false,
+ scrollEndThreshold: -1
})
})
diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts
index 24b63e1fbae..30227bf47c5 100644
--- a/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts
+++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-window.ts
@@ -1,11 +1,8 @@
// DOM windowing for the transcript: only the rows near the viewport are mounted,
// the rest are reserved as estimated height.
//
-// Anchoring is the library's, not ours. `anchorTo: 'end'` captures the row at the
-// current offset before a count change and re-resolves its position afterwards,
-// which is what keeps a "load earlier" prepend from yanking the view;
-// `followOnAppend` + `scrollEndThreshold` keep a reader who is already at the
-// bottom pinned there as a turn streams.
+// The virtualizer owns visible-row anchoring; the transcript scroll hook owns
+// end-follow intent. Geometry alone must never reattach a parked reader.
//
// Every measurement here ends up in the scroll container's own coordinate space,
// which means `offsetTop` / `offsetHeight` rather than a bounding rect. The
@@ -16,7 +13,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { elementScroll, useVirtualizer, type VirtualItem } from '@tanstack/react-virtual'
import { createProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks'
-import { NATIVE_CHAT_BOTTOM_THRESHOLD_PX } from './native-chat-autoscroll'
import { NATIVE_CHAT_ROW_GAP_PX } from './native-chat-row-height-estimate'
import { nativeChatPinnedRowIndexes, nativeChatTranscriptRange } from './native-chat-pinned-rows'
import type { NativeChatTranscriptSlot } from './native-chat-transcript-slots'
@@ -135,8 +131,9 @@ export function useNativeChatTranscriptWindow({
gap: NATIVE_CHAT_ROW_GAP_PX,
scrollMargin,
anchorTo: 'end',
- followOnAppend: true,
- scrollEndThreshold: NATIVE_CHAT_BOTTOM_THRESHOLD_PX,
+ followOnAppend: false,
+ // Distances are nonnegative: disable geometry-only resize pinning, retaining prepend anchoring.
+ scrollEndThreshold: -1,
// Every virtualizer write uses this public adapter, including measurement
// adjustments and prepend anchoring, so scroll events have one provenance.
scrollToFn: (offset, options, instance) => {
@@ -164,6 +161,10 @@ export function useNativeChatTranscriptWindow({
}
})
+ // Growing a row that spans the viewport changes content below the reader's anchor.
+ virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) =>
+ item.end <= (instance.scrollOffset ?? 0)
+
const finishReaderTakeover = useCallback(() => {
if (readerTakeoverFrameRef.current !== null) {
window.cancelAnimationFrame(readerTakeoverFrameRef.current)
From b61a2347b99cc5d6c001473108c621811a35e33b Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Mon, 14 Sep 2026 17:52:21 -0700
Subject: [PATCH 05/34] feat(design-system): gate renderer UI with @shadcn/lint
(#20731)
* feat(design-system): gate renderer UI with @shadcn/lint
Wires shadcn-ui/lint's Oxlint plugin into the two places this repo already
ratchets: the changed-lines PR gate for rules the renderer can't satisfy
today, and `pnpm lint` for the one that is already at zero.
- config/oxlint-design-system.json: no-restyle (layout allowed),
no-raw-colors, require-static-classes -- scoped to src/renderer/**/*.tsx,
run over added lines only. Measured at 10 findings across the last 60
commits (771 changed files), so it holds the line without a migration.
- config/oxlint-dead-classes.json: no-unknown-classes repo-wide, with the
renderer's plain-CSS hook namespaces allow-listed. Now at zero.
- no-inline-styles and no-arbitrary-values stay off; STYLEGUIDE says why.
Fixes the three live bugs the linter found:
- `--editor-surface` never reached `@theme inline`, so `bg-editor-surface`
generated no CSS -- 12 editor/artifact/notebook panes fell through to the
page background instead of #1e1e1e in dark mode.
- `scrollbar-none` is not a Tailwind utility and was declared nowhere, so
the remote file browser breadcrumbs showed the scrollbar they meant to
hide. Declared as a real `@utility`.
- Notebook markdown cells used `markdown-preview-body`, which no stylesheet
defines; the styled class is `markdown-body`. They rendered unstyled.
* ci: run the dead-class gate in PR CI
`pnpm lint` gained check:dead-classes, and pr-workflow-lint-parity requires
every `pnpm lint` step to have a matching step in pr.yml.
* fix(notebook): keep markdown theme selectors working
---
.github/workflows/pr.yml | 3 +
AGENTS.md | 3 +-
config/oxlint-dead-classes.json | 77 ++
config/oxlint-design-system.json | 54 ++
config/scripts/check-changed-code-quality.mjs | 6 +
package.json | 5 +-
pnpm-lock.yaml | 776 +++++++++++++++++-
pnpm-workspace.yaml | 1 +
src/renderer/src/assets/main.css | 13 +
.../assets/theme-utility-generation.test.ts | 19 +
.../src/components/editor/IpynbCellEditor.tsx | 27 +-
11 files changed, 975 insertions(+), 9 deletions(-)
create mode 100644 config/oxlint-dead-classes.json
create mode 100644 config/oxlint-design-system.json
create mode 100644 src/renderer/src/assets/theme-utility-generation.test.ts
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 2f54f2ae785..7418e8f80aa 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -170,6 +170,9 @@ jobs:
- name: Check reliability gate manifest
run: pnpm run check:reliability-gates
+ - name: Enforce dead design-system classes
+ run: pnpm run check:dead-classes
+
- name: Check VM runtime rollback compatibility
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
diff --git a/AGENTS.md b/AGENTS.md
index 74c049a49fd..0c11d13a9ca 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
# Design System
-All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section.
+All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Most of it is linted: `pnpm run check:code-quality:changed` fails on new restyles of a `components/ui/` primitive, raw palette colors, and computed `className` strings; `pnpm lint` fails on any class Tailwind cannot generate. See the Enforcement section of the style guide before suppressing either. Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section.
## Electron UI Validation
@@ -46,6 +46,7 @@ Avoid type assertions except `as const`. Unavoidable casts need a line-specific
- **Typecheck**: `pnpm tc` (or `tc:node` / `tc:cli` / `tc:web`)
- **Test**: `pnpm test [path/to/file.test.ts]`
- **Lint**: `oxlint`, or `pnpm run check:code-quality:changed` for changed files (full `pnpm lint` is slow); format with `pnpm format`
+- **Design system**: `pnpm run lint:design-system` for the full renderer report (not a gate); the changed-lines gate above is what CI enforces
# Considerations
diff --git a/config/oxlint-dead-classes.json b/config/oxlint-dead-classes.json
new file mode 100644
index 00000000000..ad58853134f
--- /dev/null
+++ b/config/oxlint-dead-classes.json
@@ -0,0 +1,77 @@
+{
+ "$schema": "../node_modules/oxlint/configuration_schema.json",
+ "plugins": [],
+ "categories": {
+ "correctness": "off",
+ "suspicious": "off",
+ "pedantic": "off",
+ "perf": "off",
+ "style": "off",
+ "restriction": "off",
+ "nursery": "off"
+ },
+ "jsPlugins": [
+ {
+ "name": "shadcn",
+ "specifier": "@shadcn/lint"
+ }
+ ],
+ "settings": {
+ "shadcn": {
+ "note": "See docs/STYLEGUIDE.md for the role each token and primitive plays."
+ }
+ },
+ "rules": {},
+ "overrides": [
+ {
+ "files": ["**/src/renderer/**/*.tsx"],
+ "rules": {
+ "shadcn/no-unknown-classes": [
+ "error",
+ {
+ "allow": [
+ "agent-map-*",
+ "comment-md-*",
+ "compact-agent-*",
+ "feature-wall-*",
+ "is-*",
+ "markdown-annotation-*",
+ "markdown-body",
+ "markdown-dark",
+ "markdown-doc-link*",
+ "markdown-light",
+ "markdown-preview",
+ "markdown-preview-search*",
+ "markdown-preview-shell",
+ "markdown-review-*",
+ "markdown-toc-*",
+ "mobile-browser-driver-banner",
+ "mobile-driver-banner",
+ "native-chat-*",
+ "orca-*",
+ "pdfViewer",
+ "popover-scroll-content",
+ "popover-wheel-scroll",
+ "ravpr-*",
+ "ravs-*",
+ "scrollbar-editor",
+ "scrollbar-sleek",
+ "scrollbar-sleek-lg",
+ "scrollbar-sleek-parent",
+ "toaster",
+ "worktree-sidebar-scrollbar",
+ "xterm-*"
+ ]
+ }
+ ]
+ }
+ },
+ {
+ "files": ["**/*.test.tsx"],
+ "rules": {
+ "shadcn/no-unknown-classes": "off"
+ }
+ }
+ ],
+ "ignorePatterns": ["**/node_modules", "**/dist", "**/out", "cloud/**", "mobile/**"]
+}
diff --git a/config/oxlint-design-system.json b/config/oxlint-design-system.json
new file mode 100644
index 00000000000..23b8df517d3
--- /dev/null
+++ b/config/oxlint-design-system.json
@@ -0,0 +1,54 @@
+{
+ "$schema": "../node_modules/oxlint/configuration_schema.json",
+ "plugins": [],
+ "categories": {
+ "correctness": "off",
+ "suspicious": "off",
+ "pedantic": "off",
+ "perf": "off",
+ "style": "off",
+ "restriction": "off",
+ "nursery": "off"
+ },
+ "jsPlugins": [
+ {
+ "name": "shadcn",
+ "specifier": "@shadcn/lint"
+ }
+ ],
+ "settings": {
+ "shadcn": {
+ "note": "See docs/STYLEGUIDE.md for the role each token and primitive plays."
+ }
+ },
+ "rules": {},
+ "overrides": [
+ {
+ "files": ["**/src/renderer/**/*.tsx"],
+ "rules": {
+ "shadcn/no-restyle": [
+ "error",
+ {
+ "allow": ["layout"]
+ }
+ ],
+ "shadcn/no-raw-colors": [
+ "error",
+ {
+ "allow": ["shadow-floating"]
+ }
+ ],
+ "shadcn/require-static-classes": "error"
+ }
+ },
+ {
+ "files": ["**/*.test.tsx"],
+ "rules": {
+ "shadcn/no-restyle": "off",
+ "shadcn/no-raw-colors": "off",
+ "shadcn/require-static-classes": "off"
+ }
+ }
+ ],
+ "ignorePatterns": ["**/node_modules", "**/dist", "**/out", "cloud/**", "mobile/**"]
+}
diff --git a/config/scripts/check-changed-code-quality.mjs b/config/scripts/check-changed-code-quality.mjs
index a1b5b2fc88a..31b6d24953a 100644
--- a/config/scripts/check-changed-code-quality.mjs
+++ b/config/scripts/check-changed-code-quality.mjs
@@ -29,6 +29,12 @@ export const OXLINT_SCANS = [
{
label: 'React Doctor',
args: ['--config', 'config/oxlint-react-doctor.json']
+ },
+ {
+ // Why changed-lines only: the renderer carries ~4.7k pre-existing restyle/raw-color
+ // findings. Gating added lines holds the line without a repo-wide migration.
+ label: 'design system',
+ args: ['--config', 'config/oxlint-design-system.json']
}
]
diff --git a/package.json b/package.json
index a9f12759f74..174a8718641 100644
--- a/package.json
+++ b/package.json
@@ -14,13 +14,15 @@
"audit:perf": "oxlint --config config/oxlint-performance-audit.json --format json src",
"test:perf:contracts": "vitest run --config config/vitest.performance.config.ts",
"format": "oxfmt --write .",
- "lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:readme-local-links && pnpm run verify:rpc-params-catalog && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-runtime-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage",
+ "lint": "oxlint && pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run check:reliability-gates && pnpm run check:dead-classes && pnpm run check:max-lines-ratchet && pnpm run check:ts-nocheck-ratchet && pnpm run check:runtime-electron-ratchet && pnpm run check:readme-local-links && pnpm run verify:rpc-params-catalog && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-runtime-catalog && pnpm run verify:localization-extraction && pnpm run verify:localization-coverage",
"audit:code-quality": "pnpm run audit:code-quality:native && pnpm run audit:code-quality:type-aware && pnpm run audit:react-doctor",
"audit:code-quality:native": "oxlint --config config/oxlint-code-quality-native-plugins.json src config tests mobile --deny-warnings",
"audit:code-quality:type-aware": "oxlint --type-aware --config config/oxlint-code-quality-type-aware.json src config tests --deny-warnings",
"audit:react-doctor": "pnpm dlx react-doctor@0.9.1 . --yes --no-supply-chain --no-telemetry --blocking none",
"audit:dead-code": "pnpm dlx knip@5.88.1 --config config/knip.json",
"check:code-quality:changed": "node config/scripts/check-changed-code-quality.mjs",
+ "check:dead-classes": "oxlint --config config/oxlint-dead-classes.json src/renderer",
+ "lint:design-system": "oxlint --config config/oxlint-design-system.json src/renderer",
"check:react-doctor:changed": "node config/scripts/check-react-doctor-changed.mjs",
"check:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs --check",
"doctor": "pnpm dlx react-doctor@0.9.1 . --no-telemetry",
@@ -199,6 +201,7 @@
"@monaco-editor/react": "^4.7.0",
"@playwright/test": "^1.59.1",
"@sanity/diff-match-patch": "^3.2.0",
+ "@shadcn/lint": "^0.1.0",
"@stablyai/playwright-test": "^2.1.14",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-virtual": "^3.14.10",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b2f6425aafd..6e9de58b228 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -219,6 +219,9 @@ importers:
'@sanity/diff-match-patch':
specifier: ^3.2.0
version: 3.2.0
+ '@shadcn/lint':
+ specifier: ^0.1.0
+ version: 0.1.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2)
'@stablyai/playwright-test':
specifier: ^2.1.14
version: 2.1.14(@playwright/test@1.59.1)(zod@4.5.4)
@@ -718,6 +721,12 @@ packages:
'@braintree/sanitize-url@7.1.2':
resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==}
+ '@cacheable/memory@2.2.0':
+ resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==}
+
+ '@cacheable/utils@2.5.0':
+ resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==}
+
'@chevrotain/types@11.1.2':
resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==}
@@ -978,6 +987,40 @@ packages:
cpu: [x64]
os: [win32]
+ '@eslint-community/eslint-utils@4.10.1':
+ resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+ peerDependencies:
+ eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
+
+ '@eslint-community/regexpp@4.12.2':
+ resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
+ engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
+
+ '@eslint/config-array@0.23.5':
+ resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ '@eslint/config-helpers@0.7.0':
+ resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ '@eslint/core@0.17.0':
+ resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@eslint/core@1.2.1':
+ resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ '@eslint/object-schema@3.0.5':
+ resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
+ '@eslint/plugin-kit@0.7.3':
+ resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
'@floating-ui/core@1.7.5':
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
@@ -1004,6 +1047,26 @@ packages:
peerDependencies:
hono: ^4
+ '@humanfs/core@0.19.2':
+ resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanfs/node@0.16.8':
+ resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanfs/types@0.15.0':
+ resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==}
+ engines: {node: '>=18.18.0'}
+
+ '@humanwhocodes/module-importer@1.0.1':
+ resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
+ engines: {node: '>=12.22'}
+
+ '@humanwhocodes/retry@0.4.3':
+ resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
+ engines: {node: '>=18.18'}
+
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
@@ -1164,6 +1227,15 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@keyv/bigmap@1.3.1':
+ resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==}
+ engines: {node: '>= 18'}
+ peerDependencies:
+ keyv: ^5.6.0
+
+ '@keyv/serialize@1.1.1':
+ resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
+
'@linear/sdk@82.1.0':
resolution: {integrity: sha512-Ok7o+LqXaenx6Um58NQqjQoQanDsCgAIe9yNgpVbqRSh5APz3Ds1kZUz2vWmSNTNATFZm1zDQtEktTMga2X7UQ==}
engines: {node: '>=18.x'}
@@ -1338,42 +1410,84 @@ packages:
cpu: [arm]
os: [android]
+ '@oxc-parser/binding-android-arm-eabi@0.148.0':
+ resolution: {integrity: sha512-pHASv9g5pASxb7akHERZNSkrEqPhFaUix98o7d9hbTpolnnFWl7UiRrcMhCsV1+iVO4/cJwKsbKRJTFNs2tdBQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [android]
+
'@oxc-parser/binding-android-arm64@0.141.0':
resolution: {integrity: sha512-a4XDQ27ZT7e7zwAlxJDTiCA7IBGWDuy2+MhFq85Of7XlBSmpkfcBFml11q0Zx6f7RMuI0B4xCtt2ytBS4yOptg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
+ '@oxc-parser/binding-android-arm64@0.148.0':
+ resolution: {integrity: sha512-sg/6Ez0KdAygsu0POELux9wN1Po2CP93WY8eNl4DBKIGprsd4QSHBXOb471Pu9i2OCD5sLkISSb2agZEhVn2Zw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [android]
+
'@oxc-parser/binding-darwin-arm64@0.141.0':
resolution: {integrity: sha512-m/kVk6rzYmBeHYnz+1Y5fod00AVTTxMbC71azFfm/zjx1j9XxwKtA0+VfkKuVMC8rbghb9TtfevnuWZa9OuPEg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
+ '@oxc-parser/binding-darwin-arm64@0.148.0':
+ resolution: {integrity: sha512-yiSJmzGUvCUaJT8X3j40gVcX+ckuHQMuiOtF8DvzTs5+JtB/7XuHFPp4M+vv5u+HlBtDUd4Ks5pyHpWz8mfnkg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [darwin]
+
'@oxc-parser/binding-darwin-x64@0.141.0':
resolution: {integrity: sha512-o0X+6KZlfucWU/v5oKRQPwdFXsXAjW8jmpo/Gpw/qyKsbKtlfkHoeH9Bjp/m13TwjewvJnCkwF0DWzgpC4HjTQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
+ '@oxc-parser/binding-darwin-x64@0.148.0':
+ resolution: {integrity: sha512-6ZeklaamrMy4H2JmhvcJg6iip59tYILtuLaILxyAHT3l5FDxnI5ihVievAft5ZmAbqtlWHErOi1OpJK8gy1wcA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [darwin]
+
'@oxc-parser/binding-freebsd-x64@0.141.0':
resolution: {integrity: sha512-W5KbTnNkTMMMylqj6dYqnsXvkmESVPodPKYLJ5zdzIPdl9fUJtolkpUeSzYEbGGYB4a4A4avl3EePnZ/wLIdJg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
+ '@oxc-parser/binding-freebsd-x64@0.148.0':
+ resolution: {integrity: sha512-vFsPx+a/qFECPnz/H8nC6x6MDvnWscLTCo/5muojEF54ERUq1kdgbvnWo95YnkhjF9sTIcG/uDxQBh1gffaufQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [freebsd]
+
'@oxc-parser/binding-linux-arm-gnueabihf@0.141.0':
resolution: {integrity: sha512-g3dtbJa8zeOGK36Sr9cQavsdi5H/ie2hVjrSjIxsNAR1qZA40ZYVXnfdfoMAlq8CmB9qFL1yhsSCUHeNmdmt8w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
+ '@oxc-parser/binding-linux-arm-gnueabihf@0.148.0':
+ resolution: {integrity: sha512-eOr3M+6iGbbxNL4PSS0VtsyQ2eOUxSBh00BqO22SbolDimPSYsBuLr/LCrZBkiqW2BoabhR6V4R8jrRAay7hjg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
'@oxc-parser/binding-linux-arm-musleabihf@0.141.0':
resolution: {integrity: sha512-e6hwQqd+3lvP13G2jxvFpoA7dzHcFLN+Mq47JCVMtdNHbbyBRo756JCtbbJH6ca8inTfyqZoqBmS3vhQlzAK2w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
+ '@oxc-parser/binding-linux-arm-musleabihf@0.148.0':
+ resolution: {integrity: sha512-58ZKDw0mQRbCNfrd2IDyV4o8T7enzGERJn41BH2tjrZVGyiKiFzcfDicuB7Zcpb/1xIOrObovr8Dja6lZi8dLw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
'@oxc-parser/binding-linux-arm64-gnu@0.141.0':
resolution: {integrity: sha512-vXz2BLAuypA+4MLyBg94pzEo6THVnzYnCtAjXoihIIQo0t2pnp/AmW+SH1EI+4VbuJnC//KplIJ5yyaCGua4jA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1381,6 +1495,13 @@ packages:
os: [linux]
libc: [glibc]
+ '@oxc-parser/binding-linux-arm64-gnu@0.148.0':
+ resolution: {integrity: sha512-Fnu95O4eZ5i++GPvIzBEZ8y4ddTLR+D9paYa8JRaRk6ZK7nHQiWP5xtrhcPQsXqgat1d7sU/d5rbbI0p1FTHSQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
'@oxc-parser/binding-linux-arm64-musl@0.141.0':
resolution: {integrity: sha512-jMkS/EztNW34HKsXIaT/SoHcmtocq/vWhwFOVduF9kduuuRIVwfwQ6uxzIO+qPKSXdd2TXt54of0BJ2zFMXnmw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1388,6 +1509,13 @@ packages:
os: [linux]
libc: [musl]
+ '@oxc-parser/binding-linux-arm64-musl@0.148.0':
+ resolution: {integrity: sha512-3CQy/BMdx7N7H3qrcPxUL+a2CwUZodUcf6oq8iJuNZ9C6Ol1aq3mcWzsgySJ7CHFLvpX21ZDPp1r1X0QLbu/AQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
'@oxc-parser/binding-linux-ppc64-gnu@0.141.0':
resolution: {integrity: sha512-vo+MR+n3zQJ6Mq92hiP084NZcgDv5iJlVR02gMf28neMvVT1tKVm7VeiW/DxhdqOi3QLeaXIk9cUcLL1qrkngw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1395,6 +1523,13 @@ packages:
os: [linux]
libc: [glibc]
+ '@oxc-parser/binding-linux-ppc64-gnu@0.148.0':
+ resolution: {integrity: sha512-9LkaYvfiF8hMOw900csAvkf1oxE8XlmMeGowu5BcastSSwV8mKvKRMNU7HsV+ycyj1dQD8pX5qgOw8ja6SJacg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
'@oxc-parser/binding-linux-riscv64-gnu@0.141.0':
resolution: {integrity: sha512-oh80w+7RuiO5gBp9Jnoa/H8Qlt3JsHL2MkW+0dwEdlDMdslVZX/YsekSK6EeyEenY66/mhCfypsNATQ7Ph3qlQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1402,6 +1537,13 @@ packages:
os: [linux]
libc: [glibc]
+ '@oxc-parser/binding-linux-riscv64-gnu@0.148.0':
+ resolution: {integrity: sha512-2GBiM9h26dR4WJfhoMvnFMnFLf7m/kYs4UMqjvrOfQG4BV1nuTJDH22Zc2MQr3INZF7nSKYQ6xlhD3hQ7A6gug==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
'@oxc-parser/binding-linux-riscv64-musl@0.141.0':
resolution: {integrity: sha512-LOyEmFA8sCnYbEXP1+iQvCC/P1YXHMA/t6x1Ksp0Y9VwhLFsiBJFzV1zIxrOIE2LKaGGhDjQ29xq9cbq6omDXA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1409,6 +1551,13 @@ packages:
os: [linux]
libc: [musl]
+ '@oxc-parser/binding-linux-riscv64-musl@0.148.0':
+ resolution: {integrity: sha512-uPqZexvKJmEgq4mAu36qe2xTfXZE7oyik1R7KtZ5tl8qKlq1U1fIqTFRUEBZqRGvforoTrGIpatRzcoPKO66RA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
'@oxc-parser/binding-linux-s390x-gnu@0.141.0':
resolution: {integrity: sha512-3wnwk/l1CvszVE5TJR1wSl/zSEfydRqrNhn6s7Vr9IzSJpUQIroqVsIoPARHRFA+FQwkxAFDAHDAasa7v8OobQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1416,6 +1565,13 @@ packages:
os: [linux]
libc: [glibc]
+ '@oxc-parser/binding-linux-s390x-gnu@0.148.0':
+ resolution: {integrity: sha512-9oUHvnTbp7ZraFsTC8PN6XhdhPSSxZumYvixWl7Smi353gEULvK6yV0sXNVrdFMHQeaDKFCi8TgDhNK7/A+Y+Q==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
'@oxc-parser/binding-linux-x64-gnu@0.141.0':
resolution: {integrity: sha512-qtyQVAAebFq57B2tifTlel3TgGqUtsYNI/e+p6aya9rN9lOZVTDvr215fGYSA9XWooxzMxDiVxkBLk2jQHbsOQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1423,6 +1579,13 @@ packages:
os: [linux]
libc: [glibc]
+ '@oxc-parser/binding-linux-x64-gnu@0.148.0':
+ resolution: {integrity: sha512-2qhDSJwKzbSZzF7lDqqk8sr/yXsmwr3PeUa4/nazIF+zFAYz1gVPEfC34GQtGxzJUUmklaYAL63368LEfrMeyw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
'@oxc-parser/binding-linux-x64-musl@0.141.0':
resolution: {integrity: sha512-SkGV1nKw40roEc94pv5EaaeH2ay14G6+roe8Q0wIUC1LcEKxzKW921h7+ZuZX0D3q2Mb/7aSFmxEVqnko3lPRw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1430,12 +1593,25 @@ packages:
os: [linux]
libc: [musl]
+ '@oxc-parser/binding-linux-x64-musl@0.148.0':
+ resolution: {integrity: sha512-qQoPDZUFV0bh9xA09XydmkjMBpgc1ukJuhMvzQ9QeVmFaHTS9W5TE5CoLmSl3QQyUP9OuHO3x/WPZTIIZPWR3Q==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
'@oxc-parser/binding-openharmony-arm64@0.141.0':
resolution: {integrity: sha512-cVgDM7n8QziQqOaP5hNgUYfMG7S/ZeuPxFWXnnHRv7rh025COk0rfQ6eEdKG3j/GaUuyvNZN4ifF1J8KmuXLLA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
+ '@oxc-parser/binding-openharmony-arm64@0.148.0':
+ resolution: {integrity: sha512-1UGbaQWEXUCLqAmaR5kwRDjx/R4S5LQKZkM9CHmaHkuKhriOF32aRLfS0jCRNE2yGQJLMEA1z9UucbBVqjXnDw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [openharmony]
+
'@oxc-parser/binding-wasm32-wasi@0.141.0':
resolution: {integrity: sha512-HggH++Fkn3OilBn+bs3jpgIFQa34oMAyUUHy0vpGum+gt1Eb5nyLc8dNU/RAPSw6lsLrx7ncKtHSZE+3Sp0l2g==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1447,18 +1623,36 @@ packages:
cpu: [arm64]
os: [win32]
+ '@oxc-parser/binding-win32-arm64-msvc@0.148.0':
+ resolution: {integrity: sha512-pWKdzRDNG2+NK4h/V6U/CYERcfYD6u28h5IB/VJVsrZaD3muvE58tUj22lieL5vLZ+XFi1GPv9YXckZbJZ9BLA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [win32]
+
'@oxc-parser/binding-win32-ia32-msvc@0.141.0':
resolution: {integrity: sha512-9UVWUOOCI/1YkiSSNjg2zyBJYM9E/t1A/8GNobd48JDn/fQ6mzxcVO3H08jb3rAaW/B1VBf8eCORTvSsO9T08g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
+ '@oxc-parser/binding-win32-ia32-msvc@0.148.0':
+ resolution: {integrity: sha512-i3p4x+mvwtjcE1J5HM6V7ggsbXiznExN/4MkNyOy3dfXrVV3bnkSfmZxvo6/84qCVX4ShkpNE1SKt9biIF31GQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ia32]
+ os: [win32]
+
'@oxc-parser/binding-win32-x64-msvc@0.141.0':
resolution: {integrity: sha512-HI/wsvbWT5RHHw5c37D0fEgeTd8/1Q4OJs5jUmEBc17VZFG6SsCIe4barq7NsAPPks/JW+3ayi3Rp+PQI5h4Kg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
+ '@oxc-parser/binding-win32-x64-msvc@0.148.0':
+ resolution: {integrity: sha512-Ye6vB7VQulghWYkYkECOBYFRVEizz4XyRTUAv+t8BuyurhKU7uD0P9eowL+mKG5Mf8MSYx+DI3Cm8SKZvYG7bQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [win32]
+
'@oxc-project/runtime@0.101.0':
resolution: {integrity: sha512-t3qpfVZIqSiLQ5Kqt/MC4Ge/WCOGrrcagAdzTcDaggupjiGxUx4nJF2v6wUCXWSzWHn5Ns7XLv13fCJEwCOERQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1469,6 +1663,9 @@ packages:
'@oxc-project/types@0.141.0':
resolution: {integrity: sha512-S4as7z0j0xQkXcJlyY5ehntwK8/wRkQb9Cyqw+J/N2rkWGQGK0SxD6X6DhQTc7qsxVTBxXbxZtBJh3mr3PtIzQ==}
+ '@oxc-project/types@0.148.0':
+ resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==}
+
'@oxfmt/binding-android-arm-eabi@0.65.0':
resolution: {integrity: sha512-M10Gs1SSpTNI6ahGx3M/OlIdUF4hkaP6OgUb+MS79t/Pgflk3r1nW5gPFqsZGUAXg0H1AfANT9AvLdBSTIhZKg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2644,6 +2841,15 @@ packages:
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
+ '@shadcn/lint@0.1.0':
+ resolution: {integrity: sha512-UDSxO4eQa8UAclN1tChum+L336CL2uB2ZLGYiJ7r/GDrYUBOKPWWNUVoAfh2dZs4LhcwJRCn62+fKG12eAy1FQ==}
+ engines: {node: '>=20.19'}
+ peerDependencies:
+ eslint: '>=9.30.0'
+ peerDependenciesMeta:
+ eslint:
+ optional: true
+
'@sindresorhus/is@4.6.0':
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
engines: {node: '>=10'}
@@ -3282,6 +3488,9 @@ packages:
'@types/http-cache-semantics@4.2.0':
resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==}
+ '@types/json-schema@7.0.15':
+ resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+
'@types/katex@0.16.8':
resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==}
@@ -3353,10 +3562,47 @@ packages:
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
+ '@typescript-eslint/parser@8.70.0':
+ resolution: {integrity: sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/project-service@8.70.0':
+ resolution: {integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/scope-manager@8.70.0':
+ resolution: {integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/tsconfig-utils@8.70.0':
+ resolution: {integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
'@typescript-eslint/types@8.60.0':
resolution: {integrity: sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@typescript-eslint/types@8.70.0':
+ resolution: {integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/typescript-estree@8.70.0':
+ resolution: {integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.1.0'
+
+ '@typescript-eslint/visitor-keys@8.70.0':
+ resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@typescript/typescript-aix-ppc64@7.0.2':
resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
engines: {node: '>=16.20.0'}
@@ -3575,6 +3821,11 @@ packages:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
+ acorn-jsx@5.3.2:
+ resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
+ peerDependencies:
+ acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
+
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
@@ -3596,6 +3847,9 @@ packages:
ajv:
optional: true
+ ajv@6.15.0:
+ resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==}
+
ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
@@ -3771,6 +4025,9 @@ packages:
resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==}
engines: {node: '>=8'}
+ cacheable@2.5.0:
+ resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==}
+
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@@ -3882,6 +4139,11 @@ packages:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
+ cn@0.2.6:
+ resolution: {integrity: sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ==}
+ engines: {node: '>=20'}
+ hasBin: true
+
code-block-writer@13.0.3:
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
@@ -4194,6 +4456,9 @@ packages:
babel-plugin-macros:
optional: true
+ deep-is@0.1.4:
+ resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+
deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
@@ -4447,15 +4712,37 @@ packages:
resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+ eslint-visitor-keys@3.4.3:
+ resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
+ engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
+
eslint-visitor-keys@5.0.1:
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+ eslint@10.10.0:
+ resolution: {integrity: sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+ hasBin: true
+ peerDependencies:
+ jiti: '*'
+ peerDependenciesMeta:
+ jiti:
+ optional: true
+
+ espree@11.2.0:
+ resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
esprima@4.0.1:
resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
engines: {node: '>=4'}
hasBin: true
+ esquery@1.7.0:
+ resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
+ engines: {node: '>=0.10'}
+
esrecurse@4.3.0:
resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
engines: {node: '>=4.0'}
@@ -4470,6 +4757,10 @@ packages:
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+ esutils@2.0.3:
+ resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
+ engines: {node: '>=0.10.0'}
+
etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
@@ -4524,6 +4815,12 @@ packages:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
+ fast-json-stable-stringify@2.1.0:
+ resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
+
+ fast-levenshtein@2.0.6:
+ resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
+
fast-sha256@1.3.0:
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
@@ -4561,6 +4858,9 @@ packages:
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
engines: {node: '>=18'}
+ file-entry-cache@11.1.5:
+ resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==}
+
filelist@1.0.6:
resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==}
@@ -4576,9 +4876,19 @@ packages:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
+ find-up@5.0.0:
+ resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
+ engines: {node: '>=10'}
+
flairup@1.0.0:
resolution: {integrity: sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==}
+ flat-cache@6.1.23:
+ resolution: {integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==}
+
+ flatted@3.4.4:
+ resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==}
+
form-data@4.0.6:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'}
@@ -4685,6 +4995,10 @@ packages:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
+ glob-parent@6.0.2:
+ resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
+ engines: {node: '>=10.13.0'}
+
glob@13.0.6:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22}
@@ -4738,6 +5052,10 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
+ hashery@1.5.1:
+ resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==}
+ engines: {node: '>=20'}
+
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
@@ -4798,6 +5116,12 @@ packages:
resolution: {integrity: sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==}
engines: {node: '>=16.9.0'}
+ hookified@1.15.1:
+ resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==}
+
+ hookified@2.2.0:
+ resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==}
+
hosted-git-info@4.1.0:
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
engines: {node: '>=10'}
@@ -4883,6 +5207,10 @@ packages:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
+ imurmurhash@0.1.4:
+ resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
+ engines: {node: '>=0.8.19'}
+
indent-string@4.0.0:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'}
@@ -5077,12 +5405,18 @@ packages:
resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
engines: {node: '>=16'}
+ json-schema-traverse@0.4.1:
+ resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
+
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
json-schema-typed@8.0.2:
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
+ json-stable-stringify-without-jsonify@1.0.1:
+ resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
+
json-stringify-safe@5.0.1:
resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
@@ -5111,6 +5445,9 @@ packages:
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+ keyv@5.6.0:
+ resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
+
khroma@2.1.0:
resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==}
@@ -5131,6 +5468,10 @@ packages:
lazy-val@1.0.5:
resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==}
+ levn@0.4.1:
+ resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
+ engines: {node: '>= 0.8.0'}
+
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
@@ -5224,6 +5565,10 @@ packages:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
+ locate-path@6.0.0:
+ resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
+ engines: {node: '>=10'}
+
lodash-es@4.18.1:
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
@@ -5576,6 +5921,9 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ natural-compare@1.4.0:
+ resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
+
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
@@ -5672,6 +6020,10 @@ packages:
resolution: {integrity: sha512-kCyjv6xdDY1W/jLWZ/L3QhhTlKUqDZMQ5+Jdlw12b3dXkKNpYBqqlMMj0YDQPShWFTMwgZI1hG14kN3XUDSg/A==}
hasBin: true
+ optionator@0.9.4:
+ resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
+ engines: {node: '>= 0.8.0'}
+
ora@8.2.0:
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
engines: {node: '>=18'}
@@ -5690,6 +6042,10 @@ packages:
resolution: {integrity: sha512-uFkGGr1KMWd6aWv9UAqooYrN78trw8MWWmoPvgWokfBEUq1+eiIQ+qfj3wokhy0fxtZWZk+0dHoS7/yRTJtd6w==}
engines: {node: ^20.19.0 || >=22.12.0}
+ oxc-parser@0.148.0:
+ resolution: {integrity: sha512-syxUKHeUll89RIABQADcI7sikYrwyssvA6gj4phSSIPezKVM8yMaLAiLLSc7fmzVvrwybfFGFbW5zme9sX87rg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+
oxfmt@0.65.0:
resolution: {integrity: sha512-SgS5VgnP42T0zl3zWD+xoH8FCqg1SAFnSRoOT/qeoa6gxcYIqrDMOmcXIg/EWSN92Du4ogB4riuKhKd6Y4CGhw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -5740,6 +6096,10 @@ packages:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
+ p-locate@5.0.0:
+ resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
+ engines: {node: '>=10'}
+
p-retry@4.6.2:
resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==}
engines: {node: '>=8'}
@@ -5892,6 +6252,10 @@ packages:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
+ prelude-ls@1.2.1:
+ resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
+ engines: {node: '>= 0.8.0'}
+
pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
@@ -5971,6 +6335,10 @@ packages:
pump@3.0.4:
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
+ punycode@2.3.1:
+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+ engines: {node: '>=6'}
+
pvtsutils@1.3.6:
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
@@ -5978,6 +6346,10 @@ packages:
resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==}
engines: {node: '>=16.0.0'}
+ qified@0.10.1:
+ resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==}
+ engines: {node: '>=20'}
+
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
@@ -6650,6 +7022,12 @@ packages:
ts-algebra@2.0.0:
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
+ ts-api-utils@2.5.0:
+ resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
+ engines: {node: '>=18.12'}
+ peerDependencies:
+ typescript: '>=4.8.4'
+
ts-dedent@2.2.0:
resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==}
engines: {node: '>=6.10'}
@@ -6673,6 +7051,10 @@ packages:
tweetnacl@1.0.3:
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
+ type-check@0.4.0:
+ resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
+ engines: {node: '>= 0.8.0'}
+
type-fest@0.13.1:
resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==}
engines: {node: '>=10'}
@@ -6767,6 +7149,9 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
+ uri-js@4.4.1:
+ resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+
use-callback-ref@1.3.3:
resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
engines: {node: '>=10'}
@@ -6907,6 +7292,10 @@ packages:
engines: {node: '>=8'}
hasBin: true
+ word-wrap@1.2.5:
+ resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
+ engines: {node: '>=0.10.0'}
+
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
@@ -7278,6 +7667,18 @@ snapshots:
'@braintree/sanitize-url@7.1.2': {}
+ '@cacheable/memory@2.2.0':
+ dependencies:
+ '@cacheable/utils': 2.5.0
+ '@keyv/bigmap': 1.3.1(keyv@5.6.0)
+ hookified: 1.15.1
+ keyv: 5.6.0
+
+ '@cacheable/utils@2.5.0':
+ dependencies:
+ hashery: 1.5.1
+ keyv: 5.6.0
+
'@chevrotain/types@11.1.2': {}
'@croct/json5-parser@0.2.2':
@@ -7524,6 +7925,40 @@ snapshots:
'@esbuild/win32-x64@0.25.12':
optional: true
+ '@eslint-community/eslint-utils@4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))':
+ dependencies:
+ eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0)
+ eslint-visitor-keys: 3.4.3
+
+ '@eslint-community/regexpp@4.12.2': {}
+
+ '@eslint/config-array@0.23.5(supports-color@7.2.0)':
+ dependencies:
+ '@eslint/object-schema': 3.0.5
+ debug: 4.4.3(supports-color@7.2.0)
+ minimatch: 10.2.5
+ transitivePeerDependencies:
+ - supports-color
+
+ '@eslint/config-helpers@0.7.0':
+ dependencies:
+ '@eslint/core': 1.2.1
+
+ '@eslint/core@0.17.0':
+ dependencies:
+ '@types/json-schema': 7.0.15
+
+ '@eslint/core@1.2.1':
+ dependencies:
+ '@types/json-schema': 7.0.15
+
+ '@eslint/object-schema@3.0.5': {}
+
+ '@eslint/plugin-kit@0.7.3':
+ dependencies:
+ '@eslint/core': 1.2.1
+ levn: 0.4.1
+
'@floating-ui/core@1.7.5':
dependencies:
'@floating-ui/utils': 0.2.11
@@ -7549,6 +7984,22 @@ snapshots:
dependencies:
hono: 4.13.0
+ '@humanfs/core@0.19.2':
+ dependencies:
+ '@humanfs/types': 0.15.0
+
+ '@humanfs/node@0.16.8':
+ dependencies:
+ '@humanfs/core': 0.19.2
+ '@humanfs/types': 0.15.0
+ '@humanwhocodes/retry': 0.4.3
+
+ '@humanfs/types@0.15.0': {}
+
+ '@humanwhocodes/module-importer@1.0.1': {}
+
+ '@humanwhocodes/retry@0.4.3': {}
+
'@iconify/types@2.0.0': {}
'@iconify/utils@3.1.1':
@@ -7699,6 +8150,14 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@keyv/bigmap@1.3.1(keyv@5.6.0)':
+ dependencies:
+ hashery: 1.5.1
+ hookified: 1.15.1
+ keyv: 5.6.0
+
+ '@keyv/serialize@1.1.1': {}
+
'@linear/sdk@82.1.0(graphql@16.14.2)':
dependencies:
'@graphql-typed-document-node/core': 3.2.0(graphql@16.14.2)
@@ -7887,51 +8346,99 @@ snapshots:
'@oxc-parser/binding-android-arm-eabi@0.141.0':
optional: true
+ '@oxc-parser/binding-android-arm-eabi@0.148.0':
+ optional: true
+
'@oxc-parser/binding-android-arm64@0.141.0':
optional: true
+ '@oxc-parser/binding-android-arm64@0.148.0':
+ optional: true
+
'@oxc-parser/binding-darwin-arm64@0.141.0':
optional: true
+ '@oxc-parser/binding-darwin-arm64@0.148.0':
+ optional: true
+
'@oxc-parser/binding-darwin-x64@0.141.0':
optional: true
+ '@oxc-parser/binding-darwin-x64@0.148.0':
+ optional: true
+
'@oxc-parser/binding-freebsd-x64@0.141.0':
optional: true
+ '@oxc-parser/binding-freebsd-x64@0.148.0':
+ optional: true
+
'@oxc-parser/binding-linux-arm-gnueabihf@0.141.0':
optional: true
+ '@oxc-parser/binding-linux-arm-gnueabihf@0.148.0':
+ optional: true
+
'@oxc-parser/binding-linux-arm-musleabihf@0.141.0':
optional: true
+ '@oxc-parser/binding-linux-arm-musleabihf@0.148.0':
+ optional: true
+
'@oxc-parser/binding-linux-arm64-gnu@0.141.0':
optional: true
+ '@oxc-parser/binding-linux-arm64-gnu@0.148.0':
+ optional: true
+
'@oxc-parser/binding-linux-arm64-musl@0.141.0':
optional: true
+ '@oxc-parser/binding-linux-arm64-musl@0.148.0':
+ optional: true
+
'@oxc-parser/binding-linux-ppc64-gnu@0.141.0':
optional: true
+ '@oxc-parser/binding-linux-ppc64-gnu@0.148.0':
+ optional: true
+
'@oxc-parser/binding-linux-riscv64-gnu@0.141.0':
optional: true
+ '@oxc-parser/binding-linux-riscv64-gnu@0.148.0':
+ optional: true
+
'@oxc-parser/binding-linux-riscv64-musl@0.141.0':
optional: true
+ '@oxc-parser/binding-linux-riscv64-musl@0.148.0':
+ optional: true
+
'@oxc-parser/binding-linux-s390x-gnu@0.141.0':
optional: true
+ '@oxc-parser/binding-linux-s390x-gnu@0.148.0':
+ optional: true
+
'@oxc-parser/binding-linux-x64-gnu@0.141.0':
optional: true
+ '@oxc-parser/binding-linux-x64-gnu@0.148.0':
+ optional: true
+
'@oxc-parser/binding-linux-x64-musl@0.141.0':
optional: true
+ '@oxc-parser/binding-linux-x64-musl@0.148.0':
+ optional: true
+
'@oxc-parser/binding-openharmony-arm64@0.141.0':
optional: true
+ '@oxc-parser/binding-openharmony-arm64@0.148.0':
+ optional: true
+
'@oxc-parser/binding-wasm32-wasi@0.141.0':
dependencies:
'@emnapi/core': 1.11.2
@@ -7942,18 +8449,30 @@ snapshots:
'@oxc-parser/binding-win32-arm64-msvc@0.141.0':
optional: true
+ '@oxc-parser/binding-win32-arm64-msvc@0.148.0':
+ optional: true
+
'@oxc-parser/binding-win32-ia32-msvc@0.141.0':
optional: true
+ '@oxc-parser/binding-win32-ia32-msvc@0.148.0':
+ optional: true
+
'@oxc-parser/binding-win32-x64-msvc@0.141.0':
optional: true
+ '@oxc-parser/binding-win32-x64-msvc@0.148.0':
+ optional: true
+
'@oxc-project/runtime@0.101.0': {}
'@oxc-project/types@0.101.0': {}
'@oxc-project/types@0.141.0': {}
+ '@oxc-project/types@0.148.0':
+ optional: true
+
'@oxfmt/binding-android-arm-eabi@0.65.0':
optional: true
@@ -8988,6 +9507,18 @@ snapshots:
'@sec-ant/readable-stream@0.4.1': {}
+ '@shadcn/lint@0.1.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2)':
+ dependencies:
+ '@eslint/core': 0.17.0
+ '@typescript-eslint/parser': 8.70.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2)
+ cn: 0.2.6
+ optionalDependencies:
+ eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0)
+ oxc-parser: 0.148.0
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
+
'@sindresorhus/is@4.6.0': {}
'@sindresorhus/merge-streams@4.0.0': {}
@@ -9621,6 +10152,8 @@ snapshots:
'@types/http-cache-semantics@4.2.0': {}
+ '@types/json-schema@7.0.15': {}
+
'@types/katex@0.16.8': {}
'@types/keyv@3.1.4':
@@ -9696,8 +10229,60 @@ snapshots:
dependencies:
'@types/node': 25.9.5
+ '@typescript-eslint/parser@8.70.0(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@7.0.2)':
+ dependencies:
+ '@typescript-eslint/scope-manager': 8.70.0
+ '@typescript-eslint/types': 8.70.0
+ '@typescript-eslint/typescript-estree': 8.70.0(supports-color@7.2.0)(typescript@7.0.2)
+ '@typescript-eslint/visitor-keys': 8.70.0
+ debug: 4.4.3(supports-color@7.2.0)
+ eslint: 10.10.0(jiti@2.7.0)(supports-color@7.2.0)
+ typescript: 7.0.2
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/project-service@8.70.0(supports-color@7.2.0)(typescript@7.0.2)':
+ dependencies:
+ '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@7.0.2)
+ '@typescript-eslint/types': 8.70.0
+ debug: 4.4.3(supports-color@7.2.0)
+ typescript: 7.0.2
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/scope-manager@8.70.0':
+ dependencies:
+ '@typescript-eslint/types': 8.70.0
+ '@typescript-eslint/visitor-keys': 8.70.0
+
+ '@typescript-eslint/tsconfig-utils@8.70.0(typescript@7.0.2)':
+ dependencies:
+ typescript: 7.0.2
+
'@typescript-eslint/types@8.60.0': {}
+ '@typescript-eslint/types@8.70.0': {}
+
+ '@typescript-eslint/typescript-estree@8.70.0(supports-color@7.2.0)(typescript@7.0.2)':
+ dependencies:
+ '@typescript-eslint/project-service': 8.70.0(supports-color@7.2.0)(typescript@7.0.2)
+ '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@7.0.2)
+ '@typescript-eslint/types': 8.70.0
+ '@typescript-eslint/visitor-keys': 8.70.0
+ debug: 4.4.3(supports-color@7.2.0)
+ minimatch: 10.2.5
+ semver: 7.8.1
+ tinyglobby: 0.2.16
+ ts-api-utils: 2.5.0(typescript@7.0.2)
+ typescript: 7.0.2
+ transitivePeerDependencies:
+ - supports-color
+
+ '@typescript-eslint/visitor-keys@8.70.0':
+ dependencies:
+ '@typescript-eslint/types': 8.70.0
+ eslint-visitor-keys: 5.0.1
+
'@typescript/typescript-aix-ppc64@7.0.2':
optional: true
@@ -9867,6 +10452,10 @@ snapshots:
mime-types: 3.0.2
negotiator: 1.0.0
+ acorn-jsx@5.3.2(acorn@8.16.0):
+ dependencies:
+ acorn: 8.16.0
+
acorn@8.16.0: {}
agent-base@7.1.4: {}
@@ -9877,6 +10466,13 @@ snapshots:
optionalDependencies:
ajv: 8.20.0
+ ajv@6.15.0:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-json-stable-stringify: 2.1.0
+ json-schema-traverse: 0.4.1
+ uri-js: 4.4.1
+
ajv@8.20.0:
dependencies:
fast-deep-equal: 3.1.3
@@ -10103,6 +10699,14 @@ snapshots:
normalize-url: 6.1.0
responselike: 2.0.1
+ cacheable@2.5.0:
+ dependencies:
+ '@cacheable/memory': 2.2.0
+ '@cacheable/utils': 2.5.0
+ hookified: 1.15.1
+ keyv: 5.6.0
+ qified: 0.10.1
+
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -10201,6 +10805,8 @@ snapshots:
- '@types/react'
- '@types/react-dom'
+ cn@0.2.6: {}
+
code-block-writer@13.0.3: {}
color-convert@2.0.1:
@@ -10500,6 +11106,8 @@ snapshots:
dedent@1.7.2: {}
+ deep-is@0.1.4: {}
+
deepmerge@4.3.1: {}
default-browser-id@5.0.1: {}
@@ -10791,8 +11399,7 @@ snapshots:
escape-html@1.0.3: {}
- escape-string-regexp@4.0.0:
- optional: true
+ escape-string-regexp@4.0.0: {}
escape-string-regexp@5.0.0: {}
@@ -10803,10 +11410,59 @@ snapshots:
esrecurse: 4.3.0
estraverse: 5.3.0
+ eslint-visitor-keys@3.4.3: {}
+
eslint-visitor-keys@5.0.1: {}
+ eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0):
+ dependencies:
+ '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0(jiti@2.7.0)(supports-color@7.2.0))
+ '@eslint-community/regexpp': 4.12.2
+ '@eslint/config-array': 0.23.5(supports-color@7.2.0)
+ '@eslint/config-helpers': 0.7.0
+ '@eslint/core': 1.2.1
+ '@eslint/plugin-kit': 0.7.3
+ '@humanfs/node': 0.16.8
+ '@humanwhocodes/module-importer': 1.0.1
+ '@humanwhocodes/retry': 0.4.3
+ '@types/estree': 1.0.8
+ ajv: 6.15.0
+ cross-spawn: 7.0.6
+ debug: 4.4.3(supports-color@7.2.0)
+ escape-string-regexp: 4.0.0
+ eslint-scope: 9.1.2
+ eslint-visitor-keys: 5.0.1
+ espree: 11.2.0
+ esquery: 1.7.0
+ esutils: 2.0.3
+ fast-deep-equal: 3.1.3
+ file-entry-cache: 11.1.5
+ find-up: 5.0.0
+ glob-parent: 6.0.2
+ ignore: 5.3.2
+ imurmurhash: 0.1.4
+ is-glob: 4.0.3
+ json-stable-stringify-without-jsonify: 1.0.1
+ minimatch: 10.2.5
+ natural-compare: 1.4.0
+ optionator: 0.9.4
+ optionalDependencies:
+ jiti: 2.7.0
+ transitivePeerDependencies:
+ - supports-color
+
+ espree@11.2.0:
+ dependencies:
+ acorn: 8.16.0
+ acorn-jsx: 5.3.2(acorn@8.16.0)
+ eslint-visitor-keys: 5.0.1
+
esprima@4.0.1: {}
+ esquery@1.7.0:
+ dependencies:
+ estraverse: 5.3.0
+
esrecurse@4.3.0:
dependencies:
estraverse: 5.3.0
@@ -10819,6 +11475,8 @@ snapshots:
dependencies:
'@types/estree': 1.0.8
+ esutils@2.0.3: {}
+
etag@1.8.1: {}
eventemitter3@5.0.4: {}
@@ -10912,6 +11570,10 @@ snapshots:
merge2: 1.4.1
micromatch: 4.0.8
+ fast-json-stable-stringify@2.1.0: {}
+
+ fast-levenshtein@2.0.6: {}
+
fast-sha256@1.3.0: {}
fast-string-truncated-width@3.0.3: {}
@@ -10946,6 +11608,10 @@ snapshots:
dependencies:
is-unicode-supported: 2.1.0
+ file-entry-cache@11.1.5:
+ dependencies:
+ flat-cache: 6.1.23
+
filelist@1.0.6:
dependencies:
minimatch: 5.1.9
@@ -10970,8 +11636,21 @@ snapshots:
locate-path: 5.0.0
path-exists: 4.0.0
+ find-up@5.0.0:
+ dependencies:
+ locate-path: 6.0.0
+ path-exists: 4.0.0
+
flairup@1.0.0: {}
+ flat-cache@6.1.23:
+ dependencies:
+ cacheable: 2.5.0
+ flatted: 3.4.4
+ hookified: 1.15.1
+
+ flatted@3.4.4: {}
+
form-data@4.0.6:
dependencies:
asynckit: 0.4.0
@@ -11080,6 +11759,10 @@ snapshots:
dependencies:
is-glob: 4.0.3
+ glob-parent@6.0.2:
+ dependencies:
+ is-glob: 4.0.3
+
glob@13.0.6:
dependencies:
minimatch: 10.2.5
@@ -11159,6 +11842,10 @@ snapshots:
dependencies:
has-symbols: 1.1.0
+ hashery@1.5.1:
+ dependencies:
+ hookified: 1.15.1
+
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
@@ -11293,6 +11980,10 @@ snapshots:
hono@4.13.0: {}
+ hookified@1.15.1: {}
+
+ hookified@2.2.0: {}
+
hosted-git-info@4.1.0:
dependencies:
lru-cache: 6.0.0
@@ -11396,6 +12087,8 @@ snapshots:
parent-module: 1.0.1
resolve-from: 4.0.0
+ imurmurhash@0.1.4: {}
+
indent-string@4.0.0: {}
inflight@1.0.6:
@@ -11531,10 +12224,14 @@ snapshots:
'@babel/runtime': 7.29.7
ts-algebra: 2.0.0
+ json-schema-traverse@0.4.1: {}
+
json-schema-traverse@1.0.0: {}
json-schema-typed@8.0.2: {}
+ json-stable-stringify-without-jsonify@1.0.1: {}
+
json-stringify-safe@5.0.1:
optional: true
@@ -11564,6 +12261,10 @@ snapshots:
dependencies:
json-buffer: 3.0.1
+ keyv@5.6.0:
+ dependencies:
+ '@keyv/serialize': 1.1.1
+
khroma@2.1.0: {}
kleur@3.0.3: {}
@@ -11576,6 +12277,11 @@ snapshots:
lazy-val@1.0.5: {}
+ levn@0.4.1:
+ dependencies:
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+
lightningcss-android-arm64@1.32.0:
optional: true
@@ -11651,6 +12357,10 @@ snapshots:
dependencies:
p-locate: 4.1.0
+ locate-path@6.0.0:
+ dependencies:
+ p-locate: 5.0.0
+
lodash-es@4.18.1: {}
lodash.escaperegexp@4.1.2: {}
@@ -12248,6 +12958,8 @@ snapshots:
nanoid@3.3.18: {}
+ natural-compare@1.4.0: {}
+
negotiator@1.0.0: {}
node-abi@4.33.0:
@@ -12339,6 +13051,15 @@ snapshots:
opentype.js@2.0.0: {}
+ optionator@0.9.4:
+ dependencies:
+ deep-is: 0.1.4
+ fast-levenshtein: 2.0.6
+ levn: 0.4.1
+ prelude-ls: 1.2.1
+ type-check: 0.4.0
+ word-wrap: 1.2.5
+
ora@8.2.0:
dependencies:
chalk: 5.6.2
@@ -12392,6 +13113,31 @@ snapshots:
'@oxc-parser/binding-win32-ia32-msvc': 0.141.0
'@oxc-parser/binding-win32-x64-msvc': 0.141.0
+ oxc-parser@0.148.0:
+ dependencies:
+ '@oxc-project/types': 0.148.0
+ optionalDependencies:
+ '@oxc-parser/binding-android-arm-eabi': 0.148.0
+ '@oxc-parser/binding-android-arm64': 0.148.0
+ '@oxc-parser/binding-darwin-arm64': 0.148.0
+ '@oxc-parser/binding-darwin-x64': 0.148.0
+ '@oxc-parser/binding-freebsd-x64': 0.148.0
+ '@oxc-parser/binding-linux-arm-gnueabihf': 0.148.0
+ '@oxc-parser/binding-linux-arm-musleabihf': 0.148.0
+ '@oxc-parser/binding-linux-arm64-gnu': 0.148.0
+ '@oxc-parser/binding-linux-arm64-musl': 0.148.0
+ '@oxc-parser/binding-linux-ppc64-gnu': 0.148.0
+ '@oxc-parser/binding-linux-riscv64-gnu': 0.148.0
+ '@oxc-parser/binding-linux-riscv64-musl': 0.148.0
+ '@oxc-parser/binding-linux-s390x-gnu': 0.148.0
+ '@oxc-parser/binding-linux-x64-gnu': 0.148.0
+ '@oxc-parser/binding-linux-x64-musl': 0.148.0
+ '@oxc-parser/binding-openharmony-arm64': 0.148.0
+ '@oxc-parser/binding-win32-arm64-msvc': 0.148.0
+ '@oxc-parser/binding-win32-ia32-msvc': 0.148.0
+ '@oxc-parser/binding-win32-x64-msvc': 0.148.0
+ optional: true
+
oxfmt@0.65.0:
dependencies:
tinypool: 2.1.0
@@ -12469,6 +13215,10 @@ snapshots:
dependencies:
p-limit: 2.3.0
+ p-locate@5.0.0:
+ dependencies:
+ p-limit: 3.1.0
+
p-retry@4.6.2:
dependencies:
'@types/retry': 0.12.0
@@ -12607,6 +13357,8 @@ snapshots:
powershell-utils@0.1.0: {}
+ prelude-ls@1.2.1: {}
+
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1
@@ -12725,12 +13477,18 @@ snapshots:
end-of-stream: 1.4.5
once: 1.4.0
+ punycode@2.3.1: {}
+
pvtsutils@1.3.6:
dependencies:
tslib: 2.8.1
pvutils@1.1.5: {}
+ qified@0.10.1:
+ dependencies:
+ hookified: 2.2.0
+
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
@@ -13528,6 +14286,10 @@ snapshots:
ts-algebra@2.0.0: {}
+ ts-api-utils@2.5.0(typescript@7.0.2):
+ dependencies:
+ typescript: 7.0.2
+
ts-dedent@2.2.0: {}
ts-morph@26.0.0:
@@ -13549,6 +14311,10 @@ snapshots:
tweetnacl@1.0.3: {}
+ type-check@0.4.0:
+ dependencies:
+ prelude-ls: 1.2.1
+
type-fest@0.13.1:
optional: true
@@ -13668,6 +14434,10 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
+ uri-js@4.4.1:
+ dependencies:
+ punycode: 2.3.1
+
use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.8):
dependencies:
react: 19.2.8
@@ -13782,6 +14552,8 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
+ word-wrap@1.2.5: {}
+
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 9c0ee568c74..68ae103f6ec 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -17,6 +17,7 @@ minimumReleaseAgeExclude:
- pdfjs-dist@6.3.289
- zod@4.5.4
- electron@43.7.0
+ - '@shadcn/lint@0.1.0'
shamefullyHoist: true
# Orca always launches the user's own resolved Claude CLI via
diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css
index 8793b1f17ec..693e7572ddf 100644
--- a/src/renderer/src/assets/main.css
+++ b/src/renderer/src/assets/main.css
@@ -60,6 +60,7 @@
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
+ --color-editor-surface: var(--editor-surface);
--color-agent-question: var(--agent-question);
--color-agent-question-text: var(--agent-question-text);
--color-chart-1: var(--chart-1);
@@ -509,6 +510,18 @@
}
}
+/* Why @utility, not a plain class: this is a Tailwind-shaped name, so it has to be
+ one Tailwind generates or `scrollbar-none` silently produces no CSS. */
+@utility scrollbar-none {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+
+ &::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+ }
+}
+
/* ── Sleek scrollbar (VS Code-like) ─────────────────── */
.scrollbar-sleek {
diff --git a/src/renderer/src/assets/theme-utility-generation.test.ts b/src/renderer/src/assets/theme-utility-generation.test.ts
new file mode 100644
index 00000000000..d17d8a10b4e
--- /dev/null
+++ b/src/renderer/src/assets/theme-utility-generation.test.ts
@@ -0,0 +1,19 @@
+import fs from 'node:fs'
+import { describe, expect, it } from 'vitest'
+
+const mainCss = fs.readFileSync(new URL('./main.css', import.meta.url), 'utf8')
+const themeBlock = /@theme inline\s*{([\s\S]*?)\n}/.exec(mainCss)?.[1] ?? ''
+
+// Why: a token that never reaches `@theme inline`, and a Tailwind-shaped name that is only a
+// plain CSS selector, both generate no CSS at all -- the utility silently does nothing.
+describe('main.css utility generation', () => {
+ it('exposes --editor-surface to Tailwind so bg-editor-surface generates', () => {
+ expect(mainCss).toMatch(/--editor-surface:/)
+ expect(themeBlock).toMatch(/--color-editor-surface:\s*var\(--editor-surface\)/)
+ })
+
+ it('declares scrollbar-none as a utility rather than a plain class', () => {
+ expect(mainCss).toMatch(/@utility scrollbar-none\s*{/)
+ expect(mainCss).not.toMatch(/^\.scrollbar-none\b/m)
+ })
+})
diff --git a/src/renderer/src/components/editor/IpynbCellEditor.tsx b/src/renderer/src/components/editor/IpynbCellEditor.tsx
index 77f319f034c..3ec18d46701 100644
--- a/src/renderer/src/components/editor/IpynbCellEditor.tsx
+++ b/src/renderer/src/components/editor/IpynbCellEditor.tsx
@@ -1,9 +1,10 @@
-import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
+import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import Editor, { type OnMount } from '@monaco-editor/react'
import Markdown from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
import remarkGfm from 'remark-gfm'
+import { cn } from '@/lib/utils'
import { monaco } from '@/lib/monaco-setup'
import { computeEditorFontSize, resolveEditorFontFamily } from '@/lib/editor-font-zoom'
import { resolveDocumentTheme } from '@/lib/document-theme'
@@ -14,11 +15,27 @@ import type { IpynbCell } from './ipynb-parse'
import MonacoCodeExcerpt from './MonacoCodeExcerpt'
export function IpynbMarkdownCell({ source }: { source: string }): React.JSX.Element {
+ const settings = useAppStore((s) => s.settings)
+ const theme = settings?.theme ?? 'system'
+ const [systemDark, setSystemDark] = useState(() => resolveDocumentTheme('system'))
+ useEffect(() => {
+ if (theme !== 'system' || typeof window.matchMedia !== 'function') {
+ return
+ }
+ const media = window.matchMedia('(prefers-color-scheme: dark)')
+ const onChange = () => setSystemDark(media.matches)
+ onChange()
+ media.addEventListener('change', onChange)
+ return () => media.removeEventListener('change', onChange)
+ }, [theme])
+ const isDark = theme === 'system' ? systemDark : resolveDocumentTheme(theme)
return (
-
-
- {source || '\u00a0'}
-
+
+
+
+ {source || '\u00a0'}
+
+
)
}
From 6f4e4bfa223dfec5804441c81c66d292c9b247f7 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 15 Sep 2026 01:00:51 +0000
Subject: [PATCH 06/34] Update README downloads badge
---
docs/assets/readme-downloads.svg | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg
index bd7a18f8488..a4191395e41 100644
--- a/docs/assets/readme-downloads.svg
+++ b/docs/assets/readme-downloads.svg
@@ -1,5 +1,5 @@
-
+
{showJump ? (
{showAccessHint && (
-
- {translate(
- 'auto.components.settings.VoiceMicrophoneSetting.accessHint',
- 'Allow microphone access to list input devices.'
- )}
-
+ {accessError ? (
+
+ {accessError}
+
+ ) : (
+
+ {translate(
+ 'auto.components.settings.VoiceMicrophoneSetting.accessHint',
+ 'Allow microphone access to list input devices.'
+ )}
+
+ )}
Date: Tue, 15 Sep 2026 00:57:23 -0700
Subject: [PATCH 24/34] Report clipboard and composer drop failures (#20795)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* refactor(renderer): give the IPC error reader a clamped and an unclamped shape
* fix(composer): name the attachments a drop could not add, in one toast
* fix(composer, source-control): use one stable failure toast slot
- Replace per-worktree toast IDs with single slot that replaces on each failure
- Remove destructive retry actions; discard must confirm in dialog
- Consolidate filesystem import types to shared location
- Add compactIpcErrorMessage for string error handling
* refactor: centralize filesystem import types and clarify failure naming
Move import result types from main/ipc to shared layer so they're available
across preload and renderer. Rename uniformFailure → commonFailure and
skippedOrFailed → failureCount for clarity. Simplify preload/API type
definitions by reusing shared types directly instead of duplicating inlined
union shapes.
* Reuse single toast slot for composer drop failures
Multiple drop failures now replace the previous toast instead of
stacking, preventing notification clutter. Uses a dedicated toast ID
separate from Source Control's stage/discard notifications.
* fix(source-control): surface a failed notes copy instead of swallowing it
* Simplify diff comment notes copy error message
Replace parameterized translation template with a direct string. Add
explicit type annotations in tests to improve type safety.
* Sanitize clipboard write error messages for user display
- Only user-friendly messages for recognized errors
- Native failures logged but not exposed to UI
- Prevents information disclosure (CWE-209)
---
src/main/window/clipboard-ipc-handlers.ts | 10 +-
.../notes/use-diff-comment-notes.test.tsx | 104 ++++++++++++++++++
.../notes/use-diff-comment-notes.ts | 13 ++-
src/renderer/src/i18n/locales/en.json | 6 +-
.../src/lib/clipboard-write-failure.ts | 14 +++
5 files changed, 143 insertions(+), 4 deletions(-)
create mode 100644 src/renderer/src/components/right-sidebar/source-control/notes/use-diff-comment-notes.test.tsx
create mode 100644 src/renderer/src/lib/clipboard-write-failure.ts
diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts
index 9b2bc509e38..f6a688ea3d3 100644
--- a/src/main/window/clipboard-ipc-handlers.ts
+++ b/src/main/window/clipboard-ipc-handlers.ts
@@ -179,7 +179,15 @@ export function registerClipboardHandlers(store: Store): void {
)
ipcMain.handle('clipboard:writeText', async (event, text: string) => {
assertTrustedClipboardTextSender(event)
- return clipboard.writeText(await assertClipboardTextWriteWithinLimitWithYield(text))
+ const safeText = await assertClipboardTextWriteWithinLimitWithYield(text)
+ try {
+ clipboard.writeText(safeText)
+ } catch (error) {
+ // Native failures can name paths or platform state, so they stay here; the renderer
+ // only renders a vetted reason (describeClipboardWriteFailure).
+ console.error('[clipboard] writeText failed', error)
+ throw error
+ }
})
ipcMain.handle('clipboard:writeTerminalText', async (event, text: string) => {
assertTrustedClipboardTextSender(event)
diff --git a/src/renderer/src/components/right-sidebar/source-control/notes/use-diff-comment-notes.test.tsx b/src/renderer/src/components/right-sidebar/source-control/notes/use-diff-comment-notes.test.tsx
new file mode 100644
index 00000000000..d3490443815
--- /dev/null
+++ b/src/renderer/src/components/right-sidebar/source-control/notes/use-diff-comment-notes.test.tsx
@@ -0,0 +1,104 @@
+// @vitest-environment happy-dom
+
+import { act, renderHook } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR } from '../../../../../../shared/clipboard-text'
+import type { DiffComment } from '../../../../../../shared/diff-comment-types'
+
+const mocks = vi.hoisted(() => ({
+ toastError: vi.fn<(title: string, options: { description?: string }) => void>(),
+ writeClipboardText: vi.fn()
+}))
+
+vi.mock('sonner', () => ({ toast: { error: mocks.toastError, message: vi.fn() } }))
+vi.mock('@/store', () => ({
+ useAppStore: (selector: (state: Record) => unknown) => selector({})
+}))
+vi.mock('@/store/worktree-diff-comments-selector', () => ({
+ selectWorktreeDiffCommentsOrEmpty: () => [
+ {
+ id: 'c1',
+ worktreeId: 'wt-1',
+ filePath: 'src/app.ts',
+ lineNumber: 1,
+ body: 'rename this',
+ createdAt: 1,
+ side: 'modified'
+ } satisfies DiffComment
+ ]
+}))
+
+import { useSourceControlDiffCommentNotes } from './use-diff-comment-notes'
+
+function renderNotes() {
+ return renderHook(() =>
+ useSourceControlDiffCommentNotes({
+ activeWorktreeId: 'wt-1',
+ clearDiffComments: async () => true,
+ clearDiffCommentsForFile: async () => true
+ })
+ )
+}
+
+describe('diff-comment notes copy failures', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ Object.assign(window, { api: { ui: { writeClipboardText: mocks.writeClipboardText } } })
+ })
+
+ function readErrorToast(): [string, { description?: string }] {
+ expect(mocks.toastError).toHaveBeenCalledTimes(1)
+ const firstCall = mocks.toastError.mock.calls[0]
+ if (!firstCall) {
+ throw new Error('Expected an error toast')
+ }
+ return firstCall
+ }
+
+ it('never shows "Copied" for a clipboard write that rejected', async () => {
+ mocks.writeClipboardText.mockRejectedValue(
+ new Error(
+ "Error invoking remote method 'ui:writeClipboardText': Error: NSPasteboard failed at /Users/someone/Library/Caches/orca"
+ )
+ )
+ const { result } = renderNotes()
+
+ await act(async () => {
+ await result.current.handleCopyDiffComments()
+ })
+
+ expect(result.current.diffCommentsCopied).toBe(false)
+ const [title, options] = readErrorToast()
+ expect(title).toBe('Failed to copy notes')
+ // An unrecognized native failure must not reach the toast (CWE-209).
+ expect(options.description).toBeUndefined()
+ })
+
+ it('describes only the recognized size failure', async () => {
+ mocks.writeClipboardText.mockRejectedValue(
+ new Error(
+ `Error invoking remote method 'ui:writeClipboardText': Error: ${CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR}`
+ )
+ )
+ const { result } = renderNotes()
+
+ await act(async () => {
+ await result.current.handleCopyDiffComments()
+ })
+
+ expect(result.current.diffCommentsCopied).toBe(false)
+ expect(readErrorToast()[1].description).toBe('The text is too large to copy.')
+ })
+
+ it('stays silent when the write resolves', async () => {
+ mocks.writeClipboardText.mockResolvedValue(undefined)
+ const { result } = renderNotes()
+
+ await act(async () => {
+ await result.current.handleCopyDiffComments()
+ })
+
+ expect(result.current.diffCommentsCopied).toBe(true)
+ expect(mocks.toastError).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/right-sidebar/source-control/notes/use-diff-comment-notes.ts b/src/renderer/src/components/right-sidebar/source-control/notes/use-diff-comment-notes.ts
index 53c8d58bdc1..d002080c83a 100644
--- a/src/renderer/src/components/right-sidebar/source-control/notes/use-diff-comment-notes.ts
+++ b/src/renderer/src/components/right-sidebar/source-control/notes/use-diff-comment-notes.ts
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { formatDiffComments } from '@/lib/diff-comments-format'
+import { describeClipboardWriteFailure } from '@/lib/clipboard-write-failure'
import { useAppStore } from '@/store'
import { selectWorktreeDiffCommentsOrEmpty } from '@/store/worktree-diff-comments-selector'
import {
@@ -61,8 +62,16 @@ export function useSourceControlDiffCommentNotes({
try {
await window.api.ui.writeClipboardText(diffCommentsPrompt)
showDiffCommentsCopied(true)
- } catch {
- // Why: swallow — clipboard write can fail when unfocused; best-effort copy needs no error surface.
+ } catch (error) {
+ // Why report: the write can reject (untrusted sender, 16MiB size guard) and silence here
+ // reads as a successful copy — the user finds out on paste.
+ toast.error(
+ translate(
+ 'auto.components.right.sidebar.SourceControl.diffCommentNotesCopyFailed',
+ 'Failed to copy notes'
+ ),
+ { description: describeClipboardWriteFailure(error) }
+ )
}
}, [diffCommentsForActive, diffCommentsPrompt, showDiffCommentsCopied])
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 2788ed6e976..e4345a6f5cc 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -977,6 +977,9 @@
"preview": {
"pairedOutsideWorktree": "Files outside the workspace can't be previewed on a paired server yet."
}
+ },
+ "clipboardWriteFailure": {
+ "tooLarge": "The text is too large to copy."
}
},
"hooks": {
@@ -12390,7 +12393,8 @@
"entryUnstageFailed": "Failed to unstage “{{value0}}”",
"entryDiscardFailed": "Failed to discard “{{value0}}”",
"entryDeleteFailed": "Failed to delete “{{value0}}”",
- "entryFailedInWorkspace": "{{value0}} in {{value1}}"
+ "entryFailedInWorkspace": "{{value0}} in {{value1}}",
+ "diffCommentNotesCopyFailed": "Failed to copy notes"
},
"SourceControlAgentActionDialog": {
"8e856842d1": "Could not start the selected agent.",
diff --git a/src/renderer/src/lib/clipboard-write-failure.ts b/src/renderer/src/lib/clipboard-write-failure.ts
new file mode 100644
index 00000000000..5cd6451a33e
--- /dev/null
+++ b/src/renderer/src/lib/clipboard-write-failure.ts
@@ -0,0 +1,14 @@
+import { isClipboardTextWriteTooLargeError } from '../../../shared/clipboard-text'
+import { translate } from '@/i18n/i18n'
+
+/**
+ * Closed set of clipboard-write reasons safe to show. A rejected write can carry native
+ * clipboard or platform detail, so an unrecognized reason gets no description at all and
+ * the raw error stays in the main-process log.
+ */
+export function describeClipboardWriteFailure(error: unknown): string | undefined {
+ if (isClipboardTextWriteTooLargeError(error)) {
+ return translate('auto.lib.clipboardWriteFailure.tooLarge', 'The text is too large to copy.')
+ }
+ return undefined
+}
From 37a5b278b3a802cd7ce429867fe7c14a2e06174d Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Tue, 15 Sep 2026 01:10:35 -0700
Subject: [PATCH 25/34] test(package): reject an Electron install takeover by
exact command (#20799)
* test(package): reject an Electron install takeover by exact command
CodeRabbit was right about #20787. Replacing the pinned postinstall string
with a /electron/i keyword check was wrong in both directions, verified:
rebuild-native-deps.mjs && rebuild-native-deps.mjs PASSED (should fail)
rebuild-native-deps.mjs && check-electron-version FAILED (should pass)
The owner's own path contains no "electron", so duplicating it slipped
through -- the one case the contract is named for. And a substring match
rejects any later step that merely mentions Electron, which is the same
over-tightness that broke every open PR in the first place, relocated.
Later steps are now checked against the exact owned command plus the known
Electron install commands. A second case pins the rejections themselves,
because reading the real postinstall cannot show a bad chain would be caught
-- that is how #20787 shipped with a guard that did not guard.
Split into its own file rather than adding a max-lines disable (AGENTS.md).
* test(package): match install commands as tokens and cover the rebuild:electron alias
Both review comments were right, verified by running them:
&& check-install-app-deps-version.mjs rejected by substring match (should pass)
&& pnpm run rebuild:electron slipped through (should fail)
package.json:101 aliases rebuild:electron to the owned script, so invoking it
is the same takeover. Matching is now token-based with the owned command still
checked as a phrase, and both cases are pinned.
---
.../package-electron-install-owner.test.mjs | 60 +++++++++++++++++++
...package-electron-runtime-contract.test.mjs | 12 ----
2 files changed, 60 insertions(+), 12 deletions(-)
create mode 100644 config/scripts/package-electron-install-owner.test.mjs
diff --git a/config/scripts/package-electron-install-owner.test.mjs b/config/scripts/package-electron-install-owner.test.mjs
new file mode 100644
index 00000000000..3e1e3cf0d09
--- /dev/null
+++ b/config/scripts/package-electron-install-owner.test.mjs
@@ -0,0 +1,60 @@
+import { readFileSync } from 'node:fs'
+import { join, resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { parse } from 'yaml'
+
+const projectDir = resolve(import.meta.dirname, '../..')
+const readProject = (file) => readFileSync(join(projectDir, file), 'utf8')
+const packageJson = JSON.parse(readProject('package.json'))
+const pnpmWorkspace = parse(readProject('pnpm-workspace.yaml'))
+
+const OWNED_ELECTRON_REBUILD = 'node config/scripts/rebuild-native-deps.mjs'
+// Why exact tokens and not /electron/i or a substring: the owner's own path has no "electron"
+// in it, so a keyword check waves a duplicated rebuild through -- the case this contract is
+// named for (#20787). Substring matching has the opposite fault: `install-app-deps` would also
+// reject a `check-install-app-deps-version.mjs` that installs nothing. `rebuild:electron` is
+// package.json's alias for the owned script, so running it is the same takeover.
+const ELECTRON_INSTALL_COMMANDS = [
+ OWNED_ELECTRON_REBUILD,
+ 'config/scripts/rebuild-native-deps.mjs',
+ 'rebuild:electron',
+ 'electron-rebuild',
+ 'electron-builder',
+ 'install-app-deps'
+]
+const tokenize = (step) => step.split(/[\s]+/).flatMap((word) => [word, ...word.split(/[@]/)])
+const takesOverElectronInstall = (step) => {
+ if (step.includes(OWNED_ELECTRON_REBUILD)) {
+ return true
+ }
+ const tokens = new Set(tokenize(step))
+ return ELECTRON_INSTALL_COMMANDS.some((command) => tokens.has(command))
+}
+
+describe('Electron binary install ownership', () => {
+ it('keeps root postinstall as the single Electron binary install owner', () => {
+ // The invariant is that the root postinstall owns the Electron binary install, not that
+ // nothing may run after it -- pinning the whole string broke every open PR (#20726).
+ const steps = packageJson.scripts.postinstall.split('&&').map((step) => step.trim())
+ expect(steps[0]).toBe(OWNED_ELECTRON_REBUILD)
+ for (const step of steps.slice(1)) {
+ expect(takesOverElectronInstall(step)).toBe(false)
+ }
+ expect(pnpmWorkspace.allowBuilds).not.toHaveProperty('electron')
+ })
+
+ // Why a separate case: the assertion above only reads the real postinstall, so it cannot show
+ // a bad chain would be caught. #20787 shipped a keyword check that missed a duplicated
+ // rebuild; these fixtures pin the rejections themselves.
+ it('rejects a chained step that would take over the Electron install', () => {
+ expect(takesOverElectronInstall(OWNED_ELECTRON_REBUILD)).toBe(true)
+ expect(takesOverElectronInstall('npx electron-rebuild')).toBe(true)
+ expect(takesOverElectronInstall('npx electron-builder install-app-deps')).toBe(true)
+ expect(takesOverElectronInstall('node config/scripts/sync-anti-slop-plugin.mjs')).toBe(false)
+ expect(takesOverElectronInstall('node config/scripts/check-electron-version.mjs')).toBe(false)
+ expect(takesOverElectronInstall('pnpm run rebuild:electron')).toBe(true)
+ expect(takesOverElectronInstall('node config/scripts/check-install-app-deps-version.mjs')).toBe(
+ false
+ )
+ })
+})
diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs
index b5874a47c03..7ede0fc6208 100644
--- a/config/scripts/package-electron-runtime-contract.test.mjs
+++ b/config/scripts/package-electron-runtime-contract.test.mjs
@@ -24,18 +24,6 @@ describe('Electron runtime package contract', () => {
linux: createPackagedRuntimeNodeModuleResources('linux')
}
- it('keeps root postinstall as the single Electron binary install owner', () => {
- // Why not an exact match: the invariant is that the root postinstall owns the Electron
- // binary install, not that nothing else may run after it. Pinning the whole string made
- // any unrelated chained step (a lint-plugin sync, say) a CI failure for every open PR.
- const postinstall = packageJson.scripts.postinstall
- const steps = postinstall.split('&&').map((step) => step.trim())
- expect(steps[0]).toBe('node config/scripts/rebuild-native-deps.mjs')
- // No later step may take over the Electron install the first step owns.
- expect(steps.slice(1).join(' ')).not.toMatch(/electron/i)
- expect(pnpmWorkspace.allowBuilds).not.toHaveProperty('electron')
- })
-
it('keeps the native Windows registry addon optional and platform-gated', () => {
const rebuildScript = readProject('config/scripts/rebuild-native-deps.mjs')
const ensureScript = readProject('config/scripts/ensure-native-runtime.mjs')
From f7b2736d6dcb075670c5dbd341f713296cc78b8a Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Tue, 15 Sep 2026 01:19:32 -0700
Subject: [PATCH 26/34] fix(worktree): block removal when the archive hook
fails (#20153)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(worktree): block removal when the archive hook fails
A repo's orca.yaml archive hook is the user's last chance to save work off a
checkout Orca is about to delete. A failed hook was logged as advisory and
stepped over, so the removal went ahead with nothing archived — and the caller
could still be told it succeeded.
The hook is now a blocking precondition, evaluated while the checkout, its Git
registration, its agents and Orca's ownership evidence are all still intact: it
sits ahead of the registration re-read, the lock/dirty preflights, stopPtys()
and removeWorktree in every orchestrator that runs it.
Failure is typed (worktree_archive_hook_failed) and carries the worktree path,
outcome, exit code where one was observed, and the hook's output. unverifiable
stays distinct from exited, so loss of contact is never read as a pass. The
waiver rides its own field at every layer and is never implied by --force, which
already carries the PTY-stop waiver; when used, the waived failure comes back on
result.archiveHookOverride rather than being swallowed.
worktree.archive-failure-blocking.v1 is advertised so an integration can tell
"accepts --run-hooks" from "safely propagates a failing hook" without risking the
data loss to find out. The runtime's SSH path cannot run a hook at all, so rather
than delete with the archive step silently skipped it refuses — waivable like
every other refusal here. #18563 retires that gate by making the path run the
hook for real.
Stacked on #20559, which makes a timed-out hook report honestly; without it a
hook that traps SIGTERM and exits 0 would defeat this gate.
Fixes #19334
* fix(worktree): close the skip-confirm dead end and the client/hook timeout gap
Four review findings on the gate.
A retry from the failure toast could fail for a DIFFERENT reason than the one
the user had just answered, and that second failure got a bare toast with no
buttons. With skipDeleteWorktreeConfirm set, the delete helpers pass no force, so
waiving a failed archive hook on a dirty checkout landed on the dirty preflight
and stopped there. Retry failures now re-enter the same failure toast, so every
retry stays as actionable as the first attempt. Third instance of this class.
The renderer gave worktree.rm a 60s budget while an archive hook may run for
120s. A hook that took 90s and succeeded timed the client out and reported
failure while the host went on to delete — telling the user their delete failed
and their checkout was gone. The budget is now derived from the hook's, and only
when a hook can run.
The SSH fail-open is logged rather than silent, and the capability's doc comment
scopes what it claims: a hook that RUNS and fails cannot delete the checkout; it
is not a promise the hook was found.
The SSH owner-resolution test now reads a real remote orca.yaml through a stubbed
provider and asserts the returned script is the remote one. It previously stopped
at the lookup key, which is the coverage that let this path break twice. It fails
against the row-only resolution.
* fix(worktree): name a signalled hook exit, and state why prunable cleanup skips the gate
Two things the rebase onto #20617 and #20576 surfaced, both found by rerunning
the real-repo harness rather than by reading the diff.
- #20617 added a registration-cleanup branch that returns before the archive
gate. That ordering is correct — both of its arms describe a row with no
checkout behind it, so there is nothing to archive and running the hook would
fail on the missing cwd — but the gate's ordering invariant is documented, so
the exception should be too.
- A signalled hook reported `Command failed with exit code null.`, which reads
as a reporting glitch rather than the `unverifiable` verdict it is about to
produce. It now says the command was terminated without reporting an exit
code. Introduced by #20576; the withheld `exitCode` itself was always right.
Fixes #19334
---
config/scripts/archive-hook-removal-repro.mjs | 526 ++++++++++++++++++
src/cli/handlers/worktree-removal-warnings.ts | 37 ++
src/cli/handlers/worktree.ts | 38 +-
src/cli/index.test.ts | 77 +++
src/cli/root-help-text-secondary.ts | 2 +-
src/cli/specs/core.ts | 7 +-
.../hooks-archive-exit-observation.test.ts | 5 +
src/main/hooks.ts | 7 +-
.../worktrees-remove-archive-hooks.test.ts | 187 ++++++-
src/main/ipc/worktrees/ipc-context-schemas.ts | 3 +
.../removal/execute-worktree-removal.ts | 74 ++-
.../removal/worktree-archive-hook.test.ts | 93 ++++
.../removal/worktree-archive-hook.ts | 62 ++-
.../removal/worktree-removal-coordinator.ts | 11 +-
.../orca-runtime-remove-managed-worktree.ts | 27 +-
.../ssh-worktree-lifecycle-part-02.spec.ts | 10 +-
...removal-and-reconciliation-part-02.spec.ts | 14 +-
...removal-and-reconciliation-part-03.spec.ts | 45 +-
...removal-and-reconciliation-part-04.spec.ts | 24 +-
...orktree-removal-and-reconciliation.spec.ts | 7 +-
...worktree-removal-archive-hook-gate.spec.ts | 242 ++++++++
.../worktree-removal-execution-host.spec.ts | 42 +-
src/main/runtime/orca-runtime.test.ts | 1 +
src/main/runtime/rpc/errors.ts | 4 +
.../worktree-rm-host-qualification.test.ts | 48 +-
.../methods/worktree-rm-pty-waiver.test.ts | 119 ++--
src/main/runtime/rpc/methods/worktree.ts | 14 +-
...ntime-registered-local-worktree-removal.ts | 26 +-
...time-registered-remote-worktree-removal.ts | 22 +-
.../runtime-worktree-selection.test.ts | 36 +-
.../runtime/runtime-worktree-selection.ts | 30 +-
.../worktree-archive-hook-cannot-run.test.ts | 86 +++
src/main/worktree-archive-hook-gate.ts | 86 +++
src/preload/api/worktree-api.ts | 3 +
.../src/components/settings/DevToolsPane.tsx | 7 +
.../delete-worktree-failure-toast.test.tsx | 52 ++
.../sidebar/delete-worktree-failure-toast.tsx | 26 +-
.../sidebar/delete-worktree-flow.test.ts | 65 ++-
.../sidebar/run-worktree-delete-with-toast.ts | 167 +++---
src/renderer/src/i18n/locales/en.json | 8 +-
src/renderer/src/lib/ipc-error.test.ts | 35 ++
src/renderer/src/lib/ipc-error.ts | 9 +-
.../store-worktree-removal-cascade.test.ts | 103 ++--
.../slices/worktree-delete-state-types.ts | 2 +
.../store/slices/worktree-removal-options.ts | 3 +
.../worktrees-remote-runtime-removal.test.ts | 12 +-
.../teardown/dispatch-worktree-removal.ts | 14 +-
.../worktrees/teardown/remove-worktree.ts | 10 +-
src/shared/cli-argument-boundary.ts | 1 +
src/shared/protocol-version.ts | 12 +
src/shared/rpc-contract/worktree-params.ts | 5 +-
...rchive-failure-blocking-capability.test.ts | 20 +
.../worktree/archive-hook-removal-gate.ts | 117 ++++
src/shared/worktree/create-types.ts | 3 +
54 files changed, 2267 insertions(+), 419 deletions(-)
create mode 100644 config/scripts/archive-hook-removal-repro.mjs
create mode 100644 src/cli/handlers/worktree-removal-warnings.ts
create mode 100644 src/main/ipc/worktrees/removal/worktree-archive-hook.test.ts
create mode 100644 src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts
create mode 100644 src/main/worktree-archive-hook-cannot-run.test.ts
create mode 100644 src/main/worktree-archive-hook-gate.ts
create mode 100644 src/shared/worktree/archive-failure-blocking-capability.test.ts
create mode 100644 src/shared/worktree/archive-hook-removal-gate.ts
diff --git a/config/scripts/archive-hook-removal-repro.mjs b/config/scripts/archive-hook-removal-repro.mjs
new file mode 100644
index 00000000000..3f392ac830d
--- /dev/null
+++ b/config/scripts/archive-hook-removal-repro.mjs
@@ -0,0 +1,526 @@
+/**
+ * Real-repo verification for #19334 — run with:
+ * node config/scripts/archive-hook-removal-repro.mjs
+ *
+ * Requires a prior `build:cli` and `build:electron-vite`; it drives the BUILT CLI against the
+ * BUILT headless runtime, so it proves the shipped artifacts rather than the test harness.
+ *: a failed archive hook must BLOCK a destructive
+ * worktree removal, and the checkout, its git registration and its files must all survive.
+ *
+ * Boots the BUILT headless runtime (`out/main/index.js --serve`), pairs the BUILT CLI to it,
+ * and drives `orca worktree rm` end to end against real git worktrees on disk.
+ */
+import { spawn, spawnSync } from 'node:child_process'
+import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, dirname, resolve } from 'node:path'
+import { randomBytes } from 'node:crypto'
+
+const projectDir = resolve(import.meta.dirname, '../..')
+const serveEntry = join(projectDir, 'out', 'main', 'index.js')
+const cliEntry = join(projectDir, 'out', 'cli', 'index.js')
+const PORT = 6900 + Math.floor(Math.random() * 400)
+const READY_TIMEOUT_MS = 180_000
+
+const control = mkdtempSync(join(tmpdir(), 'agh-control-'))
+const modeFile = join(control, 'mode')
+const ranFile = join(control, 'ran')
+const setMode = (m) => writeFileSync(modeFile, m)
+const hookRuns = () => (existsSync(ranFile) ? readFileSync(ranFile, 'utf8').trim().split('\n') : [])
+
+let failures = 0
+const out = (s) => process.stdout.write(`${s}\n`)
+const banner = (s) => out(`\n${'='.repeat(78)}\n${s}\n${'='.repeat(78)}`)
+function check(label, ok, detail = '') {
+ out(` ${ok ? 'PASS' : 'FAIL'} ${label}${detail ? ` -- ${detail}` : ''}`)
+ if (!ok) {
+ failures++
+ }
+}
+
+let pairingCode = null
+
+/** Run the real CLI against the booted server. Returns the raw process result. */
+function cli(args, { json = true } = {}) {
+ return spawnSync(
+ process.execPath,
+ [cliEntry, ...args, '--pairing-code', pairingCode, ...(json ? ['--json'] : [])],
+ { encoding: 'utf8', shell: false }
+ )
+}
+
+/** Run the CLI and require success, returning result payload. */
+function ok(args) {
+ const r = cli(args)
+ const parsed = parseJsonLine(r)
+ if (!parsed) {
+ throw new Error(`orca ${args.join(' ')} produced no JSON:\n${r.stdout}\n${r.stderr}`)
+ }
+ if (parsed.ok === false) {
+ throw new Error(`orca ${args.join(' ')} failed: ${parsed.error?.code} ${parsed.error?.message}`)
+ }
+ return parsed.result
+}
+
+/** The CLI pretty-prints one JSON document to stdout. */
+function parseJsonLine(r) {
+ const text = (r.stdout ?? '').trim()
+ const start = text.indexOf('{')
+ if (start === -1) {
+ return null
+ }
+ try {
+ return JSON.parse(text.slice(start))
+ } catch {
+ return null
+ }
+}
+
+function git(cwd, ...args) {
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8' })
+ if (r.status !== 0) {
+ throw new Error(`git ${args.join(' ')}: ${r.stderr || r.stdout}`)
+ }
+ return r.stdout
+}
+
+const ARCHIVE_HOOK = `echo "[archive-hook] running in $PWD"
+echo "$PWD" >> ${JSON.stringify(ranFile).slice(1, -1)}
+mode=$(cat ${JSON.stringify(modeFile).slice(1, -1)})
+case "$mode" in
+ ok) echo "[archive-hook] archived OK"; exit 0 ;;
+ fail) echo "[archive-hook] backup target unreachable" >&2; exit 23 ;;
+ signal) echo "[archive-hook] losing the execution host now"; kill -KILL $$ ;;
+esac
+echo "unknown mode $mode" >&2; exit 99
+`
+
+/** A throwaway git repo with one commit; optionally an orca.yaml archive hook. */
+function seedGitRepo(label, withHook, githubSlug) {
+ const dir = mkdtempSync(join(tmpdir(), `agh-repo-${label}-`))
+ writeFileSync(join(dir, 'README.md'), `# ${label}\n`)
+ if (withHook) {
+ writeFileSync(
+ join(dir, 'orca.yaml'),
+ `scripts:\n archive: |\n${ARCHIVE_HOOK.split('\n')
+ .map((l) => ` ${l}`)
+ .join('\n')}\n`
+ )
+ }
+ git(dir, 'init', '-b', 'main')
+ git(dir, 'config', 'user.email', 'verify@orca.test')
+ git(dir, 'config', 'user.name', 'Archive Gate Verify')
+ if (githubSlug) {
+ git(dir, 'remote', 'add', 'origin', `https://github.com/agh-owner/${githubSlug}.git`)
+ }
+ git(dir, 'add', '-A')
+ git(dir, 'commit', '-m', 'seed')
+ return dir
+}
+
+function waitForReady(child) {
+ return new Promise((res, rej) => {
+ let buffered = ''
+ let serverErr = ''
+ const timer = setTimeout(
+ () => rej(new Error(`no ready payload in ${READY_TIMEOUT_MS}ms\n${serverErr}`)),
+ READY_TIMEOUT_MS
+ )
+ child.stderr.setEncoding('utf8')
+ child.stderr.on('data', (c) => {
+ serverErr += c
+ })
+ child.stdout.setEncoding('utf8')
+ child.stdout.on('data', (chunk) => {
+ buffered += chunk
+ for (const line of buffered.split('\n')) {
+ if (!line.startsWith('{')) {
+ continue
+ }
+ try {
+ const p = JSON.parse(line)
+ if (p.type === 'orca_server_ready') {
+ clearTimeout(timer)
+ res(p)
+ return
+ }
+ } catch {
+ /* partial */
+ }
+ }
+ })
+ child.on('exit', (code) => {
+ clearTimeout(timer)
+ rej(new Error(`server exited ${code} before ready:\n${serverErr}`))
+ })
+ })
+}
+
+/** Filesystem + git truth about a worktree, read directly rather than through Orca. */
+function evidence(repoPath, wtPath) {
+ const ls = spawnSync('ls', ['-la', wtPath], { encoding: 'utf8' })
+ const list = spawnSync('git', ['worktree', 'list'], { cwd: repoPath, encoding: 'utf8' })
+ return {
+ dirExists: existsSync(wtPath),
+ fileExists: existsSync(join(wtPath, 'PRECIOUS.txt')),
+ fileBody: existsSync(join(wtPath, 'PRECIOUS.txt'))
+ ? readFileSync(join(wtPath, 'PRECIOUS.txt'), 'utf8').trim()
+ : null,
+ registered: (list.stdout ?? '').includes(wtPath),
+ ls: (ls.stdout ?? '').trim(),
+ worktreeList: (list.stdout ?? '').trim()
+ }
+}
+
+function showEvidence(e) {
+ out(' --- ls -la ---')
+ out(
+ e.ls
+ .split('\n')
+ .map((l) => ` ${l}`)
+ .join('\n')
+ )
+ out(' --- git worktree list (in the repo) ---')
+ out(
+ e.worktreeList
+ .split('\n')
+ .map((l) => ` ${l}`)
+ .join('\n')
+ )
+}
+
+async function main() {
+ const userDataDir = mkdtempSync(join(tmpdir(), 'agh-userdata-'))
+ out(`booting headless runtime on port ${PORT}, userData ${userDataDir}`)
+ const child = spawn(
+ 'npx',
+ [
+ 'electron',
+ serveEntry,
+ '--serve',
+ '--serve-port',
+ String(PORT),
+ '--serve-json',
+ `--user-data-dir=${userDataDir}`
+ ],
+ {
+ cwd: projectDir,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' }
+ }
+ )
+ const created = []
+
+ try {
+ const ready = await waitForReady(child)
+ pairingCode = new URL(ready.pairing.url).searchParams.get('code')
+ out(`ready: ${ready.advertisedEndpoint}`)
+
+ // ---------------------------------------------------------------- setup
+ const hookRepoPath = seedGitRepo('hooked', true)
+ const folderProjectSlug = `agh-folder-proof-${randomBytes(3).toString('hex')}`
+ const bareRepoPath = seedGitRepo('nohook', false, folderProjectSlug)
+ const hookRepo = ok(['repo', 'add', '--path', hookRepoPath]).repo
+ const bareRepo = ok(['repo', 'add', '--path', bareRepoPath]).repo
+ out(`repo with archive hook: ${hookRepoPath} (${hookRepo.id})`)
+ out(`repo without archive hook: ${bareRepoPath} (${bareRepo.id})`)
+
+ const makeWorktree = (repo, repoPath, name) => {
+ const wt = ok([
+ 'worktree',
+ 'create',
+ '--repo',
+ `id:${repo.id}`,
+ '--name',
+ name,
+ '--setup',
+ 'skip'
+ ]).worktree
+ created.push(wt)
+ const unarchivedBody = `unarchived work for ${name}`
+ writeFileSync(join(wt.path, 'PRECIOUS.txt'), `${unarchivedBody}\n`)
+ return { ...wt, repoPath, unarchivedBody }
+ }
+
+ // ============================================================ SCENARIO 1
+ banner('SCENARIO 1 — archive hook exits 23: removal MUST be refused, nothing deleted')
+ setMode('fail')
+ const wt1 = makeWorktree(hookRepo, hookRepoPath, `gate-fail-${randomBytes(3).toString('hex')}`)
+ out(`worktree: ${wt1.path}`)
+ const before = evidence(wt1.repoPath, wt1.path)
+
+ out('\n$ orca worktree rm --worktree --run-hooks (human output)')
+ const human = cli(['worktree', 'rm', '--worktree', wt1.id, '--run-hooks'], { json: false })
+ out(` exit code: ${human.status}`)
+ out(' --- stdout ---')
+ out(
+ (human.stdout ?? '')
+ .trimEnd()
+ .split('\n')
+ .map((l) => ` ${l}`)
+ .join('\n')
+ )
+ out(' --- stderr ---')
+ out(
+ (human.stderr ?? '')
+ .trimEnd()
+ .split('\n')
+ .map((l) => ` ${l}`)
+ .join('\n')
+ )
+
+ out(
+ '\n$ orca worktree rm --worktree --force --run-hooks --json (--force must NOT waive)'
+ )
+ const forced = cli(['worktree', 'rm', '--worktree', wt1.id, '--force', '--run-hooks'])
+ const forcedJson = parseJsonLine(forced)
+ out(` exit code: ${forced.status}`)
+ out(` ${JSON.stringify(forcedJson)}`)
+
+ const after1 = evidence(wt1.repoPath, wt1.path)
+ showEvidence(after1)
+
+ check('CLI exits non-zero', human.status !== 0, `got ${human.status}`)
+ check(
+ 'human stderr names the archive hook',
+ /Archive hook failed for worktree/.test(human.stderr ?? '')
+ )
+ check('--force also refused (non-zero)', forced.status !== 0, `got ${forced.status}`)
+ check(
+ 'typed error code',
+ forcedJson?.error?.code === 'worktree_archive_hook_failed',
+ JSON.stringify(forcedJson?.error?.code)
+ )
+ check(
+ "error data outcome is 'exited'",
+ forcedJson?.error?.data?.outcome === 'exited',
+ JSON.stringify(forcedJson?.error?.data)
+ )
+ check('error data carries exitCode 23', forcedJson?.error?.data?.exitCode === 23)
+ check('checkout directory still exists', after1.dirExists)
+ // Assert the CONTENTS, not just the path: a file that survived as an empty stub would prove
+ // nothing about the work the archive hook was supposed to rescue.
+ check(
+ 'unarchived file PRECIOUS.txt survives with its contents',
+ after1.fileExists && after1.fileBody === wt1.unarchivedBody,
+ `exists=${after1.fileExists} body=${JSON.stringify(after1.fileBody)}`
+ )
+ check('git worktree registration survives', after1.registered)
+ check(
+ 'nothing changed vs. before the attempt',
+ before.dirExists === after1.dirExists && before.registered === after1.registered
+ )
+ const shown = ok(['worktree', 'show', '--worktree', wt1.id]).worktree
+ check('Orca still resolves the worktree', shown?.id === wt1.id)
+ check(
+ 'the hook really ran (twice: plain + --force)',
+ hookRuns().length >= 2,
+ `runs=${hookRuns().length}`
+ )
+ // The checkout is dirty (untracked PRECIOUS.txt). The plain run reported the ARCHIVE failure,
+ // not the dirty-preflight failure, so the gate is evaluated before that preflight.
+ check(
+ 'archive gate precedes the dirty preflight (dirty checkout, archive error reported)',
+ /Archive hook failed/.test(human.stderr ?? '') &&
+ !/\?\? PRECIOUS\.txt/.test(human.stderr ?? '')
+ )
+
+ // ============================================================ SCENARIO 2
+ banner('SCENARIO 2 — --allow-failed-archive-hook: removal proceeds, waiver recorded')
+ // --force here waives only the DIRTY preflight (PRECIOUS.txt is untracked on purpose);
+ // scenario 1 already proved it does not waive the archive gate.
+ const waived = cli([
+ 'worktree',
+ 'rm',
+ '--worktree',
+ wt1.id,
+ '--force',
+ '--run-hooks',
+ '--allow-failed-archive-hook'
+ ])
+ const waivedJson = parseJsonLine(waived)
+ out(` exit code: ${waived.status}`)
+ out(` ${JSON.stringify(waivedJson)}`)
+ const after2 = evidence(wt1.repoPath, wt1.path)
+ out(` checkout still on disk: ${after2.dirExists}`)
+ out(
+ ` --- git worktree list ---\n${after2.worktreeList
+ .split('\n')
+ .map((l) => ` ${l}`)
+ .join('\n')}`
+ )
+ check('override exits zero', waived.status === 0, `got ${waived.status}`)
+ check('removal reported', waivedJson?.result?.removed === true)
+ check('checkout is GONE', !after2.dirExists)
+ check('git registration is gone', !after2.registered)
+ check(
+ 'archiveHookOverride recorded',
+ waivedJson?.result?.archiveHookOverride?.overridden === true,
+ JSON.stringify(waivedJson?.result?.archiveHookOverride)
+ )
+ check(
+ 'override records exit 23 / exited',
+ waivedJson?.result?.archiveHookOverride?.exitCode === 23 &&
+ waivedJson?.result?.archiveHookOverride?.outcome === 'exited'
+ )
+
+ // ============================================================ SCENARIO 3
+ banner('SCENARIO 3 — archive hook exits 0: removal proceeds')
+ setMode('ok')
+ const wt3 = makeWorktree(hookRepo, hookRepoPath, `gate-ok-${randomBytes(3).toString('hex')}`)
+ out(`worktree: ${wt3.path}`)
+ const okRm = cli(['worktree', 'rm', '--worktree', wt3.id, '--force', '--run-hooks'])
+ const okJson = parseJsonLine(okRm)
+ out(` exit code: ${okRm.status}`)
+ out(` ${JSON.stringify(okJson)}`)
+ const after3 = evidence(wt3.repoPath, wt3.path)
+ check('exits zero', okRm.status === 0)
+ check('checkout deleted', !after3.dirExists)
+ check('git registration gone', !after3.registered)
+ check(
+ 'no archiveHookOverride on a clean run',
+ okJson?.result?.archiveHookOverride === undefined
+ )
+
+ // ============================================================ SCENARIO 4
+ banner('SCENARIO 4 — no archive hook configured: removal proceeds unchanged')
+ const wt4 = makeWorktree(
+ bareRepo,
+ bareRepoPath,
+ `gate-nohook-${randomBytes(3).toString('hex')}`
+ )
+ out(`worktree: ${wt4.path}`)
+ const runsBefore = hookRuns().length
+ const noHook = cli(['worktree', 'rm', '--worktree', wt4.id, '--force', '--run-hooks'])
+ const noHookJson = parseJsonLine(noHook)
+ out(` exit code: ${noHook.status}`)
+ out(` ${JSON.stringify(noHookJson)}`)
+ const after4 = evidence(wt4.repoPath, wt4.path)
+ check('exits zero', noHook.status === 0)
+ check('checkout deleted', !after4.dirExists)
+ check('no hook was run', hookRuns().length === runsBefore)
+
+ // ============================================================ SCENARIO 5
+ banner('SCENARIO 5 — hook never reports an exit (killed): must BLOCK as `unverifiable`')
+ setMode('signal')
+ const wt5 = makeWorktree(hookRepo, hookRepoPath, `gate-unver-${randomBytes(3).toString('hex')}`)
+ out(`worktree: ${wt5.path}`)
+ const unver = cli(['worktree', 'rm', '--worktree', wt5.id, '--run-hooks'])
+ const unverJson = parseJsonLine(unver)
+ out(` exit code: ${unver.status}`)
+ out(` ${JSON.stringify(unverJson)}`)
+ const after5 = evidence(wt5.repoPath, wt5.path)
+ showEvidence(after5)
+ check('blocked (non-zero)', unver.status !== 0, `got ${unver.status}`)
+ check('typed error code', unverJson?.error?.code === 'worktree_archive_hook_failed')
+ check(
+ "outcome is 'unverifiable', NOT 'exited'",
+ unverJson?.error?.data?.outcome === 'unverifiable',
+ JSON.stringify(unverJson?.error?.data)
+ )
+ check(
+ 'exit code is WITHHELD (never read as a pass)',
+ unverJson?.error?.data?.exitCode === undefined
+ )
+ check('checkout survives', after5.dirExists && after5.fileExists)
+ check('git registration survives', after5.registered)
+
+ // clean up scenario 5 with the waiver so the temp dirs go away
+ setMode('ok')
+ cli(['worktree', 'rm', '--worktree', wt5.id, '--force'])
+
+ // ============================================================ SCENARIO 6
+ banner('SCENARIO 6 — folder workspace removal (the boundary that runs no hook) is unchanged')
+ const folderDir = mkdtempSync(join(tmpdir(), 'agh-folder-'))
+ mkdirSync(join(folderDir, 'src'))
+ writeFileSync(join(folderDir, 'src', 'app.txt'), 'folder workspace content\n')
+ // A folder workspace is imported against an existing project identity, so anchor it on the
+ // hookless repo's GitHub-derived project.
+ const folderProjectId = `github:agh-owner/${folderProjectSlug}`
+ ok([
+ 'project',
+ 'setup-existing-folder',
+ '--project',
+ folderProjectId,
+ '--host',
+ 'local',
+ '--path',
+ folderDir,
+ '--kind',
+ 'folder'
+ ])
+ const folderRepo = (ok(['repo', 'list']).repos ?? []).find((r) => r.path === folderDir) ?? null
+ out(`folder repo: ${folderDir} (${folderRepo?.id}) kind=${folderRepo?.kind}`)
+ check('registered repo kind is folder', folderRepo?.kind === 'folder', String(folderRepo?.kind))
+ // The project ROOT of a folder project is not deletable (pre-existing rule, unrelated to the
+ // gate); the deletable folder workspace is a child created under it.
+ const folderRoot = ok(['worktree', 'show', '--worktree', `path:${folderDir}`]).worktree
+ const rootRm = cli(['worktree', 'rm', '--worktree', folderRoot.id, '--force', '--run-hooks'])
+ out(
+ ` root refusal (unchanged): exit ${rootRm.status} ${parseJsonLine(rootRm)?.error?.code} -- ${parseJsonLine(rootRm)?.error?.message}`
+ )
+ check(
+ 'folder project root still refuses for its own reason, not the archive gate',
+ rootRm.status !== 0 && parseJsonLine(rootRm)?.error?.code !== 'worktree_archive_hook_failed'
+ )
+
+ const folderChild = ok([
+ 'worktree',
+ 'create',
+ '--repo',
+ `id:${folderRepo.id}`,
+ '--name',
+ 'agh-folder-child',
+ '--setup',
+ 'skip'
+ ]).worktree
+ out(`folder workspace: ${folderChild.id}`)
+ const runsBeforeFolder = hookRuns().length
+ out(`\n$ orca worktree rm --worktree --force --run-hooks`)
+ const folderRm = cli(['worktree', 'rm', '--worktree', folderChild.id, '--force', '--run-hooks'])
+ const folderJson = parseJsonLine(folderRm)
+ out(` exit code: ${folderRm.status}`)
+ out(` ${JSON.stringify(folderJson)}`)
+ const stillThere = cli(['worktree', 'show', '--worktree', folderChild.id])
+ check(
+ 'folder workspace removal exits zero',
+ folderRm.status === 0,
+ `${folderRm.status} ${folderRm.stderr}`
+ )
+ check('folder removal ran no archive hook', hookRuns().length === runsBeforeFolder)
+ check(
+ 'folder contents left on disk (forget, not delete)',
+ existsSync(join(folderDir, 'src', 'app.txt'))
+ )
+ check(
+ 'folder workspace is deregistered',
+ stillThere.status !== 0 && parseJsonLine(stillThere)?.error?.code === 'selector_not_found'
+ )
+ rmSync(folderDir, { recursive: true, force: true })
+
+ banner(failures === 0 ? 'ALL CHECKS PASSED' : `${failures} CHECK(S) FAILED`)
+ } catch (error) {
+ out(`\nHARNESS ERROR: ${error instanceof Error ? error.stack : String(error)}`)
+ failures++
+ } finally {
+ for (const wt of created) {
+ if (existsSync(wt.path)) {
+ cli(['worktree', 'rm', '--worktree', wt.id, '--force'])
+ rmSync(dirname(wt.path), { recursive: true, force: true })
+ }
+ }
+ if (child.exitCode === null && child.signalCode === null) {
+ child.kill('SIGTERM')
+ await Promise.race([
+ new Promise((r) => child.on('exit', r)),
+ new Promise((r) => setTimeout(r, 15_000))
+ ])
+ child.kill('SIGKILL')
+ }
+ rmSync(userDataDir, { recursive: true, force: true })
+ }
+ process.exitCode = failures === 0 ? 0 : 1
+}
+
+setMode('ok')
+main()
diff --git a/src/cli/handlers/worktree-removal-warnings.ts b/src/cli/handlers/worktree-removal-warnings.ts
new file mode 100644
index 00000000000..08079c51a0d
--- /dev/null
+++ b/src/cli/handlers/worktree-removal-warnings.ts
@@ -0,0 +1,37 @@
+import {
+ formatArchiveHookOverride,
+ type ArchiveHookOverride
+} from '../../shared/worktree/archive-hook-removal-gate'
+
+type HookWarningResult = {
+ warning?: string
+ archiveHookOverride?: ArchiveHookOverride
+}
+
+type PreservedBranchResult = {
+ preservedBranch?: {
+ branchName: string
+ }
+}
+
+export function printHookWarning(result: HookWarningResult, json: boolean): void {
+ if (json) {
+ return
+ }
+ if (result.warning) {
+ console.error(`warning: ${result.warning}`)
+ }
+ // Why (#19334): a waived archive-hook failure is the one case where Orca deleted a checkout
+ // whose archive step did not succeed. It has to stay visible in human output.
+ if (result.archiveHookOverride) {
+ console.error(`warning: ${formatArchiveHookOverride(result.archiveHookOverride)}`)
+ }
+}
+
+export function printPreservedBranchWarning(result: PreservedBranchResult, json: boolean): void {
+ if (!json && result.preservedBranch) {
+ console.error(
+ `warning: local branch "${result.preservedBranch.branchName}" was kept because Git could not safely delete it`
+ )
+ }
+}
diff --git a/src/cli/handlers/worktree.ts b/src/cli/handlers/worktree.ts
index 262599234f0..62ddd27155a 100644
--- a/src/cli/handlers/worktree.ts
+++ b/src/cli/handlers/worktree.ts
@@ -6,6 +6,7 @@ import type {
RuntimeWorktreeRemoveResult
} from '../../shared/runtime-types'
import type { CommandHandler } from '../dispatch'
+import { printHookWarning, printPreservedBranchWarning } from './worktree-removal-warnings'
import { formatWorktreeList, formatWorktreePs, formatWorktreeShow, printResult } from '../format'
import {
annotateOmittedHostScope,
@@ -38,30 +39,6 @@ import {
} from './worktree-create-parent-selector'
import { getOptionalLinearIssueLinkFlag } from './worktree-linear-issue-link'
-type HookWarningResult = {
- warning?: string
-}
-
-type PreservedBranchResult = {
- preservedBranch?: {
- branchName: string
- }
-}
-
-function printHookWarning(result: HookWarningResult, json: boolean): void {
- if (!json && result.warning) {
- console.error(`warning: ${result.warning}`)
- }
-}
-
-function printPreservedBranchWarning(result: PreservedBranchResult, json: boolean): void {
- if (!json && result.preservedBranch) {
- console.error(
- `warning: local branch "${result.preservedBranch.branchName}" was kept because Git could not safely delete it`
- )
- }
-}
-
function assertParentWorktreeFlagsCompatible(flags: Map): void {
if (flags.has('parent-worktree') && flags.get('no-parent') === true) {
throw new RuntimeClientError(
@@ -305,13 +282,24 @@ export const WORKTREE_HANDLERS: Record = {
'Orca cannot tell which host owns this workspace. Refresh projects and try again.'
)
}
+ // Why (#19334): the waiver only ever applies to a hook that ran, so without --run-hooks it
+ // silently does nothing. Rejecting it beats letting someone believe they waived something.
+ if (flags.get('allow-failed-archive-hook') === true && flags.get('run-hooks') !== true) {
+ throw new RuntimeClientError(
+ 'invalid_argument',
+ '--allow-failed-archive-hook waives a FAILED archive hook, but without --run-hooks no hook runs at all. Pass --run-hooks too, or drop the waiver.'
+ )
+ }
const result = await client.call('worktree.rm', {
worktree,
hostId,
force: flags.get('force') === true,
// Why (#11960): --force is explicit here, so it may also waive PTY-stop proof.
allowUnverifiedPtyStop: flags.get('force') === true,
- runHooks: flags.get('run-hooks') === true
+ runHooks: flags.get('run-hooks') === true,
+ // Why (#19334): deliberately NOT coupled to --force, which above already waives PTY-stop
+ // proof. Waiving a failed archive hook is a separate decision about the user's data.
+ allowFailedArchiveHook: flags.get('allow-failed-archive-hook') === true
})
printHookWarning(result.result, json)
printPreservedBranchWarning(result.result, json)
diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts
index 7308d39dac6..f63e3832429 100644
--- a/src/cli/index.test.ts
+++ b/src/cli/index.test.ts
@@ -135,6 +135,83 @@ describe('command aliases dispatch to the canonical handler', () => {
}
})
+ // #19334: a failed archive hook blocks removal, so the CLI must exit non-zero rather than
+ // report a delete that did not happen — and the waiver must ride its own flag, never --force.
+ it('exits non-zero when worktree removal is refused by a failed archive hook', async () => {
+ queueFixtures(callMock, okFixture('req_show', { worktree: { hostId: 'local' } }))
+ callMock.mockRejectedValueOnce(
+ Object.assign(new Error('Archive hook failed for worktree: /tmp/wt — exited 23.'), {
+ code: 'worktree_archive_hook_failed'
+ })
+ )
+ const priorExitCode = process.exitCode
+
+ try {
+ await main(
+ ['worktree', 'rm', '--worktree', 'id:wt-1', '--force', '--run-hooks', '--json'],
+ '/tmp/repo'
+ )
+
+ expect(process.exitCode).toBe(1)
+ expect(callMock).toHaveBeenNthCalledWith(
+ 2,
+ 'worktree.rm',
+ expect.objectContaining({
+ runHooks: true,
+ allowFailedArchiveHook: false
+ })
+ )
+ } finally {
+ process.exitCode = priorExitCode
+ }
+ })
+
+ // #19334 S4: the waiver only applies to a hook that ran, so alone it silently does nothing.
+ it('rejects the archive-hook waiver without --run-hooks instead of ignoring it', async () => {
+ queueFixtures(callMock, okFixture('req_show', { worktree: { hostId: 'local' } }))
+ const priorExitCode = process.exitCode
+
+ try {
+ await main(
+ ['worktree', 'rm', '--worktree', 'id:wt-1', '--allow-failed-archive-hook', '--json'],
+ '/tmp/repo'
+ )
+
+ expect(process.exitCode).toBe(1)
+ // The removal must never have been attempted.
+ expect(callMock).not.toHaveBeenCalledWith('worktree.rm', expect.anything())
+ } finally {
+ process.exitCode = priorExitCode
+ }
+ })
+
+ it('forwards the explicit archive-hook waiver on worktree rm', async () => {
+ queueFixtures(
+ callMock,
+ okFixture('req_show', { worktree: { hostId: 'local' } }),
+ okFixture('req', { removed: true })
+ )
+
+ await main(
+ [
+ 'worktree',
+ 'rm',
+ '--worktree',
+ 'id:wt-1',
+ '--run-hooks',
+ '--allow-failed-archive-hook',
+ '--json'
+ ],
+ '/tmp/repo'
+ )
+
+ expect(callMock).toHaveBeenNthCalledWith(
+ 2,
+ 'worktree.rm',
+ expect.objectContaining({ runHooks: true, allowFailedArchiveHook: true })
+ )
+ })
+
it('still runs `terminal focus` after the handler de-duplication', async () => {
queueFixtures(callMock, okFixture('req', { focus: { ok: true } }))
diff --git a/src/cli/root-help-text-secondary.ts b/src/cli/root-help-text-secondary.ts
index 50a1a76de7d..324fe847855 100644
--- a/src/cli/root-help-text-secondary.ts
+++ b/src/cli/root-help-text-secondary.ts
@@ -52,7 +52,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [
' orca worktree show --worktree [--json]',
' orca worktree current [--json]',
' orca worktree set --worktree [--display-name ] [--issue ] [--linear-issue ] [--comment ] [--workspace-status ] [--parent-worktree |--no-parent] [--json]',
- ' orca worktree rm --worktree [--force] [--run-hooks] [--json]',
+ ' orca worktree rm --worktree [--force] [--run-hooks] [--allow-failed-archive-hook] [--json]',
' orca worktree ps [--limit ] [--json]',
' orca file open [--worktree ] [--json]',
' orca file diff [--staged] [--worktree ] [--json]',
diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts
index 2cd3b5f3869..d029b0cd43d 100644
--- a/src/cli/specs/core.ts
+++ b/src/cli/specs/core.ts
@@ -172,10 +172,13 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
],
destructive: true,
summary: 'Remove a worktree from Orca and git',
- usage: 'orca worktree rm --worktree [--force] [--run-hooks] [--json]',
- allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'force', 'run-hooks'],
+ usage:
+ 'orca worktree rm --worktree [--force] [--run-hooks] [--allow-failed-archive-hook] [--json]',
+ allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'force', 'run-hooks', 'allow-failed-archive-hook'],
notes: [
'Repo-defined orca.yaml archive hooks are skipped unless --run-hooks is passed.',
+ 'With --run-hooks, a failed archive hook blocks the removal: nothing is stopped, deleted or deregistered, and the command exits non-zero with error code worktree_archive_hook_failed. --force does not waive this.',
+ 'Pass --allow-failed-archive-hook to delete anyway after the hook has run and failed; the waived failure is reported back on result.archiveHookOverride. It requires --run-hooks and is rejected without it, because with no hook running there is no failure to waive.',
'For Git worktrees, removal also attempts to delete the checked-out local branch, with or without --force. Orca retains branches it knows predated the worktree and any branch whose changes it cannot prove are already merged.'
]
},
diff --git a/src/main/hooks-archive-exit-observation.test.ts b/src/main/hooks-archive-exit-observation.test.ts
index f924a5291cf..7b046e844c8 100644
--- a/src/main/hooks-archive-exit-observation.test.ts
+++ b/src/main/hooks-archive-exit-observation.test.ts
@@ -118,6 +118,11 @@ describe('archive hook exit observation', () => {
).resolves.toMatchObject({ success: true })
})
+ it('names a signalled exit as one rather than reporting "exit code null"', async () => {
+ const result = await runArchiveWith({ code: null, signal: 'SIGKILL' })
+ expect(result.output).toContain('terminated without reporting an exit code')
+ })
+
it.each([
['was killed by a signal', { code: null, signal: 'SIGKILL' as const }],
// A real spawn failure carries a STRING code; the guard under test is `typeof code ===
diff --git a/src/main/hooks.ts b/src/main/hooks.ts
index 8ea68c8f5fa..00cf8ea7408 100644
--- a/src/main/hooks.ts
+++ b/src/main/hooks.ts
@@ -44,7 +44,12 @@ function classifyHookProcessResult(
return { success: false, output: `${streams}\n${message}`.trim() }
}
if (result.code !== 0) {
- const message = `Command failed with exit code ${result.code}.`
+ // `null` means signalled: there is no exit code, and saying "exit code null" reads as a
+ // reporting glitch rather than the `unverifiable` verdict the gate is about to give it.
+ const message =
+ result.code === null
+ ? 'Command was terminated without reporting an exit code.'
+ : `Command failed with exit code ${result.code}.`
console.error(`[hooks] ${context.hookName} hook failed in ${context.cwd}:`, message)
return {
success: false,
diff --git a/src/main/ipc/worktrees-remove-archive-hooks.test.ts b/src/main/ipc/worktrees-remove-archive-hooks.test.ts
index cb1b39c8d6e..38c243f95e5 100644
--- a/src/main/ipc/worktrees-remove-archive-hooks.test.ts
+++ b/src/main/ipc/worktrees-remove-archive-hooks.test.ts
@@ -14,6 +14,13 @@ import {
} from './worktrees-test-module-mocks'
import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness'
import { mockKnownFeatureWorktree } from './worktrees-test-fixtures'
+import {
+ ARCHIVE_HOOK_FAILED_REMOVAL_CODE,
+ asArchiveHookRefusal,
+ type WorktreeArchiveHookFailedError
+} from '../../shared/worktree/archive-hook-removal-gate'
+import type { RemoveWorktreeResult } from '../../shared/worktree/create-types'
+import type { RemoveWorktreeArgs } from './worktrees/ipc-context-schemas'
import type { WorktreeRuntimeStub } from './worktrees-test-runtime-stub'
vi.mock('electron', async () =>
@@ -98,6 +105,25 @@ vi.mock('../runtime/worktree-teardown', async () =>
)
vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock())
+// The shared IPC surface types every handler as returning `unknown`; removal's contract is
+// narrower, and #19334's whole point is that a caller can name and branch on it.
+async function removeWorktreeViaIpc(args: RemoveWorktreeArgs): Promise {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry types every handler as `(...) => unknown`, so this is the only place the real `worktrees:remove` return shape can be named; the production caller in worktree-ipc.ts declares the same type.
+ return (await handlers['worktrees:remove'](null, args)) as RemoveWorktreeResult
+}
+
+/** Narrows through the exported error class — the same branch a real caller would write. */
+async function expectArchiveHookRefusal(
+ args: RemoveWorktreeArgs
+): Promise {
+ try {
+ await removeWorktreeViaIpc(args)
+ } catch (error) {
+ return asArchiveHookRefusal(error)
+ }
+ throw new Error(`expected removal of ${args.worktreeId} to be refused by the archive hook`)
+}
+
describe('registerWorktreeHandlers', () => {
let runtimeStub: WorktreeRuntimeStub
@@ -443,7 +469,8 @@ describe('registerWorktreeHandlers', () => {
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', true)
})
- it('continues SSH worktree removal when the archive hook fails', async () => {
+ // Was "continues SSH worktree removal when the archive hook fails" (#19334): it now refuses.
+ it('refuses SSH worktree removal when the remote archive hook exits non-zero', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
@@ -453,7 +480,6 @@ describe('registerWorktreeHandlers', () => {
connectionId: 'conn-1',
worktreeBaseRef: null
}
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const provider = {
listWorktrees: vi.fn().mockResolvedValue([
{
@@ -492,21 +518,18 @@ describe('registerWorktreeHandlers', () => {
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { archive: 'exit 7' } })
- try {
- await handlers['worktrees:remove'](null, {
- worktreeId: 'repo-ssh::/remote/feature-wt'
- })
- expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
- expect(consoleErrorSpy).toHaveBeenCalledWith(
- '[hooks] archive hook failed for /remote/feature-wt:',
- expect.stringContaining('archive hook exited 7')
- )
- } finally {
- consoleErrorSpy.mockRestore()
- }
+ const refusal = await expectArchiveHookRefusal({
+ worktreeId: 'repo-ssh::/remote/feature-wt'
+ })
+
+ expect(refusal.code).toBe(ARCHIVE_HOOK_FAILED_REMOVAL_CODE)
+ expect(refusal.data).toMatchObject({ outcome: 'exited', exitCode: 7 })
+ expect(provider.worktreeIsClean).not.toHaveBeenCalled()
+ expect(provider.removeWorktree).not.toHaveBeenCalled()
+ expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
})
- it('continues SSH worktree removal when archive hook execution rejects', async () => {
+ it('does not read a lost SSH connection as an archive hook that passed', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
@@ -516,7 +539,6 @@ describe('registerWorktreeHandlers', () => {
connectionId: 'conn-1',
worktreeBaseRef: null
}
- const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const provider = {
listWorktrees: vi.fn().mockResolvedValue([
{
@@ -550,18 +572,15 @@ describe('registerWorktreeHandlers', () => {
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { archive: 'echo archived' } })
- try {
- await handlers['worktrees:remove'](null, {
- worktreeId: 'repo-ssh::/remote/feature-wt'
- })
- expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
- expect(consoleErrorSpy).toHaveBeenCalledWith(
- '[hooks] archive hook failed for /remote/feature-wt:',
- 'relay disconnected'
- )
- } finally {
- consoleErrorSpy.mockRestore()
- }
+ const refusal = await expectArchiveHookRefusal({
+ worktreeId: 'repo-ssh::/remote/feature-wt'
+ })
+
+ // Loss of contact is `unverifiable`, never evidence the hook succeeded.
+ expect(refusal.data).toMatchObject({ outcome: 'unverifiable' })
+ expect(refusal.data.exitCode).toBeUndefined()
+ expect(provider.removeWorktree).not.toHaveBeenCalled()
+ expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
})
it('uses cmd.exe for archive hooks on Windows-like SSH worktree paths', async () => {
@@ -681,4 +700,116 @@ describe('registerWorktreeHandlers', () => {
expect(provider.execNonInteractive).not.toHaveBeenCalled()
expect(provider.removeWorktree).toHaveBeenCalledWith('/remote/feature-wt', undefined)
})
+
+ // Regression cover for #19334: a failed archive hook is a blocking precondition, not an advisory.
+ it('refuses removal and mutates nothing when the local archive hook exits 23', async () => {
+ mockKnownFeatureWorktree()
+ removeWorktreeMock.mockResolvedValue(undefined)
+ getEffectiveHooksMock.mockReturnValue({
+ scripts: { archive: 'echo archived' }
+ })
+ runHookMock.mockResolvedValue({
+ success: false,
+ output: 'backup target unreachable',
+ exitCode: 23
+ })
+
+ const refusal = await expectArchiveHookRefusal({
+ worktreeId: 'repo-1::/workspace/feature-wt'
+ })
+
+ expect(refusal.code).toBe(ARCHIVE_HOOK_FAILED_REMOVAL_CODE)
+ expect(refusal.data).toEqual({
+ worktreePath: '/workspace/feature-wt',
+ outcome: 'exited',
+ exitCode: 23,
+ output: 'backup target unreachable'
+ })
+ expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
+ expect(assertWorktreeCleanForRemovalMock).not.toHaveBeenCalled()
+ expect(removeWorktreeMock).not.toHaveBeenCalled()
+ expect(removeWorktreeLinkedPathsMock).not.toHaveBeenCalled()
+ expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
+ })
+
+ it('classifies a local archive hook that never reported an exit as unverifiable', async () => {
+ mockKnownFeatureWorktree()
+ getEffectiveHooksMock.mockReturnValue({
+ scripts: { archive: 'echo archived' }
+ })
+ runHookMock.mockResolvedValue({
+ success: false,
+ output: 'Hook timed out after 120000ms.'
+ })
+
+ const refusal = await expectArchiveHookRefusal({
+ worktreeId: 'repo-1::/workspace/feature-wt'
+ })
+
+ expect(refusal.data).toEqual({
+ worktreePath: '/workspace/feature-wt',
+ outcome: 'unverifiable',
+ output: 'Hook timed out after 120000ms.'
+ })
+ expect(removeWorktreeMock).not.toHaveBeenCalled()
+ expect(store.removeWorktreeMeta).not.toHaveBeenCalled()
+ })
+
+ it('removes and records the waiver when a failed archive hook is explicitly overridden', async () => {
+ mockKnownFeatureWorktree()
+ removeWorktreeMock.mockResolvedValue({})
+ getEffectiveHooksMock.mockReturnValue({
+ scripts: { archive: 'echo archived' }
+ })
+ runHookMock.mockResolvedValue({
+ success: false,
+ output: 'boom',
+ exitCode: 23
+ })
+
+ const result = await removeWorktreeViaIpc({
+ worktreeId: 'repo-1::/workspace/feature-wt',
+ allowFailedArchiveHook: true
+ })
+
+ expect(result.archiveHookOverride).toEqual({
+ worktreePath: '/workspace/feature-wt',
+ outcome: 'exited',
+ exitCode: 23,
+ output: 'boom',
+ overridden: true
+ })
+ expect(removeWorktreeMock).toHaveBeenCalled()
+ })
+
+ // The folder-workspace path runs no archive hook at all (no Git removal step), so the gate has
+ // nothing to evaluate there. Pinned so a future hook added to that path is a deliberate change.
+ it('removes a folder workspace without consulting the archive hook', async () => {
+ const repo = {
+ id: 'repo-folder',
+ path: '/workspace/folder-project',
+ displayName: 'folder',
+ badgeColor: '#000',
+ addedAt: 0,
+ kind: 'folder' as const,
+ worktreeBaseRef: null
+ }
+ store.getRepos.mockReturnValue([repo])
+ store.getRepo.mockReturnValue(repo)
+ getEffectiveHooksMock.mockReturnValue({ scripts: { archive: 'exit 23' } })
+ runHookMock.mockResolvedValue({
+ success: false,
+ output: 'boom',
+ exitCode: 23
+ })
+
+ const result = await removeWorktreeViaIpc({
+ worktreeId: 'repo-folder::/workspace/folder-project/nested'
+ })
+
+ expect(result).toEqual({})
+ expect(runHookMock).not.toHaveBeenCalled()
+ expect(removeWorktreeMock).not.toHaveBeenCalled()
+ expect(store.removeWorktreeMeta).toHaveBeenCalled()
+ })
})
diff --git a/src/main/ipc/worktrees/ipc-context-schemas.ts b/src/main/ipc/worktrees/ipc-context-schemas.ts
index f29f09b39d4..26f38473a1a 100644
--- a/src/main/ipc/worktrees/ipc-context-schemas.ts
+++ b/src/main/ipc/worktrees/ipc-context-schemas.ts
@@ -21,6 +21,9 @@ export type RemoveWorktreeArgs = {
/** Explicit Force Delete only — `force` alone is set by the ordinary confirmation (#11960). */
allowUnverifiedPtyStop?: boolean
skipArchive?: boolean
+ /** Explicit waiver for a FAILED archive hook (#19334). Distinct from `skipArchive`, which
+ * never runs the hook at all, and never implied by `force`. */
+ allowFailedArchiveHook?: boolean
snapshotPruneBatchId?: string
}
diff --git a/src/main/ipc/worktrees/removal/execute-worktree-removal.ts b/src/main/ipc/worktrees/removal/execute-worktree-removal.ts
index 443e05627ca..057dfec420c 100644
--- a/src/main/ipc/worktrees/removal/execute-worktree-removal.ts
+++ b/src/main/ipc/worktrees/removal/execute-worktree-removal.ts
@@ -12,6 +12,8 @@ import { isPrunableGitFileWorktree } from '../../../worktree-prunable-git-file'
import { findRegisteredDeletableWorktree } from '../../../worktree-removal-safety'
import { removeStaleLocalWorktreeRegistration } from '../../../local-worktree-removal-recovery'
import { runHook } from '../../../hooks'
+import type { ArchiveHookOverride } from '../../../../shared/worktree/archive-hook-removal-gate'
+import { gateWorktreeRemovalOnArchiveHook } from '../../../worktree-archive-hook-gate'
import { withWorktreeRemoveStageSpan } from '../../../observability/instrumentation'
import {
cleanupUnusedWorktreePushTargetRemote,
@@ -84,6 +86,10 @@ export async function executeWorktreeRemoval(
throw new Error(formatWorktreeRemovalError(error, canonicalWorktreePath, args.force ?? false))
}
+ // Ahead of the archive-hook gate below, and that ordering is right: both arms describe a
+ // registration with no checkout behind it — a row whose path IS a `.git` file, or a tree already
+ // gone from disk. There is nothing to archive, and running the hook would fail on the missing
+ // cwd and block a cleanup that has no user data to lose.
if (
!repo.connectionId &&
((await isPrunableGitFileWorktree(registeredWorktree, localWorktreeGitOptions)) ||
@@ -126,10 +132,18 @@ export async function executeWorktreeRemoval(
return removalResult ?? {}
}
+ // No connectionId override here, deliberately: this path derives its host from the repo row
+ // (`getRepoExecutionHostId` in register-worktree-removal-handlers) and resolves its provider, git
+ // options, listing and dispatch from `repo.connectionId` alone. Passing a different owner to the
+ // hook reader would read one host's orca.yaml while running the other host's git. The runtime's
+ // SSH path is the one that carries a route owner separate from the row, and it passes it.
const hooks = await getArchiveHooksForRemoval(repo)
const archiveScript = hooks?.scripts.archive
+ // Precondition, not an advisory (#19334): both branches below stop PTYs and delete the
+ // checkout, so a hook failure has to throw here — before either is reached.
+ let archiveHookOverride: ArchiveHookOverride | undefined
if (archiveScript && !args.skipArchive) {
// Why the branch on connectionId: this block is shared by both flows, so a hardcoded
// 'remote' would file every local archive hook under the SSH breakdown.
@@ -146,38 +160,40 @@ export async function executeWorktreeRemoval(
undefined,
localWorktreeGitOptions
)
- if (!result.success) {
- console.error(`[hooks] archive hook failed for ${canonicalWorktreePath}:`, result.output)
- }
+ archiveHookOverride = gateWorktreeRemovalOnArchiveHook({
+ worktreePath: canonicalWorktreePath,
+ result,
+ allowFailure: args.allowFailedArchiveHook === true
+ })
}
)
}
const remoteConnectionId = repo.connectionId ?? undefined
- if (remoteConnectionId) {
- return removeRegisteredRemoteWorktree(
- context,
- args,
- repo,
- repoId,
- canonicalWorktreePath,
- removalHostId,
- registeredWorktree,
- removedPushTarget,
- provider!,
- deleteBranch
- )
- }
- return removeRegisteredLocalWorktree(
- context,
- args,
- repo,
- repoId,
- canonicalWorktreePath,
- removalHostId,
- removedPushTarget,
- localWorktreeGitOptions,
- hasLocalWorktreeGitOptions,
- deleteBranch
- )
+ const result = remoteConnectionId
+ ? await removeRegisteredRemoteWorktree(
+ context,
+ args,
+ repo,
+ repoId,
+ canonicalWorktreePath,
+ removalHostId,
+ registeredWorktree,
+ removedPushTarget,
+ provider!,
+ deleteBranch
+ )
+ : await removeRegisteredLocalWorktree(
+ context,
+ args,
+ repo,
+ repoId,
+ canonicalWorktreePath,
+ removalHostId,
+ removedPushTarget,
+ localWorktreeGitOptions,
+ hasLocalWorktreeGitOptions,
+ deleteBranch
+ )
+ return archiveHookOverride ? { ...result, archiveHookOverride } : result
}
diff --git a/src/main/ipc/worktrees/removal/worktree-archive-hook.test.ts b/src/main/ipc/worktrees/removal/worktree-archive-hook.test.ts
new file mode 100644
index 00000000000..5058cc6445b
--- /dev/null
+++ b/src/main/ipc/worktrees/removal/worktree-archive-hook.test.ts
@@ -0,0 +1,93 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { Repo } from '../../../../shared/repo-types'
+import type * as HooksModule from '../../../hooks'
+
+const { getSshFilesystemProviderMock, getEffectiveHooksMock } = vi.hoisted(() => ({
+ getSshFilesystemProviderMock: vi.fn(),
+ getEffectiveHooksMock: vi.fn()
+}))
+vi.mock('../../../providers/ssh-filesystem-dispatch', () => ({
+ getSshFilesystemProvider: getSshFilesystemProviderMock
+}))
+// Only `getEffectiveHooks` is stubbed: the module under test also imports `parseOrcaYaml` from
+// here, and replacing it wholesale made the parse throw into the fail-open catch — which answers
+// "no hook", so the test saw an empty result rather than an error.
+vi.mock('../../../hooks', async () => ({
+ ...(await vi.importActual('../../../hooks')),
+ getEffectiveHooks: getEffectiveHooksMock
+}))
+
+import { getArchiveHooksForRemoval } from './worktree-archive-hook'
+
+const REMOTE_REPO: Repo = {
+ id: 'r',
+ path: '/home/orca/repo',
+ displayName: 'r',
+ badgeColor: '#000',
+ addedAt: 0
+}
+
+// Why (#19334): a worktree row can name its owner only as `executionHostId: 'ssh:'`, leaving
+// `repo.connectionId` null. Resolving hooks off the row alone then reads THIS machine's disk for a
+// repo that lives on an SSH host — the committed archive hook goes unseen and the removal proceeds
+// as though none were configured, which is the bug the gate exists to stop.
+describe('getArchiveHooksForRemoval owner resolution', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ getSshFilesystemProviderMock.mockReturnValue(undefined)
+ getEffectiveHooksMock.mockReturnValue(null)
+ })
+
+ // Why this reads a file rather than just checking the lookup key: SSH owner resolution has been
+ // wrong twice on this path, and both times the fix looked right. Asserting only that
+ // `'ssh-target'` was passed stops short of the thing that broke — whether the hook actually comes
+ // from the REMOTE orca.yaml. This drives a stubbed provider holding real content and asserts the
+ // returned script is the remote one.
+ it('returns the hook from the execution host\u2019s orca.yaml, not the local disk', async () => {
+ const readFile = vi.fn().mockResolvedValue({
+ isBinary: false,
+ content: 'scripts:\n archive: remote-archive.sh\n'
+ })
+ getSshFilesystemProviderMock.mockReturnValue({ readFile })
+ // If the local reader were consulted it would answer with a DIFFERENT script, so a wrong
+ // resolution shows up as the wrong value rather than as a silent absence.
+ getEffectiveHooksMock.mockReturnValue({ scripts: { archive: 'local-archive.sh' } })
+
+ const hooks = await getArchiveHooksForRemoval(REMOTE_REPO, 'ssh-target')
+
+ expect(getSshFilesystemProviderMock).toHaveBeenCalledWith('ssh-target')
+ expect(readFile).toHaveBeenCalledWith('/home/orca/repo/orca.yaml')
+ expect(hooks?.scripts.archive).toBe('remote-archive.sh')
+ expect(getEffectiveHooksMock).not.toHaveBeenCalled()
+ })
+
+ it('falls back to the repo row when the caller names no owner', async () => {
+ await getArchiveHooksForRemoval({ ...REMOTE_REPO, connectionId: 'row-connection' })
+
+ expect(getSshFilesystemProviderMock).toHaveBeenCalledWith('row-connection')
+ expect(getEffectiveHooksMock).not.toHaveBeenCalled()
+ })
+
+ it('reads locally only when neither names a connection', async () => {
+ await getArchiveHooksForRemoval({ ...REMOTE_REPO, path: '/local/repo' })
+
+ expect(getSshFilesystemProviderMock).not.toHaveBeenCalled()
+ expect(getEffectiveHooksMock).toHaveBeenCalled()
+ })
+
+ // Known limitation, pinned so it is a decision rather than a surprise: the relay rewrites a
+ // non-numeric error code to -32000, so a missing orca.yaml and an unreachable host arrive
+ // identically. Both answer "no hook", which lets the removal proceed. Reporting them apart needs
+ // a provider contract that returns absence as a successful outcome — tracked in #20196.
+ it('answers "no hook" when the host cannot be read, missing or unreachable alike', async () => {
+ getSshFilesystemProviderMock.mockReturnValue({
+ readFile: vi.fn().mockRejectedValue(
+ Object.assign(new Error('transport closed'), {
+ code: -32000
+ })
+ )
+ })
+
+ await expect(getArchiveHooksForRemoval(REMOTE_REPO, 'ssh-target')).resolves.toEqual(null)
+ })
+})
diff --git a/src/main/ipc/worktrees/removal/worktree-archive-hook.ts b/src/main/ipc/worktrees/removal/worktree-archive-hook.ts
index f59ac4a2ebb..4df6bedd49f 100644
--- a/src/main/ipc/worktrees/removal/worktree-archive-hook.ts
+++ b/src/main/ipc/worktrees/removal/worktree-archive-hook.ts
@@ -7,16 +7,42 @@ import { getSshFilesystemProvider } from '../../../providers/ssh-filesystem-disp
import { requireSshGitProvider } from '../../../providers/ssh-git-dispatch'
import { joinWorktreeRelativePath } from '../../../runtime/runtime-relative-paths'
import { getSetupRunnerEnvVars } from '../../../setup-hook-env-vars'
+import {
+ ARCHIVE_HOOK_TIMEOUT_MS,
+ type ArchiveHookRunResult
+} from '../../../../shared/worktree/archive-hook-removal-gate'
-const WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS = 120_000
-
-export async function getArchiveHooksForRemoval(repo: Repo): Promise {
- if (!repo.connectionId) {
+/**
+ * Resolve the archive hook against the host that owns the worktree.
+ *
+ * A failed read is answered as "no hook", which is a known limitation rather than a judgement: a
+ * missing `orca.yaml` is indistinguishable from an unreachable one here, because the relay rewrites
+ * a non-numeric error code to `-32000` (`src/relay/dispatcher-rpc-routing.ts`), so nothing survives
+ * to tell ENOENT from a transport failure. Reporting it as unreadable fired on every SSH repo that
+ * simply has no orca.yaml; blocking on it would refuse those deletes outright. Distinguishing the
+ * two needs a provider contract that reports absence as a successful outcome — tracked in #20196.
+ *
+ * @param connectionId Overrides `repo.connectionId`, which answers null for a row that names its
+ * owner only as `executionHostId: 'ssh:'`. Callers holding a resolved removal route must
+ * pass it, or an SSH-hosted repo is read on the local disk and its archive hook goes unseen.
+ */
+export async function getArchiveHooksForRemoval(
+ repo: Repo,
+ connectionId?: string
+): Promise {
+ const owner = connectionId ?? repo.connectionId
+ if (!owner) {
return getEffectiveHooks(repo)
}
- const fsProvider = getSshFilesystemProvider(repo.connectionId)
+ const fsProvider = getSshFilesystemProvider(owner)
if (!fsProvider) {
+ // Fail-open, and the one case here we can name confidently: no provider means the host's
+ // orca.yaml was never even looked at, so "no archive hook" is an assumption. Logged rather
+ // than surfaced, because the removal that follows fails on its own missing provider anyway.
+ console.warn(
+ `[hooks] no SSH filesystem provider for ${owner}; treating ${repo.path} as having no archive hook`
+ )
return getEffectiveHooksFromConfig(repo, null)
}
@@ -24,7 +50,16 @@ export async function getArchiveHooksForRemoval(repo: Repo): Promise {
+): Promise {
if (!repo.connectionId) {
return { success: true, output: '' }
}
@@ -46,7 +81,7 @@ export async function runRemoteArchiveHook(
isWindowsRemote ? 'cmd.exe' : '/bin/bash',
isWindowsRemote ? ['/d', '/s', '/c', script] : ['-lc', script],
worktreePath,
- WORKTREE_ARCHIVE_HOOK_TIMEOUT_MS,
+ ARCHIVE_HOOK_TIMEOUT_MS,
undefined,
env
)
@@ -70,8 +105,15 @@ export async function runRemoteArchiveHook(
.join('\n')
.trim()
+ // Why (#19334): a spawn error or timeout means the host never reported an exit for this run, so
+ // the code is withheld and the gate classifies the failure `unverifiable` rather than `exited`.
+ const observedExit =
+ !result.spawnError && !result.timedOut && typeof result.exitCode === 'number'
+ ? result.exitCode
+ : undefined
return {
- success: !result.spawnError && !result.timedOut && result.exitCode === 0,
- output
+ success: observedExit === 0,
+ output,
+ ...(observedExit !== undefined ? { exitCode: observedExit } : {})
}
}
diff --git a/src/main/ipc/worktrees/removal/worktree-removal-coordinator.ts b/src/main/ipc/worktrees/removal/worktree-removal-coordinator.ts
index 3c52c4cabae..66fc769e046 100644
--- a/src/main/ipc/worktrees/removal/worktree-removal-coordinator.ts
+++ b/src/main/ipc/worktrees/removal/worktree-removal-coordinator.ts
@@ -8,14 +8,21 @@ export type WorktreeRemovalInFlight = {
}
export function getWorktreeRemovalOptionsKey(
- args: Pick
+ args: Pick<
+ RemoveWorktreeArgs,
+ 'force' | 'allowUnverifiedPtyStop' | 'skipArchive' | 'allowFailedArchiveHook'
+ >
): string {
const forceKey = args.force === true ? 'force' : 'normal'
const archiveKey = args.skipArchive === true ? 'skip-archive' : 'run-archive'
// Why: a Force Delete retry must not coalesce onto the in-flight attempt that
// just failed the PTY gate — it would inherit that failure instead of retrying.
const ptyKey = args.allowUnverifiedPtyStop === true ? 'allow-unverified-pty' : 'require-pty-stop'
- return `${forceKey}:${archiveKey}:${ptyKey}`
+ // Same reason for the archive waiver: a retry that waives the failed hook must not coalesce
+ // onto the in-flight attempt that is about to refuse on it.
+ const archiveFailureKey =
+ args.allowFailedArchiveHook === true ? 'allow-failed-archive' : 'require-archive'
+ return `${forceKey}:${archiveKey}:${ptyKey}:${archiveFailureKey}`
}
export function getWorktreeRemovalInFlightKey(
diff --git a/src/main/runtime/orca-runtime-remove-managed-worktree.ts b/src/main/runtime/orca-runtime-remove-managed-worktree.ts
index 9cb95e5d354..10f9dd91ae1 100644
--- a/src/main/runtime/orca-runtime-remove-managed-worktree.ts
+++ b/src/main/runtime/orca-runtime-remove-managed-worktree.ts
@@ -7,7 +7,10 @@ import {
import type { RemoveWorktreeResult } from '../../shared/worktree/create-types'
import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup'
-import { getRuntimeWorktreeRemovalOptionsKey } from './runtime-worktree-selection'
+import {
+ getRuntimeWorktreeRemovalOptionsKey,
+ type RemoveManagedWorktreeOptions
+} from './runtime-worktree-selection'
import { withWorktreeSpan } from '../observability/instrumentation'
import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth'
import { resolveWorktreeRemovalRoute } from '../worktree-removal-execution-host-route'
@@ -30,11 +33,15 @@ import { deleteRemoteWorktreeHistory } from '../remote-worktree-history-cleanup'
export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateManagedRemoteWorktree {
async removeManagedWorktree(
worktreeSelector: string,
- force = false,
- runHooks = false,
- allowUnverifiedPtyStop = false,
- hostId?: string
+ options: RemoveManagedWorktreeOptions = {}
): Promise {
+ const {
+ force = false,
+ runHooks = false,
+ allowUnverifiedPtyStop = false,
+ allowFailedArchiveHook = false,
+ hostId
+ } = options
if (!this.store) {
throw new Error('runtime_unavailable')
}
@@ -45,7 +52,12 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM
worktreeId: removalTarget.id,
hostId: cleanupHostId
})
- const optionsKey = getRuntimeWorktreeRemovalOptionsKey(force, runHooks, allowUnverifiedPtyStop)
+ const optionsKey = getRuntimeWorktreeRemovalOptionsKey({
+ force,
+ runHooks,
+ allowUnverifiedPtyStop,
+ allowFailedArchiveHook
+ })
const inFlightRemoval = this.removeManagedWorktreeInFlight.get(
cleanupScopeKey,
removalTarget.id,
@@ -190,6 +202,8 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM
}
if (route.kind === 'ssh') {
return removeRuntimeRegisteredRemoteWorktree({
+ runHooks,
+ allowFailedArchiveHook,
repo,
target: removalTarget,
registeredWorktree,
@@ -240,6 +254,7 @@ export class OrcaRuntimeWithRemoveManagedWorktree extends OrcaRuntimeWithCreateM
hasLocalOptions: hasLocalWorktreeGitOptions,
force,
runHooks,
+ allowFailedArchiveHook,
allowUnverifiedPtyStop,
deleteBranch,
acquireWatcherRemoval: this.acquireFileWatcherRemoval,
diff --git a/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle-part-02.spec.ts
index 7af4da3957c..678ea23eb1d 100644
--- a/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle-part-02.spec.ts
+++ b/src/main/runtime/orca-runtime-tests/ssh-worktree-lifecycle-part-02.spec.ts
@@ -396,7 +396,7 @@ describe('OrcaRuntimeService', () => {
})
try {
- await runtime.removeManagedWorktree('path:/remote/feature', true, false)
+ await runtime.removeManagedWorktree('path:/remote/feature', { force: true, runHooks: false })
} finally {
unregisterSshGitProvider('ssh-1')
}
@@ -472,7 +472,7 @@ describe('OrcaRuntimeService', () => {
runtime.registerPty('pty-local-same-id', `${TEST_REPO_ID}::/remote/feature`, null)
try {
- await runtime.removeManagedWorktree('path:/remote/feature', true, false)
+ await runtime.removeManagedWorktree('path:/remote/feature', { force: true, runHooks: false })
} finally {
unregisterSshGitProvider('ssh-1')
}
@@ -519,9 +519,9 @@ describe('OrcaRuntimeService', () => {
const runtime = new OrcaRuntimeService(remoteStore as never)
try {
- await expect(runtime.removeManagedWorktree('path:/remote/repo', true)).rejects.toThrow(
- 'Refusing to delete protected worktree path: /remote/repo'
- )
+ await expect(
+ runtime.removeManagedWorktree('path:/remote/repo', { force: true })
+ ).rejects.toThrow('Refusing to delete protected worktree path: /remote/repo')
} finally {
unregisterSshGitProvider('ssh-1')
}
diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-02.spec.ts
index 16ad2bb818e..0c7f99da477 100644
--- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-02.spec.ts
+++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-02.spec.ts
@@ -298,7 +298,7 @@ describe('OrcaRuntimeService', () => {
.mockResolvedValue([])
try {
- const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID, true)
+ const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true })
expect(result).toEqual({
preservedBranch: { branchName: 'feature/foo', head: 'abc' },
@@ -340,7 +340,9 @@ describe('OrcaRuntimeService', () => {
)
try {
- await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, true)).rejects.toThrow(
+ await expect(
+ runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true })
+ ).rejects.toThrow(
`Failed to force delete worktree at ${TEST_WORKTREE_PATH}. error: failed to delete deep/file.txt: Filename too long`
)
expect(removePathSpy).not.toHaveBeenCalled()
@@ -387,7 +389,7 @@ describe('OrcaRuntimeService', () => {
})
try {
- const result = await runtime.removeManagedWorktree(worktreeId, true)
+ const result = await runtime.removeManagedWorktree(worktreeId, { force: true })
expect(result).toEqual({
preservedBranch: { branchName: 'feature/foo', head: 'abc' }
@@ -425,9 +427,9 @@ describe('OrcaRuntimeService', () => {
vi.mocked(listWorktreesStrict).mockResolvedValue(registeredWorktrees)
vi.mocked(removeWorktree).mockResolvedValue({})
- await expect(runtime.removeManagedWorktree(worktreeId, true, false)).rejects.toThrow(
- 'Worktree is locked by Git. Lock reason: active agent session'
- )
+ await expect(
+ runtime.removeManagedWorktree(worktreeId, { force: true, runHooks: false })
+ ).rejects.toThrow('Worktree is locked by Git. Lock reason: active agent session')
expect(removeWorktree).not.toHaveBeenCalled()
expect(removeWorktreeMeta).not.toHaveBeenCalled()
diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts
index 06982cf179e..9006502ad73 100644
--- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts
+++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec.ts
@@ -94,7 +94,12 @@ describe('OrcaRuntimeService', () => {
const runtime = createWorktreeRemovalRuntime(runtimeStore)
try {
- await runtime.removeManagedWorktree(TEST_WORKTREE_ID, false, false, false, 'ssh:ssh-1')
+ await runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
+ force: false,
+ runHooks: false,
+ allowUnverifiedPtyStop: false,
+ hostId: 'ssh:ssh-1'
+ })
expect(provider.removeWorktree).toHaveBeenCalledWith(TEST_WORKTREE_PATH, false)
expect(metaById[TEST_WORKTREE_ID]?.hostId).toBe('local')
const result = await runtime.forceDeletePreservedBranch(
@@ -181,8 +186,8 @@ describe('OrcaRuntimeService', () => {
return {}
})
- const first = runtime.removeManagedWorktree(TEST_WORKTREE_ID, true)
- const second = runtime.removeManagedWorktree(TEST_WORKTREE_ID, true)
+ const first = runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true })
+ const second = runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true })
await removeStarted.promise
await Promise.resolve()
@@ -238,14 +243,18 @@ describe('OrcaRuntimeService', () => {
registerSshGitProvider('host-b', provider as never)
try {
- const local = runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'local')
- const remote = runtime.removeManagedWorktree(
- TEST_WORKTREE_ID,
- true,
- false,
- false,
- 'ssh:host-b'
- )
+ const local = runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
+ force: true,
+ runHooks: false,
+ allowUnverifiedPtyStop: false,
+ hostId: 'local'
+ })
+ const remote = runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
+ force: true,
+ runHooks: false,
+ allowUnverifiedPtyStop: false,
+ hostId: 'ssh:host-b'
+ })
await bothStarted.promise
expect(removeWorktree).toHaveBeenCalledTimes(1)
@@ -271,7 +280,7 @@ describe('OrcaRuntimeService', () => {
const first = runtime.removeManagedWorktree(TEST_WORKTREE_ID)
await removeStarted.promise
- await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, true)).rejects.toThrow(
+ await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true })).rejects.toThrow(
'Worktree deletion already in progress'
)
@@ -292,7 +301,7 @@ describe('OrcaRuntimeService', () => {
try {
vi.mocked(listWorktrees).mockResolvedValue([])
- await expect(runtime.removeManagedWorktree(worktreeId, true)).resolves.toEqual({})
+ await expect(runtime.removeManagedWorktree(worktreeId, { force: true })).resolves.toEqual({})
expect(removeWorktree).not.toHaveBeenCalled()
// The repo resolved to the local host, so the metadata purge names it —
@@ -458,7 +467,9 @@ describe('OrcaRuntimeService', () => {
})
try {
- await expect(runtime.removeManagedWorktree(`id:${worktreeId}`, true)).resolves.toEqual({})
+ await expect(
+ runtime.removeManagedWorktree(`id:${worktreeId}`, { force: true })
+ ).resolves.toEqual({})
} finally {
unregisterSshGitProvider(repo.connectionId)
unregisterSshFilesystemProvider(repo.connectionId)
@@ -513,7 +524,7 @@ describe('OrcaRuntimeService', () => {
try {
vi.mocked(listWorktrees).mockResolvedValue([])
- await expect(runtime.removeManagedWorktree(worktreeId, true)).resolves.toEqual({})
+ await expect(runtime.removeManagedWorktree(worktreeId, { force: true })).resolves.toEqual({})
await expect(lstat(orphanPath)).rejects.toMatchObject({ code: 'ENOENT' })
expect(closeLocalWatcherForWorktreePathMock).toHaveBeenCalledWith(
@@ -586,7 +597,7 @@ describe('OrcaRuntimeService', () => {
expect(removeWorktree).not.toHaveBeenCalled()
expect(removeWorktreeMeta).not.toHaveBeenCalled()
- await expect(runtime.removeManagedWorktree(worktreeId, true)).resolves.toEqual({})
+ await expect(runtime.removeManagedWorktree(worktreeId, { force: true })).resolves.toEqual({})
await expect(lstat(leftoverPath)).rejects.toMatchObject({ code: 'ENOENT' })
expect(assertWorktreeCleanForRemoval).not.toHaveBeenCalled()
@@ -642,7 +653,7 @@ describe('OrcaRuntimeService', () => {
try {
vi.mocked(listWorktrees).mockResolvedValue([])
- await expect(runtime.removeManagedWorktree(worktreeId, true)).rejects.toThrow(
+ await expect(runtime.removeManagedWorktree(worktreeId, { force: true })).rejects.toThrow(
`Refusing to delete unregistered worktree path: ${standalonePath}`
)
diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-04.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-04.spec.ts
index efc9efc57b5..78c4f072bb3 100644
--- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-04.spec.ts
+++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation-part-04.spec.ts
@@ -90,9 +90,9 @@ describe('OrcaRuntimeService', () => {
const runtime = createWorktreeRemovalRuntime(runtimeStore)
try {
- await expect(runtime.removeManagedWorktree(`id:${worktreeId}`, true)).rejects.toThrow(
- 'SSH filesystem provider unavailable'
- )
+ await expect(
+ runtime.removeManagedWorktree(`id:${worktreeId}`, { force: true })
+ ).rejects.toThrow('SSH filesystem provider unavailable')
await expect(lstat(localPath)).resolves.toBeTruthy()
expect(removeWorktree).not.toHaveBeenCalled()
@@ -112,7 +112,7 @@ describe('OrcaRuntimeService', () => {
try {
vi.mocked(listWorktrees).mockResolvedValue([])
- await expect(runtime.removeManagedWorktree(worktreeId, true)).rejects.toThrow(
+ await expect(runtime.removeManagedWorktree(worktreeId, { force: true })).rejects.toThrow(
'Refusing to delete unregistered worktree path'
)
@@ -177,7 +177,9 @@ describe('OrcaRuntimeService', () => {
}
})
- await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, true)).rejects.toThrow(
+ await expect(
+ runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true, runHooks: true })
+ ).rejects.toThrow(
`Refusing to delete worktree because it contains another registered worktree: ${TEST_WORKTREE_PATH}/child`
)
@@ -238,7 +240,9 @@ describe('OrcaRuntimeService', () => {
}
])
- await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, true)).rejects.toThrow(
+ await expect(
+ runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true, runHooks: true })
+ ).rejects.toThrow(
`Failed to force delete worktree at ${TEST_WORKTREE_PATH}. Worktree is locked by Git.`
)
@@ -278,9 +282,9 @@ describe('OrcaRuntimeService', () => {
}
])
- await expect(runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, true)).rejects.toThrow(
- 'Worktree is locked by Git'
- )
+ await expect(
+ runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: true, runHooks: true })
+ ).rejects.toThrow('Worktree is locked by Git')
expect(runHook).toHaveBeenCalled()
expect(removeWorktreeLinkedPathsMock).not.toHaveBeenCalled()
@@ -377,7 +381,7 @@ describe('OrcaRuntimeService', () => {
vi.mocked(runHook).mockResolvedValue({ success: true, output: '' })
vi.mocked(removeWorktree).mockResolvedValue({})
- await runtime.removeManagedWorktree(TEST_WORKTREE_ID, false, true)
+ await runtime.removeManagedWorktree(TEST_WORKTREE_ID, { force: false, runHooks: true })
expect(runHook).toHaveBeenCalledWith(
'archive',
diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts
index 630eaebf007..aa976375801 100644
--- a/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts
+++ b/src/main/runtime/orca-runtime-tests/worktree-removal-and-reconciliation.spec.ts
@@ -612,7 +612,12 @@ describe('OrcaRuntimeService', () => {
})
await expect(
- runtime.removeManagedWorktree(TEST_WORKTREE_ID, false, false, false, 'runtime:env-b')
+ runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
+ force: false,
+ runHooks: false,
+ allowUnverifiedPtyStop: false,
+ hostId: 'runtime:env-b'
+ })
).rejects.toThrow('no longer belongs to runtime:env-b')
expect(localProvider.listProcesses).not.toHaveBeenCalled()
diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts
new file mode 100644
index 00000000000..1333d6bd469
--- /dev/null
+++ b/src/main/runtime/orca-runtime-tests/worktree-removal-archive-hook-gate.spec.ts
@@ -0,0 +1,242 @@
+// Regression cover for #19334: a failed archive hook used to be logged and stepped over, so the
+// checkout was deleted with nothing archived. The hook is a blocking precondition now.
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ assertWorktreeCleanForRemoval,
+ deleteWorktreeHistoryDirMock,
+ getEffectiveHooks,
+ invalidateAuthorizedRootsCacheMock,
+ listWorktreesStrict,
+ removeWorktree,
+ removeWorktreeLinkedPathsMock,
+ runHook
+} from '../orca-runtime-test-mocks.spec'
+import {
+ TEST_REPO_PATH,
+ TEST_WORKTREE_ID,
+ TEST_WORKTREE_PATH,
+ createStaleRuntimeWorktreeStore,
+ deferred
+} from '../orca-runtime-test-fixtures.spec'
+import { createWorktreeRemovalRuntime } from '../orca-runtime-test-scenario-builders.spec'
+import {
+ ARCHIVE_HOOK_FAILED_REMOVAL_CODE,
+ asArchiveHookRefusal
+} from '../../../shared/worktree/archive-hook-removal-gate'
+
+function withArchiveHook(): void {
+ vi.mocked(getEffectiveHooks).mockReturnValue({
+ scripts: { archive: 'pnpm worktree:archive' }
+ })
+}
+
+function expectNothingMutated(removeWorktreeMeta: ReturnType): void {
+ // The checkout, its Git registration, its agents and Orca's ownership evidence all survive.
+ expect(removeWorktree).not.toHaveBeenCalled()
+ expect(removeWorktreeMeta).not.toHaveBeenCalled()
+ expect(removeWorktreeLinkedPathsMock).not.toHaveBeenCalled()
+ expect(deleteWorktreeHistoryDirMock).not.toHaveBeenCalled()
+ expect(invalidateAuthorizedRootsCacheMock).not.toHaveBeenCalled()
+ // The gate runs before the registration re-read, so even the preflights never start. The one
+ // listing is the orchestrator's own lookup ahead of the hook; the post-hook refresh never runs.
+ expect(listWorktreesStrict).toHaveBeenCalledTimes(1)
+ expect(assertWorktreeCleanForRemoval).not.toHaveBeenCalled()
+}
+
+describe('archive hook removal gate', () => {
+ // These specs are imported into one aggregate test file, so the module-level mocks arrive with
+ // calls from earlier specs. Clear counts here and restore the shared defaults afterwards.
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ afterEach(() => {
+ vi.mocked(getEffectiveHooks).mockReturnValue(null)
+ vi.mocked(runHook).mockResolvedValue({ success: true, output: '' })
+ })
+
+ it('refuses removal and mutates nothing when the archive hook exits 23', async () => {
+ const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(TEST_WORKTREE_ID)
+ const runtime = createWorktreeRemovalRuntime(runtimeStore)
+ withArchiveHook()
+ vi.mocked(runHook).mockResolvedValue({
+ success: false,
+ output: 'backup target unreachable',
+ exitCode: 23
+ })
+
+ const failure = await runtime
+ .removeManagedWorktree(TEST_WORKTREE_ID, { force: false, runHooks: true })
+ .catch((error: unknown) => error)
+
+ const refusal = asArchiveHookRefusal(failure)
+ expect(refusal.code).toBe(ARCHIVE_HOOK_FAILED_REMOVAL_CODE)
+ expect(refusal.data).toEqual({
+ worktreePath: TEST_WORKTREE_PATH,
+ outcome: 'exited',
+ exitCode: 23,
+ output: 'backup target unreachable'
+ })
+ expectNothingMutated(removeWorktreeMeta)
+ })
+
+ it('refuses removal when the hook never reported an exit, without claiming it passed', async () => {
+ const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(TEST_WORKTREE_ID)
+ const runtime = createWorktreeRemovalRuntime(runtimeStore)
+ withArchiveHook()
+ // A timeout or a lost execution host yields no exit code: `unverifiable`, never a pass.
+ vi.mocked(runHook).mockResolvedValue({
+ success: false,
+ output: 'Hook timed out after 120000ms.'
+ })
+
+ const failure = await runtime
+ .removeManagedWorktree(TEST_WORKTREE_ID, { force: false, runHooks: true })
+ .catch((error: unknown) => error)
+
+ const refusal = asArchiveHookRefusal(failure)
+ expect(refusal.data).toEqual({
+ worktreePath: TEST_WORKTREE_PATH,
+ outcome: 'unverifiable',
+ output: 'Hook timed out after 120000ms.'
+ })
+ expectNothingMutated(removeWorktreeMeta)
+ })
+
+ it('does not let --force waive a failed archive hook', async () => {
+ const { runtimeStore, removeWorktreeMeta } = createStaleRuntimeWorktreeStore(TEST_WORKTREE_ID)
+ const runtime = createWorktreeRemovalRuntime(runtimeStore)
+ withArchiveHook()
+ vi.mocked(runHook).mockResolvedValue({
+ success: false,
+ output: 'boom',
+ exitCode: 23
+ })
+
+ await expect(
+ // force + the PTY-stop waiver, i.e. everything the desktop Force Delete sets.
+ runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
+ force: true,
+ runHooks: true,
+ allowUnverifiedPtyStop: true
+ })
+ ).rejects.toMatchObject({ code: ARCHIVE_HOOK_FAILED_REMOVAL_CODE })
+ expectNothingMutated(removeWorktreeMeta)
+ })
+
+ it('removes and records the waiver when the failure is explicitly overridden', async () => {
+ const runtime = createWorktreeRemovalRuntime()
+ withArchiveHook()
+ vi.mocked(runHook).mockResolvedValue({
+ success: false,
+ output: 'boom',
+ exitCode: 23
+ })
+ vi.mocked(removeWorktree).mockResolvedValue({})
+
+ const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
+ force: false,
+ runHooks: true,
+ allowUnverifiedPtyStop: false,
+ allowFailedArchiveHook: true
+ })
+
+ expect(result.archiveHookOverride).toEqual({
+ worktreePath: TEST_WORKTREE_PATH,
+ outcome: 'exited',
+ exitCode: 23,
+ output: 'boom',
+ overridden: true
+ })
+ expect(removeWorktree).toHaveBeenCalledWith(
+ TEST_REPO_PATH,
+ TEST_WORKTREE_PATH,
+ false,
+ expect.objectContaining({
+ knownRemovedWorktree: expect.objectContaining({
+ path: TEST_WORKTREE_PATH
+ })
+ })
+ )
+ })
+
+ it('removes without an override record when the hook succeeds', async () => {
+ const runtime = createWorktreeRemovalRuntime()
+ withArchiveHook()
+ vi.mocked(runHook).mockResolvedValue({
+ success: true,
+ output: '',
+ exitCode: 0
+ })
+ vi.mocked(removeWorktree).mockResolvedValue({})
+
+ const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
+ force: false,
+ runHooks: true
+ })
+
+ expect(result.archiveHookOverride).toBeUndefined()
+ expect(removeWorktree).toHaveBeenCalled()
+ })
+
+ it('removes when the hook is configured but not requested', async () => {
+ const runtime = createWorktreeRemovalRuntime()
+ withArchiveHook()
+ vi.mocked(removeWorktree).mockResolvedValue({})
+
+ const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID)
+
+ expect(runHook).not.toHaveBeenCalled()
+ expect(result.warning).toContain('archive hook skipped')
+ expect(removeWorktree).toHaveBeenCalled()
+ })
+
+ it('removes when no archive hook is configured', async () => {
+ const runtime = createWorktreeRemovalRuntime()
+ vi.mocked(getEffectiveHooks).mockReturnValue(null)
+ vi.mocked(removeWorktree).mockResolvedValue({})
+
+ const result = await runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
+ force: false,
+ runHooks: true
+ })
+
+ expect(runHook).not.toHaveBeenCalled()
+ expect(result.warning).toBeUndefined()
+ expect(removeWorktree).toHaveBeenCalled()
+ })
+
+ it('does not coalesce an override retry onto the refusal already in flight', async () => {
+ const runtime = createWorktreeRemovalRuntime()
+ withArchiveHook()
+ const hookRun = deferred<{
+ success: boolean
+ output: string
+ exitCode?: number
+ }>()
+ vi.mocked(runHook).mockReturnValue(hookRun.promise)
+ vi.mocked(removeWorktree).mockResolvedValue({})
+
+ const refused = runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
+ force: false,
+ runHooks: true
+ })
+ await vi.waitFor(() => expect(runHook).toHaveBeenCalled())
+
+ // The waiver is part of the in-flight options key, so a concurrent waived retry is refused
+ // outright rather than handed the in-flight attempt that is about to reject on the hook.
+ await expect(
+ runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
+ force: false,
+ runHooks: true,
+ allowUnverifiedPtyStop: false,
+ allowFailedArchiveHook: true
+ })
+ ).rejects.toThrow('Worktree deletion already in progress')
+
+ hookRun.resolve({ success: false, output: 'boom', exitCode: 23 })
+ await expect(refused).rejects.toMatchObject({
+ code: ARCHIVE_HOOK_FAILED_REMOVAL_CODE
+ })
+ })
+})
diff --git a/src/main/runtime/orca-runtime-tests/worktree-removal-execution-host.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-removal-execution-host.spec.ts
index 8ec38e0e5e8..4370d02e217 100644
--- a/src/main/runtime/orca-runtime-tests/worktree-removal-execution-host.spec.ts
+++ b/src/main/runtime/orca-runtime-tests/worktree-removal-execution-host.spec.ts
@@ -102,7 +102,12 @@ describe('OrcaRuntimeService worktree removal execution host', () => {
vi.spyOn(runtime, 'acquireFileWatcherRemoval').mockResolvedValue({ finish: vi.fn() })
try {
- await runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'ssh:target-a')
+ await runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
+ force: true,
+ runHooks: false,
+ allowUnverifiedPtyStop: false,
+ hostId: 'ssh:target-a'
+ })
expect(provider.listWorktrees).toHaveBeenCalledWith(REMOTE_REPO_PATH)
expect(provider.removeWorktree).toHaveBeenCalledWith(TEST_WORKTREE_PATH, true)
@@ -127,7 +132,12 @@ describe('OrcaRuntimeService worktree removal execution host', () => {
try {
await expect(
- runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'ssh:target-a')
+ runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
+ force: true,
+ runHooks: false,
+ allowUnverifiedPtyStop: false,
+ hostId: 'ssh:target-a'
+ })
).resolves.toEqual({})
expect(provider.listWorktrees).toHaveBeenCalledWith(REMOTE_REPO_PATH)
@@ -152,7 +162,12 @@ describe('OrcaRuntimeService worktree removal execution host', () => {
vi.spyOn(runtime, 'acquireFileWatcherRemoval').mockResolvedValue({ finish: vi.fn() })
try {
- await runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'ssh:target-b')
+ await runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
+ force: true,
+ runHooks: false,
+ allowUnverifiedPtyStop: false,
+ hostId: 'ssh:target-b'
+ })
expect(providerB.removeWorktree).toHaveBeenCalledWith(TEST_WORKTREE_PATH, true)
expect(providerA.listWorktrees).not.toHaveBeenCalled()
@@ -168,7 +183,12 @@ describe('OrcaRuntimeService worktree removal execution host', () => {
const runtime = createWorktreeRemovalRuntime(runtimeStore)
await expect(
- runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'ssh:target-a')
+ runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
+ force: true,
+ runHooks: false,
+ allowUnverifiedPtyStop: false,
+ hostId: 'ssh:target-a'
+ })
).rejects.toThrow('Remote connection dropped')
expect(listWorktreesStrict).not.toHaveBeenCalled()
@@ -181,7 +201,12 @@ describe('OrcaRuntimeService worktree removal execution host', () => {
const runtime = createWorktreeRemovalRuntime(runtimeStore)
await expect(
- runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'runtime:env-1')
+ runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
+ force: true,
+ runHooks: false,
+ allowUnverifiedPtyStop: false,
+ hostId: 'runtime:env-1'
+ })
).rejects.toThrow('not dispatched by this process')
expect(listWorktreesStrict).not.toHaveBeenCalled()
@@ -201,7 +226,12 @@ describe('OrcaRuntimeService worktree removal execution host', () => {
try {
await expect(
- runtime.removeManagedWorktree(TEST_WORKTREE_ID, true, false, false, 'runtime:env-1')
+ runtime.removeManagedWorktree(TEST_WORKTREE_ID, {
+ force: true,
+ runHooks: false,
+ allowUnverifiedPtyStop: false,
+ hostId: 'runtime:env-1'
+ })
).rejects.toThrow('not dispatched by this process')
// Selector resolution still lists through the raw field before removal begins — a read on
diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts
index 7ec1fd5fc35..d9e187817bf 100644
--- a/src/main/runtime/orca-runtime.test.ts
+++ b/src/main/runtime/orca-runtime.test.ts
@@ -110,6 +110,7 @@ await import('./orca-runtime-tests/worktree-removal-and-reconciliation.spec')
await import('./orca-runtime-tests/worktree-removal-and-reconciliation-part-02.spec')
await import('./orca-runtime-tests/worktree-removal-and-reconciliation-part-03.spec')
await import('./orca-runtime-tests/worktree-removal-and-reconciliation-part-04.spec')
+await import('./orca-runtime-tests/worktree-removal-archive-hook-gate.spec')
await import('./orca-runtime-tests/worktree-removal-execution-host.spec')
await import('./orca-runtime-tests/targeting-and-resilience.spec')
await import('./orca-runtime-tests/worktree-scan-cache-ttl.spec')
diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts
index 5bb9dc28343..5081236f09a 100644
--- a/src/main/runtime/rpc/errors.ts
+++ b/src/main/runtime/rpc/errors.ts
@@ -22,6 +22,7 @@ import {
} from '../../../shared/skill-install-failure'
import { GIT_DIFF_TOO_LARGE_CODE } from '../../../shared/git-diff-transport-budget'
import { AUTOMATION_OWNER_CONFLICT_CODES } from '../../../shared/automation-owner-conflict'
+import { ARCHIVE_HOOK_FAILED_REMOVAL_CODE } from '../../../shared/worktree/archive-hook-removal-gate'
import { NESTED_WORKER_DEPTH_EXCEEDED_CODE } from '../../../shared/nested-worker-depth'
export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess {
@@ -126,6 +127,9 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([
'stale_delivery',
'waiter_exists',
'invalid_argument',
+ // Why (#19334): "your archive hook failed, nothing was deleted" is a distinct decision — retry,
+ // waive, or skip the hook. Flattened to runtime_error a caller can only pattern-match the text.
+ ARCHIVE_HOOK_FAILED_REMOVAL_CODE,
NESTED_WORKER_DEPTH_EXCEEDED_CODE,
GIT_DIFF_TOO_LARGE_CODE,
ARTIFACT_SHARING_DISABLED_CODE,
diff --git a/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts b/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts
index 68f8b040479..d96fa04c521 100644
--- a/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts
+++ b/src/main/runtime/rpc/methods/worktree-rm-host-qualification.test.ts
@@ -20,6 +20,15 @@ function makeRequest(params: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method: 'worktree.rm', params }
}
+/** The removal options every case forwards; only the resolved host differs. */
+const forwarded = (hostId?: string): Record => ({
+ force: true,
+ runHooks: false,
+ allowUnverifiedPtyStop: false,
+ allowFailedArchiveHook: false,
+ ...(hostId ? { hostId } : {})
+})
+
describe('worktree.rm host qualification', () => {
it('routes an explicitly qualified removal to that host', async () => {
const runtime = makeRuntime()
@@ -29,13 +38,7 @@ describe('worktree.rm host qualification', () => {
makeRequest({ worktree: 'id:wt-1', hostId: 'local', force: true, runHooks: false })
)
- expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
- 'id:wt-1',
- true,
- false,
- false,
- 'local'
- )
+ expect(runtime.removeManagedWorktree).toHaveBeenCalledWith('id:wt-1', forwarded('local'))
expect(response).toMatchObject({ ok: true, result: { removed: true } })
})
@@ -54,10 +57,7 @@ describe('worktree.rm host qualification', () => {
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
`id:${WORKTREE_ID}`,
- true,
- false,
- false,
- 'local'
+ forwarded('local')
)
expect(response).toMatchObject({ ok: true, result: { removed: true } })
})
@@ -77,10 +77,7 @@ describe('worktree.rm host qualification', () => {
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
`id:${WORKTREE_ID}`,
- true,
- false,
- false,
- 'runtime:env-1'
+ forwarded('runtime:env-1')
)
})
@@ -119,10 +116,7 @@ describe('worktree.rm host qualification', () => {
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
`id:${WORKTREE_ID}`,
- true,
- false,
- false,
- 'ssh:target-a'
+ forwarded('ssh:target-a')
)
})
@@ -151,13 +145,7 @@ describe('worktree.rm host qualification', () => {
)
expect(runtime.showManagedWorktree).toHaveBeenCalledWith('id:wt-1')
- expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
- 'id:wt-1',
- true,
- false,
- false,
- 'local'
- )
+ expect(runtime.removeManagedWorktree).toHaveBeenCalledWith('id:wt-1', forwarded('local'))
expect(response).toMatchObject({ ok: true, result: { removed: true } })
})
@@ -205,13 +193,7 @@ describe('worktree.rm host qualification', () => {
expect(response).toMatchObject({ ok: true, result: { removed: true } })
// Unqualified on purpose: removeManagedWorktree owns the stale-row path and
// still refuses on its own if the id turns out to have two owners.
- expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
- 'id:wt-gone',
- true,
- false,
- false,
- undefined
- )
+ expect(runtime.removeManagedWorktree).toHaveBeenCalledWith('id:wt-gone', forwarded())
})
it('propagates a non-missing lookup failure instead of deleting unqualified', async () => {
diff --git a/src/main/runtime/rpc/methods/worktree-rm-pty-waiver.test.ts b/src/main/runtime/rpc/methods/worktree-rm-pty-waiver.test.ts
index 7f4a6c7751c..0c912db53be 100644
--- a/src/main/runtime/rpc/methods/worktree-rm-pty-waiver.test.ts
+++ b/src/main/runtime/rpc/methods/worktree-rm-pty-waiver.test.ts
@@ -14,74 +14,75 @@ function makeRuntime(): OrcaRuntimeService {
} as unknown as OrcaRuntimeService
}
-// Why (#11960): waiving the proof that every PTY stopped must ride its own field.
-// The desktop sets `force` for an ordinary confirmed delete, so keying the waiver
-// off `force` would silently disable the gate on the primary delete path.
-describe('worktree.rm PTY-stop waiver', () => {
- it('forwards an explicit waiver to the runtime', async () => {
+/** The dispatcher validates against the Zod schema, so the test spells the wire shape. */
+type RmParams = {
+ hostId?: string
+ force?: boolean
+ runHooks?: boolean
+ allowUnverifiedPtyStop?: boolean
+ allowFailedArchiveHook?: boolean
+}
+
+async function dispatchRm(runtime: OrcaRuntimeService, params: RmParams): Promise {
+ const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
+ const request: RpcRequest = {
+ id: 'req-1',
+ authToken: 'tok',
+ method: 'worktree.rm',
+ params: { worktree: 'id:wt-1', ...params }
+ }
+ await dispatcher.dispatch(request)
+}
+
+/** Every waiver off unless a case turns it on — the defaults are the assertion. */
+const forwarded = (overrides: Partial> = {}): Record => ({
+ force: false,
+ runHooks: false,
+ allowUnverifiedPtyStop: false,
+ allowFailedArchiveHook: false,
+ hostId: 'local',
+ ...overrides
+})
+
+// Why (#11960 and #19334): each waiver rides its own field. The desktop sets `force` for an
+// ordinary confirmed delete, so keying either waiver off `force` would silently disable that gate
+// on the primary delete path. These cases exist to keep `force` from acquiring a second meaning.
+describe('worktree.rm waivers travel on their own fields', () => {
+ it.each([
+ [
+ 'an explicit PTY-stop waiver reaches the runtime',
+ { hostId: 'local', force: true, allowUnverifiedPtyStop: true, runHooks: false },
+ forwarded({ force: true, allowUnverifiedPtyStop: true })
+ ],
+ [
+ 'force alone does NOT waive the PTY-stop proof',
+ { hostId: 'local', force: true, runHooks: false },
+ forwarded({ force: true })
+ ],
+ [
+ 'an explicit archive-hook waiver reaches the runtime',
+ { hostId: 'local', runHooks: true, allowFailedArchiveHook: true },
+ forwarded({ runHooks: true, allowFailedArchiveHook: true })
+ ],
+ [
+ 'force plus a PTY waiver does NOT waive a failed archive hook',
+ { hostId: 'local', force: true, allowUnverifiedPtyStop: true, runHooks: true },
+ forwarded({ force: true, runHooks: true, allowUnverifiedPtyStop: true })
+ ]
+ ])('%s', async (_name, params, expected) => {
const runtime = makeRuntime()
- const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
-
- await dispatcher.dispatch({
- id: 'req-1',
- authToken: 'tok',
- method: 'worktree.rm',
- params: {
- worktree: 'id:wt-1',
- hostId: 'local',
- force: true,
- allowUnverifiedPtyStop: true,
- runHooks: false
- }
- } satisfies RpcRequest)
-
- expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
- 'id:wt-1',
- true,
- false,
- true,
- 'local'
- )
- })
-
- it('does not infer a waiver from force alone', async () => {
- const runtime = makeRuntime()
- const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
-
- await dispatcher.dispatch({
- id: 'req-1',
- authToken: 'tok',
- method: 'worktree.rm',
- params: { worktree: 'id:wt-1', hostId: 'local', force: true, runHooks: false }
- } satisfies RpcRequest)
-
- expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
- 'id:wt-1',
- true,
- false,
- false,
- 'local'
- )
+ await dispatchRm(runtime, params)
+ expect(runtime.removeManagedWorktree).toHaveBeenCalledWith('id:wt-1', expected)
})
it('resolves the host before forwarding an unqualified removal', async () => {
const runtime = makeRuntime()
- const dispatcher = new RpcDispatcher({ runtime, methods: WORKTREE_METHODS })
-
- await dispatcher.dispatch({
- id: 'req-1',
- authToken: 'tok',
- method: 'worktree.rm',
- params: { worktree: 'id:wt-1', force: true, runHooks: false }
- } satisfies RpcRequest)
+ await dispatchRm(runtime, { force: true, runHooks: false })
expect(runtime.showManagedWorktree).toHaveBeenCalledWith('id:wt-1')
expect(runtime.removeManagedWorktree).toHaveBeenCalledWith(
'id:wt-1',
- true,
- false,
- false,
- 'ssh:builder'
+ forwarded({ force: true, hostId: 'ssh:builder' })
)
})
})
diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts
index be8a0983036..3d7c407254c 100644
--- a/src/main/runtime/rpc/methods/worktree.ts
+++ b/src/main/runtime/rpc/methods/worktree.ts
@@ -235,13 +235,13 @@ export const WORKTREE_METHODS = [
}
}
}
- const removalArgs = [
- params.worktree,
- params.force === true,
- params.runHooks === true,
- params.allowUnverifiedPtyStop === true
- ] as const
- const result = await runtime.removeManagedWorktree(...removalArgs, resolvedHostId)
+ const result = await runtime.removeManagedWorktree(params.worktree, {
+ force: params.force === true,
+ runHooks: params.runHooks === true,
+ allowUnverifiedPtyStop: params.allowUnverifiedPtyStop === true,
+ allowFailedArchiveHook: params.allowFailedArchiveHook === true,
+ ...(resolvedHostId ? { hostId: resolvedHostId } : {})
+ })
return { removed: true, ...result }
}
}),
diff --git a/src/main/runtime/runtime-registered-local-worktree-removal.ts b/src/main/runtime/runtime-registered-local-worktree-removal.ts
index d5df7e52132..8f34b1df393 100644
--- a/src/main/runtime/runtime-registered-local-worktree-removal.ts
+++ b/src/main/runtime/runtime-registered-local-worktree-removal.ts
@@ -1,5 +1,7 @@
import type { GitPushTarget, GitWorktreeInfo } from '../../shared/worktree/types'
import type { RemoveWorktreeResult } from '../../shared/worktree/create-types'
+import type { ArchiveHookOverride } from '../../shared/worktree/archive-hook-removal-gate'
+import { gateWorktreeRemovalOnArchiveHook } from '../worktree-archive-hook-gate'
import type { Repo } from '../../shared/repo-types'
import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal'
import type { LocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
@@ -40,6 +42,8 @@ export async function removeRuntimeRegisteredLocalWorktree(args: {
hasLocalOptions: boolean
force: boolean
runHooks: boolean
+ /** Explicit waiver for a FAILED archive hook. Never implied by `force` — see #19334. */
+ allowFailedArchiveHook: boolean
allowUnverifiedPtyStop: boolean
deleteBranch: boolean
acquireWatcherRemoval: (path: string) => Promise<{ finish: (removed: boolean) => Promise }>
@@ -60,6 +64,9 @@ export async function removeRuntimeRegisteredLocalWorktree(args: {
const canonicalPath = registeredWorktree.path
const hooks = getEffectiveHooks(repo)
let warning: string | undefined
+ // Precondition, not an advisory: this runs before the registration refresh, the preflights, the
+ // PTY stop and `removeWorktree`, so a throw here leaves every one of them untouched (#19334).
+ let archiveHookOverride: ArchiveHookOverride | undefined
if (hooks?.scripts.archive && args.runHooks) {
const result = await runHook(
'archive',
@@ -68,9 +75,11 @@ export async function removeRuntimeRegisteredLocalWorktree(args: {
undefined,
args.hasLocalOptions ? localOptions : undefined
)
- if (!result.success) {
- console.error(`[hooks] archive hook failed for ${canonicalPath}:`, result.output)
- }
+ archiveHookOverride = gateWorktreeRemovalOnArchiveHook({
+ worktreePath: canonicalPath,
+ result,
+ allowFailure: args.allowFailedArchiveHook
+ })
} else if (hooks?.scripts.archive) {
warning = `orca.yaml archive hook skipped for ${canonicalPath}; pass --run-hooks to run it.`
console.warn(`[hooks] ${warning}`)
@@ -151,7 +160,10 @@ export async function removeRuntimeRegisteredLocalWorktree(args: {
await cleanupPushTarget(args)
args.finishRemoval(undefined, false, refreshed.head)
completed = true
- return warning ? { warning } : {}
+ return {
+ ...(archiveHookOverride ? { archiveHookOverride } : {}),
+ ...(warning ? { warning } : {})
+ }
} else {
throw new Error(formatWorktreeRemovalError(error, canonicalPath, args.force))
}
@@ -162,7 +174,11 @@ export async function removeRuntimeRegisteredLocalWorktree(args: {
}
await cleanupPushTarget(args)
args.finishRemoval(removalResult, true, refreshed.head)
- return { ...removalResult, ...(warning ? { warning } : {}) }
+ return {
+ ...removalResult,
+ ...(archiveHookOverride ? { archiveHookOverride } : {}),
+ ...(warning ? { warning } : {})
+ }
}
async function cleanupOrphanedDirectory(
diff --git a/src/main/runtime/runtime-registered-remote-worktree-removal.ts b/src/main/runtime/runtime-registered-remote-worktree-removal.ts
index 6eec18d52c8..b971072e979 100644
--- a/src/main/runtime/runtime-registered-remote-worktree-removal.ts
+++ b/src/main/runtime/runtime-registered-remote-worktree-removal.ts
@@ -5,6 +5,7 @@ import type { SshGitProvider } from '../providers/ssh-git-provider'
import { cleanupUnusedWorktreePushTargetRemoteSsh } from '../ipc/worktree-remote'
import type { RuntimeStore } from './runtime-store-contract'
import type { RuntimeWorktreeRemovalTarget } from './runtime-worktree-selection'
+import { gateRemovalWhereArchiveHookCannotRun } from '../worktree-archive-hook-gate'
export async function removeRuntimeRegisteredRemoteWorktree(args: {
repo: Repo
@@ -15,6 +16,10 @@ export async function removeRuntimeRegisteredRemoteWorktree(args: {
provider: SshGitProvider
/** From the resolved removal route; `repo.connectionId!` answered null for an `ssh:`-only row. */
connectionId: string
+ /** #19334: this path runs no archive hook, so the gate below decides what that means. */
+ runHooks: boolean
+ /** Explicit waiver for that refusal; without it the block has no exit on this path. */
+ allowFailedArchiveHook: boolean
force: boolean
allowUnverifiedPtyStop: boolean
deleteBranch: boolean
@@ -29,8 +34,17 @@ export async function removeRuntimeRegisteredRemoteWorktree(args: {
fallbackHead: string | undefined
) => RemoveWorktreeResult
finishRemoval: (result: RemoveWorktreeResult) => void
-}): Promise {
+}): Promise {
const { repo, target, registeredWorktree, provider, connectionId } = args
+ // Precondition, before anything is stopped or deleted: no archive hook runs here, so a removal
+ // that asked for one refuses rather than deleting with the archive step silently skipped.
+ const hookGate = await gateRemovalWhereArchiveHookCannotRun({
+ repo,
+ connectionId,
+ worktreePath: registeredWorktree.path,
+ runHooks: args.runHooks,
+ allowFailedArchiveHook: args.allowFailedArchiveHook
+ })
const removeOptions = !args.deleteBranch ? { deleteBranch: args.deleteBranch } : {}
const gate = await args.acquireWatcherRemoval(registeredWorktree.path, connectionId)
let rawResult: RemoveWorktreeResult | undefined
@@ -54,5 +68,9 @@ export async function removeRuntimeRegisteredRemoteWorktree(args: {
)
await args.deleteHistory()
args.finishRemoval(result)
- return result
+ return {
+ ...result,
+ ...(hookGate.override ? { archiveHookOverride: hookGate.override } : {}),
+ ...(hookGate.warning ? { warning: hookGate.warning } : {})
+ }
}
diff --git a/src/main/runtime/runtime-worktree-selection.test.ts b/src/main/runtime/runtime-worktree-selection.test.ts
index 0509d94a2b4..2fa602fe982 100644
--- a/src/main/runtime/runtime-worktree-selection.test.ts
+++ b/src/main/runtime/runtime-worktree-selection.test.ts
@@ -1,5 +1,39 @@
import { describe, expect, it } from 'vitest'
-import { runtimeRepoMatchesExecutionHost } from './runtime-worktree-selection'
+import {
+ getRuntimeWorktreeRemovalOptionsKey,
+ runtimeRepoMatchesExecutionHost
+} from './runtime-worktree-selection'
+
+describe('getRuntimeWorktreeRemovalOptionsKey', () => {
+ it('separates a waived archive-hook retry from the attempt about to refuse on it (#19334)', () => {
+ const strict = getRuntimeWorktreeRemovalOptionsKey({ runHooks: true })
+ expect(
+ getRuntimeWorktreeRemovalOptionsKey({ runHooks: true, allowFailedArchiveHook: true })
+ ).not.toBe(strict)
+ })
+
+ it('keeps every waiver on its own axis, so none of them coalesce', () => {
+ const keys = [
+ {},
+ { force: true },
+ { runHooks: true },
+ { allowUnverifiedPtyStop: true },
+ { allowFailedArchiveHook: true }
+ ].map(getRuntimeWorktreeRemovalOptionsKey)
+ expect(new Set(keys).size).toBe(keys.length)
+ })
+
+ it('treats an omitted option as its off value', () => {
+ expect(getRuntimeWorktreeRemovalOptionsKey({})).toBe(
+ getRuntimeWorktreeRemovalOptionsKey({
+ force: false,
+ runHooks: false,
+ allowUnverifiedPtyStop: false,
+ allowFailedArchiveHook: false
+ })
+ )
+ })
+})
describe('runtimeRepoMatchesExecutionHost', () => {
it('matches an unstamped SSH repo against its own host (#11163)', () => {
diff --git a/src/main/runtime/runtime-worktree-selection.ts b/src/main/runtime/runtime-worktree-selection.ts
index 7e3fc5be481..230f2952da8 100644
--- a/src/main/runtime/runtime-worktree-selection.ts
+++ b/src/main/runtime/runtime-worktree-selection.ts
@@ -26,15 +26,35 @@ export function gitStatusErrorMeansNotRepository(error: unknown): boolean {
return /not a git repository/i.test(`${message}\n${stderr}`)
}
+/**
+ * Options for `removeManagedWorktree`. Named rather than positional on purpose: three of the
+ * four are interchangeable booleans that each waive a different safety check on a destructive
+ * delete, so a transposition would silently delete a checkout the caller meant to protect.
+ */
+export type RemoveManagedWorktreeOptions = {
+ force?: boolean
+ runHooks?: boolean
+ /** Waives proof that every PTY stopped (#11960). Set by explicit Force Delete only. */
+ allowUnverifiedPtyStop?: boolean
+ /** Waives a FAILED archive hook (#19334). Never implied by `force`, never by `runHooks`. */
+ allowFailedArchiveHook?: boolean
+ hostId?: string
+}
+
export function getRuntimeWorktreeRemovalOptionsKey(
- force: boolean,
- runHooks: boolean,
- allowUnverifiedPtyStop: boolean
+ options: Pick<
+ RemoveManagedWorktreeOptions,
+ 'force' | 'runHooks' | 'allowUnverifiedPtyStop' | 'allowFailedArchiveHook'
+ >
): string {
// Why: a forced retry must not coalesce onto the in-flight attempt that just
// failed the PTY gate — it would inherit that failure instead of retrying.
- const ptyKey = allowUnverifiedPtyStop ? 'allow-unverified-pty' : 'require-pty-stop'
- return `${force ? 'force' : 'normal'}:${runHooks ? 'run-hooks' : 'skip-hooks'}:${ptyKey}`
+ const ptyKey = options.allowUnverifiedPtyStop ? 'allow-unverified-pty' : 'require-pty-stop'
+ // Same reason for the archive waiver: a retry that waives the failed hook must not coalesce
+ // onto the in-flight attempt that is about to refuse on it.
+ const archiveKey = options.allowFailedArchiveHook ? 'allow-failed-archive' : 'require-archive'
+ const hooksKey = options.runHooks ? 'run-hooks' : 'skip-hooks'
+ return `${options.force ? 'force' : 'normal'}:${hooksKey}:${ptyKey}:${archiveKey}`
}
// Null executionHostId means host-unaware: path-only callers match any repo, and the first runtime
diff --git a/src/main/worktree-archive-hook-cannot-run.test.ts b/src/main/worktree-archive-hook-cannot-run.test.ts
new file mode 100644
index 00000000000..9900f684c53
--- /dev/null
+++ b/src/main/worktree-archive-hook-cannot-run.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { Repo } from '../shared/repo-types'
+import { gateRemovalWhereArchiveHookCannotRun } from './worktree-archive-hook-gate'
+import {
+ ARCHIVE_HOOK_FAILED_REMOVAL_CODE,
+ asArchiveHookRefusal
+} from '../shared/worktree/archive-hook-removal-gate'
+
+// Mocked at the SSH-aware reader, because that is the whole point: on an SSH worktree the hook
+// lives on the execution host, not on the runtime's local disk.
+const { getArchiveHooksForRemovalMock } = vi.hoisted(() => ({
+ getArchiveHooksForRemovalMock: vi.fn()
+}))
+vi.mock('./ipc/worktrees/removal/worktree-archive-hook', () => ({
+ getArchiveHooksForRemoval: getArchiveHooksForRemovalMock
+}))
+
+const REPO: Repo = { id: 'r', path: '/repo', displayName: 'r', badgeColor: '#000', addedAt: 0 }
+
+const withArchiveHook = (present: boolean): void => {
+ getArchiveHooksForRemovalMock.mockResolvedValue(
+ present ? { scripts: { archive: 'archive.sh' } } : null
+ )
+}
+
+const gate = (over: Partial[0]> = {}) =>
+ gateRemovalWhereArchiveHookCannotRun({
+ repo: REPO,
+ connectionId: undefined,
+ worktreePath: '/w/f',
+ runHooks: true,
+ allowFailedArchiveHook: false,
+ ...over
+ })
+
+// Why (#19334 / S1): the runtime's SSH path runs no archive hook. Silently deleting there would
+// reproduce the reported bug in the one place `worktree.archive-failure-blocking.v1` promises it
+// cannot happen, so the capability would be advertising a guarantee it does not keep.
+describe('gateRemovalWhereArchiveHookCannotRun', () => {
+ it('lets a repo with no archive hook through untouched', async () => {
+ withArchiveHook(false)
+ await expect(gate()).resolves.toEqual({})
+ })
+
+ it('warns rather than refuses when hooks were not requested', async () => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+ withArchiveHook(true)
+ await expect(gate({ runHooks: false })).resolves.toMatchObject({
+ warning: expect.stringContaining('pass --run-hooks to run it')
+ })
+ })
+
+ // Why (#19334): reading locally would miss the committed hook on an SSH host entirely.
+ it('asks the execution host whether a hook exists, not the local disk', async () => {
+ withArchiveHook(false)
+ await gate({ connectionId: 'ssh-target' })
+ expect(getArchiveHooksForRemovalMock).toHaveBeenCalledWith(REPO, 'ssh-target')
+ })
+
+ it('refuses a hooks-requested removal it cannot honour, as unverifiable', async () => {
+ withArchiveHook(true)
+ const refusal = asArchiveHookRefusal(await gate().catch((error: unknown) => error))
+
+ expect(refusal.code).toBe(ARCHIVE_HOOK_FAILED_REMOVAL_CODE)
+ // Never `exited`: nothing ran, so nothing reported an exit to read.
+ expect(refusal.data).toMatchObject({ worktreePath: '/w/f', outcome: 'unverifiable' })
+ expect(refusal.data.exitCode).toBeUndefined()
+ })
+
+ // Why this matters: without it the refusal is a dead loop. The desktop's "Delete Anyway" and the
+ // CLI's --allow-failed-archive-hook both land here, and a block with no reachable exit on the
+ // surface where it happens is the failure mode this PR fixed on the desktop path.
+ it('deletes anyway when the refusal is explicitly waived, and records it', async () => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+ withArchiveHook(true)
+
+ const result = await gate({ allowFailedArchiveHook: true })
+
+ expect(result.warning).toBeUndefined()
+ expect(result.override).toMatchObject({
+ worktreePath: '/w/f',
+ outcome: 'unverifiable',
+ overridden: true
+ })
+ })
+})
diff --git a/src/main/worktree-archive-hook-gate.ts b/src/main/worktree-archive-hook-gate.ts
new file mode 100644
index 00000000000..1c50635372b
--- /dev/null
+++ b/src/main/worktree-archive-hook-gate.ts
@@ -0,0 +1,86 @@
+import type { Repo } from '../shared/repo-types'
+import { getArchiveHooksForRemoval } from './ipc/worktrees/removal/worktree-archive-hook'
+import {
+ WorktreeArchiveHookFailedError,
+ formatArchiveHookOverride,
+ type ArchiveHookFailure,
+ classifyArchiveHookFailure,
+ formatArchiveHookFailure,
+ type ArchiveHookOverride,
+ type ArchiveHookRunResult
+} from '../shared/worktree/archive-hook-removal-gate'
+
+/**
+ * The archive-hook precondition for a destructive worktree removal (#19334). Call it while the
+ * checkout, its registration, its agents and its ownership evidence are all still intact: on a
+ * failure it throws, and no caller may stop a PTY, deregister, or delete before it has returned.
+ *
+ * Returns the override record when the failure was explicitly waived, `undefined` on success.
+ */
+export function gateWorktreeRemovalOnArchiveHook(args: {
+ worktreePath: string
+ result: ArchiveHookRunResult
+ allowFailure: boolean
+}): ArchiveHookOverride | undefined {
+ if (args.result.success) {
+ return undefined
+ }
+ const failure = classifyArchiveHookFailure(args.worktreePath, args.result)
+ if (!args.allowFailure) {
+ console.error(`[hooks] ${formatArchiveHookFailure(failure)}`)
+ throw new WorktreeArchiveHookFailedError(failure)
+ }
+ console.warn(
+ `[hooks] archive hook failure overridden for ${args.worktreePath}; deleting anyway:`,
+ args.result.output
+ )
+ return { ...failure, overridden: true }
+}
+
+/**
+ * The runtime's SSH removal path cannot run an archive hook at all (see #18563, which adds it).
+ * Until it can, a removal that asked for hooks has to refuse rather than delete: deleting would
+ * repeat exactly the bug this gate exists to stop, and reporting success would make
+ * `worktree.archive-failure-blocking.v1` a lie in the one case the reporter asked it to cover.
+ *
+ * Modelled as `unverifiable` because that is what it is — the hook's outcome was never observed —
+ * so it reuses the same typed error, the same `--allow-failed-archive-hook` waiver, and the same
+ * desktop "Delete Anyway" affordance as any other unobserved hook. Waiving it records the same
+ * `archiveHookOverride` the other paths return, so a caller is told what it accepted.
+ *
+ * Returns the skipped-hook warning when hooks were not requested, matching the local path.
+ *
+ * Hooks are read through `getArchiveHooksForRemoval` rather than `getEffectiveHooks`: on an
+ * SSH-hosted worktree `repo.path` names a path on the EXECUTION host, so a local read would miss
+ * the committed `orca.yaml` this gate exists for, and could refuse on a coincidental local one.
+ */
+export async function gateRemovalWhereArchiveHookCannotRun(args: {
+ repo: Repo
+ /** The removal route's owner; `repo.connectionId` is null for an `ssh:`-only row. */
+ connectionId: string | undefined
+ worktreePath: string
+ runHooks: boolean
+ /** Explicit waiver. Without it the refusal below has no exit on this path. */
+ allowFailedArchiveHook: boolean
+}): Promise<{ warning?: string; override?: ArchiveHookOverride }> {
+ const hooks = await getArchiveHooksForRemoval(args.repo, args.connectionId)
+ if (!hooks?.scripts.archive) {
+ return {}
+ }
+ if (!args.runHooks) {
+ const warning = `orca.yaml archive hook skipped for ${args.worktreePath}; pass --run-hooks to run it.`
+ console.warn(`[hooks] ${warning}`)
+ return { warning }
+ }
+ const failure: ArchiveHookFailure = {
+ worktreePath: args.worktreePath,
+ outcome: 'unverifiable',
+ output:
+ 'This host cannot run an archive hook for an SSH-hosted worktree, so the hook never ran. Remove it from the desktop app, which does run it, or delete anyway to accept that nothing was archived.'
+ }
+ if (!args.allowFailedArchiveHook) {
+ throw new WorktreeArchiveHookFailedError(failure)
+ }
+ console.warn(`[hooks] ${formatArchiveHookOverride({ ...failure, overridden: true })}`)
+ return { override: { ...failure, overridden: true } }
+}
diff --git a/src/preload/api/worktree-api.ts b/src/preload/api/worktree-api.ts
index d665d278580..115ee66d9a2 100644
--- a/src/preload/api/worktree-api.ts
+++ b/src/preload/api/worktree-api.ts
@@ -98,6 +98,9 @@ export type WorktreeApi = {
// may waive the proof that every PTY stopped.
allowUnverifiedPtyStop?: boolean
skipArchive?: boolean
+ // Why (#19334): distinct from `skipArchive` (never runs the hook) and never implied by
+ // `force` — this waives a hook that ran and FAILED.
+ allowFailedArchiveHook?: boolean
snapshotPruneBatchId?: string
}) => Promise
// Forget a workspace from Orca only (no remote Git/FS work) — for workspaces pinned to a removed/disconnected SSH host.
diff --git a/src/renderer/src/components/settings/DevToolsPane.tsx b/src/renderer/src/components/settings/DevToolsPane.tsx
index 5084dbfb4de..7042e67758a 100644
--- a/src/renderer/src/components/settings/DevToolsPane.tsx
+++ b/src/renderer/src/components/settings/DevToolsPane.tsx
@@ -102,6 +102,13 @@ function showDeleteFailureToast(): void {
),
canForceDelete: true,
forceDeleteReason: 'dirty',
+ onDeleteAnyway: () =>
+ toast.error(
+ translate(
+ 'auto.components.settings.DevToolsPane.deleteAnywayClicked',
+ 'Delete Anyway clicked'
+ )
+ ),
onViewChanges: () =>
toast.message(
translate(
diff --git a/src/renderer/src/components/sidebar/delete-worktree-failure-toast.test.tsx b/src/renderer/src/components/sidebar/delete-worktree-failure-toast.test.tsx
index fe2350f38f3..632a05f048d 100644
--- a/src/renderer/src/components/sidebar/delete-worktree-failure-toast.test.tsx
+++ b/src/renderer/src/components/sidebar/delete-worktree-failure-toast.test.tsx
@@ -51,6 +51,7 @@ describe('showDeleteWorktreeFailureToast', () => {
it('uses a persistent in-body action footer when force delete is available', () => {
const onViewChanges = vi.fn()
const onForceDelete = vi.fn()
+ const onDeleteAnyway = vi.fn()
showDeleteWorktreeFailureToast({
error: 'branch has changes',
@@ -58,6 +59,7 @@ describe('showDeleteWorktreeFailureToast', () => {
forceDeleteReason: 'dirty',
onViewChanges,
onForceDelete,
+ onDeleteAnyway,
worktreeId: 'wt-1',
worktreeName: 'feature/foo'
})
@@ -97,6 +99,7 @@ describe('showDeleteWorktreeFailureToast', () => {
forceDeleteReason: null,
onViewChanges,
onForceDelete: vi.fn(),
+ onDeleteAnyway: vi.fn(),
worktreeId: 'wt-2',
worktreeName: 'feature/bar'
})
@@ -119,6 +122,53 @@ describe('showDeleteWorktreeFailureToast', () => {
expect(onViewChanges).toHaveBeenCalled()
})
+ // #19334: the archive-hook refusal is the one failure a user clears by waiving rather than by
+ // fixing state, and the desktop is where most people meet it.
+ it('offers Delete Anyway when the archive hook refused the removal', () => {
+ const onDeleteAnyway = vi.fn()
+
+ showDeleteWorktreeFailureToast({
+ error: 'Archive hook failed for worktree: /w/feature — exited 23.',
+ canForceDelete: false,
+ forceDeleteReason: null,
+ canWaiveArchiveHook: true,
+ onViewChanges: vi.fn(),
+ onForceDelete: vi.fn(),
+ onDeleteAnyway,
+ worktreeId: 'wt-archive',
+ worktreeName: 'feature/archive'
+ })
+
+ expect(toast.error).toHaveBeenCalledWith(
+ 'Failed to delete workspace feature/archive',
+ // The user has to read the reason before choosing, so this toast must not expire.
+ expect.objectContaining({ duration: Infinity })
+ )
+
+ const body = renderToastBody('error')
+ expect(body.textContent).toContain('Delete Anyway')
+ expect(body.textContent).not.toContain('Force Delete')
+
+ clickButton(body, 'Delete Anyway')
+ expect(toast.dismiss).toHaveBeenCalledWith('delete-worktree-failure:wt-archive')
+ expect(onDeleteAnyway).toHaveBeenCalled()
+ })
+
+ it('does not offer Delete Anyway for an ordinary failure', () => {
+ showDeleteWorktreeFailureToast({
+ error: 'permission denied',
+ canForceDelete: false,
+ forceDeleteReason: null,
+ onViewChanges: vi.fn(),
+ onForceDelete: vi.fn(),
+ onDeleteAnyway: vi.fn(),
+ worktreeId: 'wt-plain',
+ worktreeName: 'feature/plain'
+ })
+
+ expect(renderToastBody('error').textContent).not.toContain('Delete Anyway')
+ })
+
it('offers neither force delete nor View for a locked workspace', () => {
const onViewChanges = vi.fn()
@@ -128,6 +178,7 @@ describe('showDeleteWorktreeFailureToast', () => {
forceDeleteReason: null,
onViewChanges,
onForceDelete: vi.fn(),
+ onDeleteAnyway: vi.fn(),
worktreeId: 'wt-locked',
worktreeName: 'feature/locked'
})
@@ -151,6 +202,7 @@ describe('showDeleteWorktreeFailureToast', () => {
hasKnownChanges: true,
onViewChanges: vi.fn(),
onForceDelete: vi.fn(),
+ onDeleteAnyway: vi.fn(),
worktreeId: 'wt-locked-dirty',
worktreeName: 'feature/locked-dirty'
})
diff --git a/src/renderer/src/components/sidebar/delete-worktree-failure-toast.tsx b/src/renderer/src/components/sidebar/delete-worktree-failure-toast.tsx
index 549c36c97a0..303dc6bea39 100644
--- a/src/renderer/src/components/sidebar/delete-worktree-failure-toast.tsx
+++ b/src/renderer/src/components/sidebar/delete-worktree-failure-toast.tsx
@@ -13,8 +13,11 @@ type DeleteWorktreeFailureToastOptions = {
forceDeleteReason: WorktreeForceDeleteReason | null
lockReason?: string | null
hasKnownChanges?: boolean
+ /** The archive hook refused this removal, so the user may waive it (#19334). */
+ canWaiveArchiveHook?: boolean
onViewChanges: () => void
onForceDelete: () => void
+ onDeleteAnyway: () => void
worktreeId: string
worktreeName: string
}
@@ -26,16 +29,20 @@ function deleteWorktreeFailureToastId(worktreeId: string): string {
function DeleteWorktreeFailureToastBody({
description,
canForceDelete,
+ canWaiveArchiveHook,
showViewChanges,
onViewChanges,
onForceDelete,
+ onDeleteAnyway,
toastId
}: {
description?: string
canForceDelete: boolean
+ canWaiveArchiveHook: boolean
showViewChanges: boolean
onViewChanges: () => void
onForceDelete: () => void
+ onDeleteAnyway: () => void
toastId: string
}): React.JSX.Element {
const viewChanges = (): void => {
@@ -46,6 +53,10 @@ function DeleteWorktreeFailureToastBody({
toast.dismiss(toastId)
onForceDelete()
}
+ const deleteAnyway = (): void => {
+ toast.dismiss(toastId)
+ onDeleteAnyway()
+ }
return (
@@ -63,6 +74,14 @@ function DeleteWorktreeFailureToastBody({
{translate('auto.components.sidebar.delete.worktree.flow.2b20ce87b3', 'Force Delete')}
) : null}
+ {canWaiveArchiveHook ? (
+
+ {translate(
+ 'auto.components.sidebar.delete.worktree.failure.archive.waiver',
+ 'Delete Anyway'
+ )}
+
+ ) : null}
)
@@ -74,8 +93,10 @@ export function showDeleteWorktreeFailureToast({
forceDeleteReason,
lockReason,
hasKnownChanges,
+ canWaiveArchiveHook,
onViewChanges,
onForceDelete,
+ onDeleteAnyway,
worktreeId,
worktreeName
}: DeleteWorktreeFailureToastOptions): void {
@@ -96,13 +117,16 @@ export function showDeleteWorktreeFailureToast({
),
- duration: canForceDelete ? Infinity : 10000,
+ // A toast offering a destructive choice must not expire before the user reads the reason.
+ duration: canForceDelete || canWaiveArchiveHook === true ? Infinity : 10000,
dismissible: true
})
}
diff --git a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts
index a84b8323d03..8f4b92a8cd2 100644
--- a/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts
+++ b/src/renderer/src/components/sidebar/delete-worktree-flow.test.ts
@@ -1,7 +1,20 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExecutionHostId } from '../../../../shared/execution-host'
+type MockWorktreeDeleteState = {
+ isDeleting?: boolean
+ error?: string | null
+ canForceDelete?: boolean
+ forceDeleteReason?: 'dirty' | null
+ lockReason?: string | null
+ canWaiveArchiveHook?: boolean
+ executionHostId?: ExecutionHostId | null
+}
+
const mocks = vi.hoisted(() => {
+ // Declared up here so the empty initialisers can be typed rather than asserted.
+ const gitStatusByWorktree: Record = {}
+ const deleteStateByWorktreeId: Record = {}
const state = {
settings: { skipDeleteWorktreeConfirm: false },
worktreeMap: new Map<
@@ -35,18 +48,8 @@ const mocks = vi.hoisted(() => {
setRightSidebarTab: vi.fn(),
setRightSidebarOpen: vi.fn(),
removeWorktree: vi.fn().mockResolvedValue({ ok: true }),
- gitStatusByWorktree: {} as Record,
- deleteStateByWorktreeId: {} as Record<
- string,
- {
- isDeleting?: boolean
- error?: string | null
- canForceDelete?: boolean
- forceDeleteReason?: 'dirty' | null
- lockReason?: string | null
- executionHostId?: ExecutionHostId | null
- }
- >
+ gitStatusByWorktree,
+ deleteStateByWorktreeId
}
return { state }
})
@@ -631,4 +634,42 @@ describe('delete worktree flow', () => {
description: 'Refresh Space and try again if the workspace list looks stale.'
})
})
+
+ // #19334: a waived delete is still a delete — the caller's bookkeeping has to hear about it, or a
+ // batch/Space-panel list keeps showing the workspace it just removed.
+ it('reports a Delete Anyway success to the caller like a force retry', async () => {
+ mocks.state.settings = { skipDeleteWorktreeConfirm: true }
+ mocks.state.removeWorktree
+ .mockImplementationOnce(async () => {
+ mocks.state.deleteStateByWorktreeId['wt-1'] = {
+ isDeleting: false,
+ error: 'Archive hook failed for worktree: /w/one — exited 23.',
+ canForceDelete: false,
+ forceDeleteReason: null,
+ canWaiveArchiveHook: true
+ }
+ return { ok: false, error: 'Archive hook failed for worktree: /w/one — exited 23.' }
+ })
+ .mockResolvedValueOnce({ ok: true })
+ setWorktrees([{ id: 'wt-1', displayName: 'one' }])
+ const onDeleted = vi.fn()
+
+ expect(runWorktreeBatchDelete(['wt-1'], { onDeleted })).toBe(true)
+
+ await vi.waitFor(() => expect(showDeleteWorktreeFailureToast).toHaveBeenCalled())
+ const toastOptions = vi.mocked(showDeleteWorktreeFailureToast).mock.calls[0]?.[0]
+ expect(toastOptions?.canWaiveArchiveHook).toBe(true)
+ toastOptions?.onDeleteAnyway()
+
+ await vi.waitFor(() => {
+ // The waiver rides its own option; force stays whatever the original attempt used.
+ expect(mocks.state.removeWorktree).toHaveBeenNthCalledWith(
+ 2,
+ { id: 'wt-1', executionHostId: null },
+ false,
+ { allowFailedArchiveHook: true }
+ )
+ expect(onDeleted).toHaveBeenCalledWith([{ id: 'wt-1', executionHostId: null }])
+ })
+ })
})
diff --git a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts
index 3f844d6f09d..82e3a1ed048 100644
--- a/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts
+++ b/src/renderer/src/components/sidebar/run-worktree-delete-with-toast.ts
@@ -38,6 +38,101 @@ export function runWorktreeDeleteWithToast(
...(options.suppressPreservedBranchToast ? { suppressPreservedBranchToast: true } : {}),
...(options.snapshotPruneBatchId ? { snapshotPruneBatchId: options.snapshotPruneBatchId } : {})
}
+ const showFailureToast = (
+ error: string,
+ state: ReturnType
+ ): void => {
+ const hasKnownChanges =
+ (useAppStore.getState().gitStatusByWorktree[worktreeId]?.length ?? 0) > 0
+ showDeleteWorktreeFailureToast({
+ error,
+ canForceDelete: state?.canForceDelete ?? false,
+ canWaiveArchiveHook: state?.canWaiveArchiveHook === true,
+ forceDeleteReason: state?.forceDeleteReason ?? null,
+ lockReason: state?.lockReason ?? null,
+ hasKnownChanges,
+ onViewChanges: () => viewWorktreeDiff(worktreeId, target.executionHostId),
+ // Why (#19334): re-runs the archive hook and waives the failure this time, so the waiver
+ // is an informed choice made after reading the refusal -- not something `force` implied.
+ onDeleteAnyway: () =>
+ retryFromToast({ force: options.force === true, allowFailedArchiveHook: true }),
+ // The explicit Force Delete retry may waive an unverified PTY-stop proof.
+ onForceDelete: () =>
+ retryFromToast({
+ force: true,
+ allowUnverifiedPtyStop: true,
+ failedTitle: translate(
+ 'auto.components.sidebar.delete.worktree.flow.4f3876c0f5',
+ 'Force delete failed'
+ ),
+ withViewAction: true
+ }),
+ worktreeId,
+ worktreeName
+ })
+ }
+
+ // Both toast buttons do the same thing: recapture focus (the user may have navigated while the
+ // toast was open), retry with one waiver added, and report a success through `onForceDeleted` so
+ // the caller's bookkeeping runs. Only the waiver and the failure copy differ.
+ const retryFromToast = (retry: {
+ force: boolean
+ allowUnverifiedPtyStop?: boolean
+ allowFailedArchiveHook?: boolean
+ failedTitle?: string
+ withViewAction?: boolean
+ }): void => {
+ const commitRetryFocus = prepareActiveWorktreeFocusAfterDelete(worktreeId)
+ const viewAction = retry.withViewAction
+ ? {
+ action: {
+ label: translate('auto.components.sidebar.delete.worktree.flow.7488ed8711', 'View'),
+ onClick: () => viewWorktreeDiff(worktreeId, target.executionHostId)
+ }
+ }
+ : {}
+ // Why re-show the full failure toast rather than a bare `toast.error` (#19334): a retry can
+ // fail for a DIFFERENT reason than the one the user just answered. Waiving a failed archive
+ // hook on a dirty checkout lands on the dirty preflight next, and a bare error offers no
+ // buttons — leaving the user stuck one step further in, which is the dead end this gate has
+ // now produced three times. Routing back through the same toast keeps every retry actionable.
+ const failed = (description: string): void => {
+ const retryState = getDeleteStateForWorktreeHost(
+ { id: worktreeId, hostId: target.executionHostId ?? undefined },
+ useAppStore.getState().deleteStateByWorktreeId
+ )
+ if (retryState?.canForceDelete === true || retryState?.canWaiveArchiveHook === true) {
+ showFailureToast(description, retryState)
+ return
+ }
+ toast.error(
+ retry.failedTitle ??
+ translate(
+ 'auto.components.sidebar.delete.worktree.flow.ae57cbf6e4',
+ 'Failed to delete workspace'
+ ),
+ { description, ...viewAction }
+ )
+ }
+ useAppStore
+ .getState()
+ .removeWorktree(target, retry.force, {
+ ...(retry.allowUnverifiedPtyStop ? { allowUnverifiedPtyStop: true } : {}),
+ ...(retry.allowFailedArchiveHook ? { allowFailedArchiveHook: true } : {})
+ })
+ .then((result) => {
+ if (!result.ok) {
+ failed(result.error)
+ return
+ }
+ commitRetryFocus()
+ // "A retry started from this toast completed the delete" — callers hang their bookkeeping
+ // off it, so without this a batch or Space-panel delete keeps listing what it removed.
+ options.onForceDeleted?.(target)
+ })
+ .catch((err: unknown) => failed(err instanceof Error ? err.message : String(err)))
+ }
+
const removal =
Object.keys(removeOptions).length > 0
? removeWorktree(target, options.force === true, removeOptions)
@@ -61,73 +156,13 @@ export function runWorktreeDeleteWithToast(
}
return true
}
- const state = getDeleteStateForWorktreeHost(
- { id: worktreeId, hostId: target.executionHostId ?? undefined },
- useAppStore.getState().deleteStateByWorktreeId
+ showFailureToast(
+ result.error,
+ getDeleteStateForWorktreeHost(
+ { id: worktreeId, hostId: target.executionHostId ?? undefined },
+ useAppStore.getState().deleteStateByWorktreeId
+ )
)
- const canForceDelete = state?.canForceDelete ?? false
- const hasKnownChanges =
- (useAppStore.getState().gitStatusByWorktree[worktreeId]?.length ?? 0) > 0
- showDeleteWorktreeFailureToast({
- error: result.error,
- canForceDelete,
- forceDeleteReason: state?.forceDeleteReason ?? null,
- lockReason: state?.lockReason ?? null,
- hasKnownChanges,
- onViewChanges: () => viewWorktreeDiff(worktreeId, target.executionHostId),
- onForceDelete: () => {
- // Recapture focus because the user may have navigated while the toast was open.
- const commitForceFocus = prepareActiveWorktreeFocusAfterDelete(worktreeId)
- // The explicit Force Delete retry may waive an unverified PTY-stop proof.
- const forceRemoval = useAppStore
- .getState()
- .removeWorktree(target, true, { allowUnverifiedPtyStop: true })
- forceRemoval
- .then((forceResult) => {
- if (!forceResult.ok) {
- toast.error(
- translate(
- 'auto.components.sidebar.delete.worktree.flow.4f3876c0f5',
- 'Force delete failed'
- ),
- {
- description: forceResult.error,
- action: {
- label: translate(
- 'auto.components.sidebar.delete.worktree.flow.7488ed8711',
- 'View'
- ),
- onClick: () => viewWorktreeDiff(worktreeId, target.executionHostId)
- }
- }
- )
- return
- }
- commitForceFocus()
- options.onForceDeleted?.(target)
- })
- .catch((err: unknown) => {
- toast.error(
- translate(
- 'auto.components.sidebar.delete.worktree.flow.ae57cbf6e4',
- 'Failed to delete workspace'
- ),
- {
- description: err instanceof Error ? err.message : String(err),
- action: {
- label: translate(
- 'auto.components.sidebar.delete.worktree.flow.7488ed8711',
- 'View'
- ),
- onClick: () => viewWorktreeDiff(worktreeId, target.executionHostId)
- }
- }
- )
- })
- },
- worktreeId,
- worktreeName
- })
return false
})
.catch((err: unknown) => {
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index e4345a6f5cc..2a0fe5755d1 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -5802,6 +5802,11 @@
"unstoppedPtyLive": "This workspace still has running terminals, so Orca stopped before deleting any files. Force Delete will kill them and discard any uncommitted work they hold.",
"runningAgentSession": "Orca could not confirm every agent session in this workspace has closed, so it stopped before deleting any files. Use Force Delete to remove it anyway.",
"runningAgentSessionLive": "This workspace still has running agent sessions that Orca could not close, so it stopped before deleting any files. Force Delete will discard any work they hold."
+ },
+ "failure": {
+ "archive": {
+ "waiver": "Delete Anyway"
+ }
}
}
},
@@ -10953,7 +10958,8 @@
"orcaCloudSignOut": "Sign out",
"orcaCloudConnect": "Connect profile",
"orcaCloudRefresh": "Refresh status",
- "orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build."
+ "orcaCloudNotConfigured": "Set ORCA_CLOUD_API_URL and ORCA_CLOUD_CLIENT_ID to preview Orca Cloud sign-in in this dev build.",
+ "deleteAnywayClicked": "Delete Anyway clicked"
},
"EphemeralVmRecipeRow": {
"useInWorkspace": "Use in workspace"
diff --git a/src/renderer/src/lib/ipc-error.test.ts b/src/renderer/src/lib/ipc-error.test.ts
index cf7ba9d6817..572bf8ffa4f 100644
--- a/src/renderer/src/lib/ipc-error.test.ts
+++ b/src/renderer/src/lib/ipc-error.test.ts
@@ -131,3 +131,38 @@ describe('readIpcErrorDetail', () => {
).toBe('SSH connection failed: Relay package not found.')
})
})
+
+// Why (#19334): a typed main-process error keeps its class name after the wrapper comes off, and
+// the worktree-removal refusal is rendered to a user who does not care what the class was called.
+describe('typed main-process errors', () => {
+ const wrapped = new Error(
+ "Error invoking remote method 'worktrees:remove': WorktreeArchiveHookFailedError: " +
+ 'Archive hook failed for worktree: /w/feature — exited 23.\nbackup target unreachable'
+ )
+
+ it('drops the error class name along with the wrapper', () => {
+ expect(readIpcErrorDetail(wrapped)).toBe(
+ 'Archive hook failed for worktree: /w/feature — exited 23.\nbackup target unreachable'
+ )
+ })
+
+ it('keeps the detail lines a refusal needs, unlike the clamped read', () => {
+ // The hook's own output is the actionable part of an archive refusal, so the unclamped read
+ // has to survive the newline that `readIpcErrorMessage` deliberately cuts at.
+ expect(readIpcErrorMessage(wrapped)).toBe(
+ 'Archive hook failed for worktree: /w/feature — exited 23.'
+ )
+ })
+
+ it('leaves a renderer-local error its class name, having unwrapped nothing', () => {
+ expect(readIpcErrorDetail(new Error('TypeError: x is not a function'))).toBe(
+ 'TypeError: x is not a function'
+ )
+ })
+
+ it('does not mistake an errno prefix for a class name', () => {
+ expect(
+ readIpcErrorDetail(new Error("Error occurred in handler for 'fs:read': EACCES: denied"))
+ ).toBe('EACCES: denied')
+ })
+})
diff --git a/src/renderer/src/lib/ipc-error.ts b/src/renderer/src/lib/ipc-error.ts
index 020bc7e9629..739119f3f5d 100644
--- a/src/renderer/src/lib/ipc-error.ts
+++ b/src/renderer/src/lib/ipc-error.ts
@@ -1,10 +1,17 @@
// Unanchored so caller-owned context around an Electron wrapper survives.
const IPC_INVOKE_PREFIX = /Error invoking remote method '[^']*':\s*(?:Error:\s*)?/
const IPC_HANDLER_PREFIX = /Error occurred in handler for '[^']*':\s*(?:Error:\s*)?/
+// Why (#19334): once the wrapper is gone, a typed main-process error still leads with its class
+// name — "WorktreeArchiveHookFailedError: Archive hook failed for worktree: …". That is noise to
+// someone reading a toast, and it pushes the sentence that matters off the first line.
+const ERROR_CLASS_PREFIX = /^(?:[A-Za-z_$][\w$]*)?Error:\s*/
function unwrapIpcErrorMessage(message: string): string | undefined {
+ const wrapped = IPC_INVOKE_PREFIX.test(message) || IPC_HANDLER_PREFIX.test(message)
const detail = message.replace(IPC_INVOKE_PREFIX, '').replace(IPC_HANDLER_PREFIX, '').trim()
- return detail || undefined
+ // Only strip the class name off something we actually unwrapped, so a renderer-local
+ // `TypeError: …` keeps the prefix that tells you what it was.
+ return (wrapped ? detail.replace(ERROR_CLASS_PREFIX, '').trim() : detail) || undefined
}
export function compactIpcErrorMessage(message: string): string | undefined {
diff --git a/src/renderer/src/store/slices/store-worktree-removal-cascade.test.ts b/src/renderer/src/store/slices/store-worktree-removal-cascade.test.ts
index 797b58cc25d..27d4ecc5201 100644
--- a/src/renderer/src/store/slices/store-worktree-removal-cascade.test.ts
+++ b/src/renderer/src/store/slices/store-worktree-removal-cascade.test.ts
@@ -377,18 +377,34 @@ describe('removeWorktree cascade', () => {
})
})
- it('offers force delete for Electron-wrapped local dirty preflight errors', async () => {
+ // Why a table (#19334): these three differ only in the wrapped message and the classification it
+ // earns. The shared body is what matters — the IPC wrapper is stripped for display while
+ // classification still reads the wrapped input.
+ it.each([
+ [
+ 'offers force delete for Electron-wrapped local dirty preflight errors',
+ "Error invoking remote method 'worktrees:remove': Error: Failed to delete worktree at /workspace/feature-wt. ?? scratch.txt",
+ 'Failed to delete worktree at /workspace/feature-wt. ?? scratch.txt',
+ { canForceDelete: true, forceDeleteReason: 'dirty' }
+ ],
+ [
+ 'offers force delete when Git already removed an unregistered worktree',
+ "Error invoking remote method 'worktrees:remove': Error: Worktree is no longer registered with Git and its directory is already gone.",
+ 'Worktree is no longer registered with Git and its directory is already gone.',
+ { canForceDelete: true, forceDeleteReason: 'missing-registration' }
+ ],
+ [
+ 'does not offer force delete when Electron wraps SSH filesystem provider failures',
+ "Error invoking remote method 'worktrees:remove': Error: SSH filesystem provider unavailable",
+ 'SSH filesystem provider unavailable',
+ { canForceDelete: false, forceDeleteReason: null }
+ ]
+ ])('%s', async (_title, wrapped, displayed, classification) => {
const store = createTestStore()
const worktreeId = 'repo1::/workspace/feature-wt'
- const error =
- "Error invoking remote method 'worktrees:remove': Error: Failed to delete worktree at /workspace/feature-wt. ?? scratch.txt"
-
- mockApi.worktrees.remove.mockRejectedValueOnce(new Error(error))
-
+ mockApi.worktrees.remove.mockRejectedValueOnce(new Error(wrapped))
seedStore(store, {
- worktreesByRepo: {
- repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1' })]
- },
+ worktreesByRepo: { repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1' })] },
tabsByWorktree: {},
ptyIdsByTabId: {},
terminalLayoutsByTabId: {}
@@ -396,12 +412,11 @@ describe('removeWorktree cascade', () => {
const result = await store.getState().removeWorktree({ id: worktreeId, executionHostId: null })
- expect(result).toEqual({ ok: false, error })
+ expect(result).toEqual({ ok: false, error: displayed })
expect(store.getState().deleteStateByWorktreeId[worktreeId]).toEqual({
isDeleting: false,
- error,
- canForceDelete: true,
- forceDeleteReason: 'dirty'
+ error: displayed,
+ ...classification
})
})
@@ -462,34 +477,6 @@ describe('removeWorktree cascade', () => {
})
})
- it('offers force delete when Git already removed an unregistered worktree', async () => {
- const store = createTestStore()
- const worktreeId = 'repo1::/workspace/deleted-wt'
- const error =
- "Error invoking remote method 'worktrees:remove': Error: Worktree is no longer registered with Git and its directory is already gone."
-
- mockApi.worktrees.remove.mockRejectedValueOnce(new Error(error))
-
- seedStore(store, {
- worktreesByRepo: {
- repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1' })]
- },
- tabsByWorktree: {},
- ptyIdsByTabId: {},
- terminalLayoutsByTabId: {}
- })
-
- const result = await store.getState().removeWorktree({ id: worktreeId, executionHostId: null })
-
- expect(result).toEqual({ ok: false, error })
- expect(store.getState().deleteStateByWorktreeId[worktreeId]).toEqual({
- isDeleting: false,
- error,
- canForceDelete: true,
- forceDeleteReason: 'missing-registration'
- })
- })
-
it('sets canForceDelete=false when force=true removal fails', async () => {
const store = createTestStore()
const worktreeId = 'repo1::/path/wt1'
@@ -579,34 +566,6 @@ describe('removeWorktree cascade', () => {
expect(store.getState().deleteStateByWorktreeId[worktreeId]?.canForceDelete).toBe(false)
})
- it('does not offer force delete when Electron wraps SSH filesystem provider failures', async () => {
- const store = createTestStore()
- const worktreeId = 'repo1::/path/wt1'
- const error =
- "Error invoking remote method 'worktrees:remove': Error: SSH filesystem provider unavailable"
-
- mockApi.worktrees.remove.mockRejectedValueOnce(new Error(error))
-
- seedStore(store, {
- worktreesByRepo: {
- repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1' })]
- },
- tabsByWorktree: {},
- ptyIdsByTabId: {},
- terminalLayoutsByTabId: {}
- })
-
- const result = await store.getState().removeWorktree({ id: worktreeId, executionHostId: null })
-
- expect(result).toEqual({ ok: false, error })
- expect(store.getState().deleteStateByWorktreeId[worktreeId]).toEqual({
- isDeleting: false,
- error,
- canForceDelete: false,
- forceDeleteReason: null
- })
- })
-
it.each([
'Could not connect to the remote Orca runtime.',
'Remote Orca runtime closed the connection.',
@@ -617,6 +576,8 @@ describe('removeWorktree cascade', () => {
const store = createTestStore()
const worktreeId = 'repo1::/path/wt1'
const error = `Error invoking remote method 'runtime-environments:call': Error: ${runtimeFailure}`
+ // The wrapper is stripped for display; the runtime failure text is what the user sees.
+ const displayed = runtimeFailure
mockApi.runtimeEnvironments.call.mockImplementation((args: { method: string }) => {
const compatibility = createCompatibleRuntimeStatusResponseIfNeeded(args)
@@ -648,10 +609,10 @@ describe('removeWorktree cascade', () => {
.getState()
.removeWorktree({ id: worktreeId, executionHostId: null })
- expect(result).toEqual({ ok: false, error })
+ expect(result).toEqual({ ok: false, error: displayed })
expect(store.getState().deleteStateByWorktreeId[worktreeId]).toEqual({
isDeleting: false,
- error,
+ error: displayed,
canForceDelete: false,
forceDeleteReason: null
})
diff --git a/src/renderer/src/store/slices/worktree-delete-state-types.ts b/src/renderer/src/store/slices/worktree-delete-state-types.ts
index 85178bc15dc..ecbb4a50f3e 100644
--- a/src/renderer/src/store/slices/worktree-delete-state-types.ts
+++ b/src/renderer/src/store/slices/worktree-delete-state-types.ts
@@ -10,6 +10,8 @@ export type WorktreeDeleteState = {
canForceDelete: boolean
forceDeleteReason: WorktreeForceDeleteReason | null
lockReason?: string | null
+ /** The removal was refused by a failed archive hook, so "Delete anyway" is offered (#19334). */
+ canWaiveArchiveHook?: boolean
}
export type WorktreeDeleteStateTarget = Pick
diff --git a/src/renderer/src/store/slices/worktree-removal-options.ts b/src/renderer/src/store/slices/worktree-removal-options.ts
index 04096db9a50..c77568248de 100644
--- a/src/renderer/src/store/slices/worktree-removal-options.ts
+++ b/src/renderer/src/store/slices/worktree-removal-options.ts
@@ -9,6 +9,9 @@ export type RemoveWorktreeOptions = {
// Why (#11960): only an explicit Force Delete waives the proof that every
// PTY stopped; `force` alone is set by the ordinary delete confirmation.
allowUnverifiedPtyStop?: boolean
+ // Why (#19334): waives a FAILED archive hook. Set only by the explicit "Delete anyway" retry
+ // after the user has seen the refusal -- never by the ordinary confirmation, never by `force`.
+ allowFailedArchiveHook?: boolean
snapshotPruneBatchId?: string
/** Fresh cleanup-scan evidence for a same-id owner not represented in the catalog. */
sameIdSurvivingHostId?: ExecutionHostId
diff --git a/src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts b/src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts
index 528c9a977d5..dc54b8c7763 100644
--- a/src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts
+++ b/src/renderer/src/store/slices/worktrees-remote-runtime-removal.test.ts
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { ARCHIVE_HOOK_TIMEOUT_MS } from '../../../../shared/worktree/archive-hook-removal-gate'
import type { AppState } from '../types'
import type { RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture'
import { makeWorktree } from './worktrees-slice-test-fixtures'
@@ -64,7 +65,8 @@ describe('worktree remote runtime mutations', () => {
allowUnverifiedPtyStop: false,
runHooks: true
},
- timeoutMs: 60_000,
+ // Hooks run here, so the client must outlast the host's archive-hook budget (#19334).
+ timeoutMs: ARCHIVE_HOOK_TIMEOUT_MS + 60_000,
expectedEnvironmentPairingRevision: undefined,
expectedRuntimeId: undefined
})
@@ -127,7 +129,8 @@ describe('worktree remote runtime mutations', () => {
allowUnverifiedPtyStop: false,
runHooks: true
},
- timeoutMs: 60_000,
+ // Hooks run here, so the client must outlast the host's archive-hook budget (#19334).
+ timeoutMs: ARCHIVE_HOOK_TIMEOUT_MS + 60_000,
expectedEnvironmentPairingRevision: undefined,
expectedRuntimeId: undefined
})
@@ -225,7 +228,8 @@ describe('worktree remote runtime mutations', () => {
allowUnverifiedPtyStop: false,
runHooks: true
},
- timeoutMs: 60_000
+ // Hooks run here, so the client must outlast the host's archive-hook budget (#19334).
+ timeoutMs: ARCHIVE_HOOK_TIMEOUT_MS + 60_000
})
expect(mockApi.worktrees.remove).not.toHaveBeenCalled()
expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([])
@@ -601,6 +605,8 @@ describe('worktree remote runtime mutations', () => {
force: undefined,
// Why (#11960): an ordinary remove never waives the PTY-stop proof.
allowUnverifiedPtyStop: false,
+ // Why (#19334): nor a failed archive hook — only the explicit "Delete anyway" retry does.
+ allowFailedArchiveHook: false,
skipArchive: false
})
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
diff --git a/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts b/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts
index 2ca0212b08f..3b2a11da6e4 100644
--- a/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts
+++ b/src/renderer/src/store/slices/worktrees/teardown/dispatch-worktree-removal.ts
@@ -3,6 +3,7 @@ import type { RemoveWorktreeResult } from '../../../../../../shared/worktree/cre
import { callRuntimeRpc, type getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client'
import { toRuntimeWorktreeSelector } from '../../../../runtime/runtime-worktree-selector'
import type { RemoveWorktreeOptions } from '../../worktree-removal-options'
+import { ARCHIVE_HOOK_TIMEOUT_MS } from '../../../../../../shared/worktree/archive-hook-removal-gate'
/**
* Sends the destructive removal over whichever transport owns this workspace.
@@ -36,6 +37,7 @@ export async function dispatchWorktreeRemoval(args: {
hostId,
force,
allowUnverifiedPtyStop: options?.allowUnverifiedPtyStop === true,
+ allowFailedArchiveHook: options?.allowFailedArchiveHook === true,
skipArchive,
...snapshotPruneBatch
})
@@ -50,9 +52,19 @@ export async function dispatchWorktreeRemoval(args: {
...(effectiveHostId ? { hostId: effectiveHostId } : {}),
force,
allowUnverifiedPtyStop: options?.allowUnverifiedPtyStop === true,
+ // Why only when set, unlike the IPC branch: this crosses a version boundary, and a host
+ // that predates the gate drops unknown params silently. Send it when it means something.
+ ...(options?.allowFailedArchiveHook === true ? { allowFailedArchiveHook: true } : {}),
runHooks: !skipArchive
},
- { timeoutMs: 60_000 }
+ {
+ // Why not a flat 60s (#19334): the host may run an archive hook for up to
+ // ARCHIVE_HOOK_TIMEOUT_MS before it decides anything. A client that gives up first reports a
+ // failure for a removal that is still in progress — and if the hook then succeeds, the host
+ // deletes the checkout while the user has been told the delete failed. Outlast the hook when
+ // one can run; keep the short budget when none will.
+ timeoutMs: skipArchive ? 60_000 : ARCHIVE_HOOK_TIMEOUT_MS + 60_000
+ }
)
}
diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts
index a6a9c87d838..56bd12581f0 100644
--- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts
+++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts
@@ -7,6 +7,8 @@ import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
import { getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client'
import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-control-huge-repo-warning-dismissals'
import { forgetWorktreeSleepIntent } from '@/lib/worktree-sleep-intent'
+import { readIpcErrorDetail } from '@/lib/ipc-error'
+import { isArchiveHookRemovalError } from '../../../../../../shared/worktree/archive-hook-removal-gate'
import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast'
import {
resolveWorktreeOperationRouteResult,
@@ -297,13 +299,18 @@ export function createRemoveWorktree(
} catch (err) {
// Why: git refusing a non-force delete for dirty/untracked files is a handled user decision, not an app error.
console.warn('Failed to remove worktree:', err)
- const error = err instanceof Error ? err.message : String(err)
+ // The raw message arrives wrapped in Electron's IPC channel and class names; this string is
+ // read by a user in a toast, and the refusal sentence has to lead it.
+ const error = readIpcErrorDetail(err) ?? (err instanceof Error ? err.message : String(err))
const forceDeleteReason = classifyWorktreeForceDeleteReason(
error,
force,
options?.allowUnverifiedPtyStop === true
)
const locked = isLockedWorktreeRemovalError(error)
+ // Why (#19334): the refusal is the only failure a retry can clear by waiving rather than by
+ // fixing state, so the toast needs to know it may offer that choice.
+ const canWaiveArchiveHook = isArchiveHookRemovalError(error)
set((s) => ({
deleteStateByWorktreeId: {
...s.deleteStateByWorktreeId,
@@ -313,6 +320,7 @@ export function createRemoveWorktree(
error,
canForceDelete: forceDeleteReason !== null,
forceDeleteReason,
+ ...(canWaiveArchiveHook ? { canWaiveArchiveHook: true } : {}),
...(locked ? { lockReason: getLockedWorktreeRemovalReason(error) } : {})
}
}
diff --git a/src/shared/cli-argument-boundary.ts b/src/shared/cli-argument-boundary.ts
index 088f6ff0e76..ad89c9a8978 100644
--- a/src/shared/cli-argument-boundary.ts
+++ b/src/shared/cli-argument-boundary.ts
@@ -3,6 +3,7 @@ export const CLI_GLOBAL_FLAGS: readonly string[] = ['help', 'json', ...CLI_GLOBA
export const CLI_BOOLEAN_FLAGS = new Set([
'all',
+ 'allow-failed-archive-hook',
'attachments',
'children',
'comments',
diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts
index 6afdf0b3b62..bd2ca84c95f 100644
--- a/src/shared/protocol-version.ts
+++ b/src/shared/protocol-version.ts
@@ -114,6 +114,17 @@ export const TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY = 'terminal.quick-comman
// status.worktreeCreateIdempotency carries the optional host retention policy.
export const WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY =
'worktree.create-idempotency.v1' as const
+// Scope of the claim: a hook that RUNS and fails cannot delete the checkout. It does not promise
+// the hook was found — an SSH host whose orca.yaml cannot be read answers "no hook" and the removal
+// proceeds, because a failed read is indistinguishable from an absent file across the relay
+// (#20196 tracks the provider contract that would separate them).
+// Why (#19334): "accepts --run-hooks" and "refuses to delete when the archive hook fails" were
+// indistinguishable from the outside — both take the flag and behave identically on success, so
+// the only way to tell an unfixed host apart was to fail a hook and see whether the checkout
+// survived. Lifecycle integrations keep teardown evidence inside the checkout and cannot risk
+// that. Advertised unconditionally: every build carrying this constant has the gate.
+export const WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY =
+ 'worktree.archive-failure-blocking.v1' as const
export const CODEX_RESET_CREDIT_RUNTIME_CAPABILITY = 'accounts.codex-reset-credit.v1' as const
export const ACCOUNT_IMPORT_RUNTIME_CAPABILITY = 'accounts.import-host-credentials.v1' as const
// Why: older hosts cannot reconcile terminal.create's mutation after losing the reply, so clients may only retry unknown outcomes when advertised.
@@ -283,6 +294,7 @@ export const RUNTIME_CAPABILITIES = [
TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY,
TERMINAL_QUICK_COMMANDS_RUNTIME_CAPABILITY,
WORKTREE_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
+ WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY,
TERMINAL_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY,
SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY,
SESSION_TABS_AUTHORITATIVE_INVENTORY_RUNTIME_CAPABILITY,
diff --git a/src/shared/rpc-contract/worktree-params.ts b/src/shared/rpc-contract/worktree-params.ts
index 00ed7627f9d..74b74dd799c 100644
--- a/src/shared/rpc-contract/worktree-params.ts
+++ b/src/shared/rpc-contract/worktree-params.ts
@@ -171,7 +171,10 @@ export const WorktreeRemove = WorktreeSelector.extend({
// desktop sets `force` for an ordinary confirmed delete too, so the PTY-stop
// waiver travels on its own field.
allowUnverifiedPtyStop: OptionalBoolean,
- runHooks: OptionalBoolean
+ runHooks: OptionalBoolean,
+ // Why (#19334): a failed archive hook blocks removal. This waives that refusal and is recorded
+ // in the result; it is NOT `force`, and it does not decide whether the hook runs.
+ allowFailedArchiveHook: OptionalBoolean
})
export const WorktreeForceDeleteBranch = WorktreeSelector.extend({
diff --git a/src/shared/worktree/archive-failure-blocking-capability.test.ts b/src/shared/worktree/archive-failure-blocking-capability.test.ts
new file mode 100644
index 00000000000..be6e010fc5e
--- /dev/null
+++ b/src/shared/worktree/archive-failure-blocking-capability.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest'
+import {
+ RUNTIME_CAPABILITIES,
+ WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY
+} from '../protocol-version'
+
+// Why (#19334): the reporter's integration (Harbour) keeps its teardown ownership evidence inside
+// the checkout, so it must know *before* removing anything whether this host refuses to delete on a
+// failed archive hook. It cannot probe for that — probing means risking the data loss.
+describe('worktree.archive-failure-blocking.v1', () => {
+ it('uses the id the reporting integration already codes against', () => {
+ expect(WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY).toBe(
+ 'worktree.archive-failure-blocking.v1'
+ )
+ })
+
+ it('is advertised by every build that carries the gate', () => {
+ expect(RUNTIME_CAPABILITIES).toContain(WORKTREE_ARCHIVE_FAILURE_BLOCKING_RUNTIME_CAPABILITY)
+ })
+})
diff --git a/src/shared/worktree/archive-hook-removal-gate.ts b/src/shared/worktree/archive-hook-removal-gate.ts
new file mode 100644
index 00000000000..9dcf270d28e
--- /dev/null
+++ b/src/shared/worktree/archive-hook-removal-gate.ts
@@ -0,0 +1,117 @@
+// Why (#19334): the archive hook is a user's last chance to save work off a checkout Orca is
+// about to delete. A failed hook used to be logged and stepped over, so the delete went ahead
+// with nothing archived. It is a precondition, evaluated before any stop/delete mutation.
+
+/**
+ * How long an archive hook gets before it is cut off. Shared because a client waiting on a removal
+ * has to outlast it: a client that gives up first reports a failure for a hook that is still
+ * running, and the host then completes the delete anyway — telling the user the opposite of what
+ * happened to their checkout (#19334).
+ */
+export const ARCHIVE_HOOK_TIMEOUT_MS = 120_000
+
+/** RPC/CLI error code for a removal refused because the repo's archive hook did not succeed. */
+export const ARCHIVE_HOOK_FAILED_REMOVAL_CODE = 'worktree_archive_hook_failed'
+
+export const ARCHIVE_HOOK_FAILED_REMOVAL_PREFIX = 'Archive hook failed for worktree:'
+
+// One string, three surfaces: the CLI, RPC callers, and the desktop toast that now carries its own
+// Delete Anyway button. Naming only the CLI flag sent desktop users to a terminal for a button that
+// was six inches away, so both affordances are named and neither is presented as the only one.
+export const ARCHIVE_HOOK_OVERRIDE_HINT =
+ 'Nothing was stopped, deleted or deregistered. Fix the hook and retry, or delete anyway with an explicit waiver — "Delete Anyway" in the app, or --allow-failed-archive-hook on the CLI.'
+
+/**
+ * `exited` means the host reported a non-zero exit for this hook run. `unverifiable` covers every
+ * case where the hook's outcome was never observed — spawn failure, timeout, lost contact with the
+ * execution host. Loss of contact is never evidence that the hook passed, so both block removal.
+ * Vocabulary is deliberately the `UnstoppedPtyVerdict` spelling; see docs/reference/ssh-execution-boundary.md.
+ */
+export type ArchiveHookOutcome = 'exited' | 'unverifiable'
+
+export type ArchiveHookFailure = {
+ worktreePath: string
+ outcome: ArchiveHookOutcome
+ /** Only ever set for `exited` — an absent code is not a zero code. */
+ exitCode?: number
+ output: string
+}
+
+/** What a caller sees when the failure was explicitly overridden instead of blocking. */
+export type ArchiveHookOverride = ArchiveHookFailure & { overridden: true }
+
+export class WorktreeArchiveHookFailedError extends Error {
+ readonly code = ARCHIVE_HOOK_FAILED_REMOVAL_CODE
+ readonly data: ArchiveHookFailure
+
+ constructor(failure: ArchiveHookFailure) {
+ super(formatArchiveHookFailure(failure))
+ this.name = 'WorktreeArchiveHookFailedError'
+ this.data = failure
+ }
+}
+
+function describeArchiveHookVerdict(failure: ArchiveHookFailure): string {
+ return failure.outcome === 'exited'
+ ? `exited ${failure.exitCode}`
+ : 'outcome unverifiable (the hook never reported an exit)'
+}
+
+export function formatArchiveHookFailure(failure: ArchiveHookFailure): string {
+ const output = failure.output.trim()
+ return [
+ `${ARCHIVE_HOOK_FAILED_REMOVAL_PREFIX} ${failure.worktreePath} — ${describeArchiveHookVerdict(failure)}.`,
+ ARCHIVE_HOOK_OVERRIDE_HINT,
+ ...(output ? [output] : [])
+ ].join(' ')
+}
+
+/**
+ * The waived case says the opposite of the refusal: the removal DID go ahead. Reusing
+ * `formatArchiveHookFailure` here printed "Nothing was stopped, deleted or deregistered" directly
+ * after deleting the checkout.
+ */
+export function formatArchiveHookOverride(override: ArchiveHookOverride): string {
+ const output = override.output.trim()
+ return [
+ `Archive hook failed for worktree: ${override.worktreePath} — ${describeArchiveHookVerdict(override)}.`,
+ 'Deleted anyway because the failure was explicitly waived; nothing was archived.',
+ ...(output ? [output] : [])
+ ].join(' ')
+}
+
+/**
+ * Narrow an unknown rejection to the typed refusal, or rethrow it. This is the branch a real
+ * caller writes, so tests asserting on a refusal should go through it rather than re-deriving it.
+ */
+export function asArchiveHookRefusal(error: unknown): WorktreeArchiveHookFailedError {
+ if (error instanceof WorktreeArchiveHookFailedError) {
+ return error
+ }
+ throw error
+}
+
+/** Recognise the refusal on a surface that only has the message, e.g. a renderer toast. */
+export function isArchiveHookRemovalError(error: string): boolean {
+ return error.includes(ARCHIVE_HOOK_FAILED_REMOVAL_PREFIX)
+}
+
+/** Shape both the local and the SSH archive runners answer with. */
+export type ArchiveHookRunResult = {
+ success: boolean
+ output: string
+ /** Omitted whenever no exit was observed, which classifies the failure as `unverifiable`. */
+ exitCode?: number
+}
+
+export function classifyArchiveHookFailure(
+ worktreePath: string,
+ result: ArchiveHookRunResult
+): ArchiveHookFailure {
+ return {
+ worktreePath,
+ outcome: typeof result.exitCode === 'number' ? 'exited' : 'unverifiable',
+ ...(typeof result.exitCode === 'number' ? { exitCode: result.exitCode } : {}),
+ output: result.output
+ }
+}
diff --git a/src/shared/worktree/create-types.ts b/src/shared/worktree/create-types.ts
index d8f3cfc81c5..7d41f1363be 100644
--- a/src/shared/worktree/create-types.ts
+++ b/src/shared/worktree/create-types.ts
@@ -1,4 +1,5 @@
import type { ExecutionHostId } from '../execution-host'
+import type { ArchiveHookOverride } from './archive-hook-removal-gate'
import type { WorkspaceSource } from '../workspace-source'
import type { TaskSourceContext } from '../task-source-context'
import type { WorkspaceKey } from '../folder-workspace-types'
@@ -207,6 +208,8 @@ export type PreservedWorktreeBranch = {
export type RemoveWorktreeResult = {
preservedBranch?: PreservedWorktreeBranch
+ /** Present only when a FAILED archive hook was explicitly waived for this removal (#19334). */
+ archiveHookOverride?: ArchiveHookOverride
}
export type ForceDeleteWorktreeBranchResult = {
From f107499e4423ff9d9a0bc203dad3ca56c41ce7f8 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Tue, 15 Sep 2026 01:24:30 -0700
Subject: [PATCH 27/34] fix(lint): enable anti-slop/no-reflect-get (#20786)
`anti-slop/no-reflect-get` rejects every call to `Reflect.get`. The
reflective read bypasses ordinary property access and throws away the
type evidence the compiler would otherwise give you: the result is
`any`/`unknown` with no narrowing, so a typo in the key or a shape drift
in the source object is invisible until runtime. The rule's remedy is to
parse dynamic input into a named domain type (or narrow it with `in`)
and then read the field normally.
Baseline: 86 violations across 67 files. Now zero unsuppressed
violations under
`npx oxlint --config config/oxlint-anti-slop.json --ignore-pattern 'config/oxlint-plugins/anti-slop/**' src config tests mobile`.
Fix pattern
-----------
44 of the 86 were rewritten. The dominant shape was an `unknown` value
read through `Reflect.get` right after a `typeof === 'object'` guard;
those became `in`-narrowed property access, which TypeScript checks:
- Reflect.get(value, 'agents')
+ 'agents' in value ? value.agents : null
Two further shapes:
- `Reflect.get(Object(x), 'k')` on a possibly-primitive envelope became a
small named reader that boxes once and indexes a
`Record` (`settingsField` in
mobile/src/transport/settings-read-operations.ts).
- Tests reaching into private state moved to TypeScript's checked
bracket-index escape hatch (`runtime['layoutQueues']`), or to a
documented read-only accessor on the owning class
(`SearchSubprocessLineAccumulator.retainedCapacityBytes()`,
`CodexSubagentExecutions.retentionSizes()`).
No type assertion was added anywhere: the diff contains zero net-new
`as` casts, `as any`, `as unknown as`, `@ts-ignore`, or
`@ts-expect-error`, so nothing was laundered into the sibling
assertion rules.
Suppressions
------------
42x `// oxlint-disable-next-line anti-slop/no-reflect-get` across 38
files. Every one is the default-forward branch of a `Proxy` `get` trap:
get(target, property, receiver) {
...
return Reflect.get(target, property, receiver)
}
`Reflect.get(target, property, receiver)` is the only construct that
forwards with correct `receiver` semantics; `target[property]` invokes
an accessor with the wrong `this` and silently breaks getters that read
sibling state. There is no typed alternative, so these are suppressed
rather than rewritten.
3x `// oxlint-disable-next-line typescript-eslint/consistent-type-definitions
-- declaration merging requires interface` in
tests/e2e/github-url-smart-input-transition.spec.ts,
tests/e2e/linear-url-workspace-entry.spec.ts, and
tests/e2e/worktree-active-delete-scroll-position.spec.ts. Replacing
`Reflect.get(window, 'x')` with typed `window.x` requires a
`declare global { interface Window }` block, and `interface` is
mandatory for declaration merging. Matches the existing convention at
tests/e2e/helpers/runtime-types.ts:63.
1x `// eslint-disable-next-line no-var -- main-process gate handle for
this spec` in tests/e2e/project-group-creation-visibility.spec.ts, for
the same reason a `var` global is needed to type the handle. Matches
tests/e2e/agent-session-log-tail-stability.spec.ts:24.
Also updates two source-text anchors in mobile's rpc-recording mutation
harness (mobile/src/test-support/rpc-recording/operation-mutations.ts
and recording-runner.test.ts), which pin the exact text of the rewritten
line in settings-read-operations.ts and would otherwise fail with
"Mutant anchor matched 0 sites, expected 1".
---
config/oxlint-anti-slop.json | 2 +-
.../mutants/operation-mutations.ts | 4 +-
.../rpc-recording/recording-runner.test.ts | 2 +-
.../src/transport/settings-read-operations.ts | 10 ++++-
.../managed-hook-detection-commands.ts | 8 ++--
...session-document-stream-boundaries.test.ts | 2 +-
src/main/codex/codex-prompt-registry.ts | 7 +---
.../codex/codex-subagent-executions.test.ts | 8 ++--
src/main/codex/codex-subagent-executions.ts | 5 +++
src/main/daemon/daemon-client-rpc-request.ts | 7 +++-
...ructured-agent-session-close-retry.test.ts | 1 +
...issing-worktree-terminal-reconciliation.ts | 1 +
.../mobile-subscribe-integration.test.ts | 40 ++++++++++++-------
.../terminal-listing.spec.ts | 3 +-
.../mailbox-pointer-stage.test.ts | 6 ++-
.../runtime/remote-desktop-driver.test.ts | 20 +++++-----
.../runtime/runtime-linear-command-surface.ts | 1 +
...erminal-orphan-topology-validation.test.ts | 1 +
.../structured-agent-session-runtime.test.ts | 4 +-
.../skill-bundle-install-service.test.ts | 1 +
.../skill-cloud-grant-installation.test.ts | 1 +
...pload-session-admission-regression.test.ts | 9 ++---
src/main/updater-test-harness.ts | 3 +-
src/main/workspace-space-repo-scan.test.ts | 1 +
src/main/worktree-name-retirement.ts | 7 +++-
src/relay/managed-hook-installer.ts | 4 +-
...handler-inventory-process-evidence.test.ts | 1 +
src/relay/pty-source-credit-ledger.test.ts | 1 +
...ard-snapshot-orchestration-routing.test.ts | 1 +
.../use-agent-row-conversation-name.test.ts | 1 +
.../components/editor/tiptap-marked-facade.ts | 1 +
...ssue-attribute-filter-primary-team.test.ts | 1 +
.../ProjectCombobox.dialog-handoff.test.tsx | 3 +-
.../active-checks-status.test.ts | 1 +
...rent-pr-checks-projection-selector.test.ts | 5 ++-
.../parent-pr-checks-projection-selector.ts | 3 +-
...worktree-agent-orchestration-batch.test.ts | 3 ++
...worktree-agent-orchestration-index.test.ts | 1 +
.../terminal-tab-activity-status.test.ts | 1 +
...-watcher-synchronization.react185.test.tsx | 1 +
...minal-provider-snapshot-capability.test.ts | 1 +
.../useIpcEvents-rate-limit-hydration.test.ts | 4 +-
.../hooks/useIpcEvents-updater-status.test.ts | 7 ++--
.../hooks/useIpcEvents-zoom-routing.test.ts | 8 ++--
.../src/lib/codex-pane-selection-lane.test.ts | 3 +-
.../pane-manager/terminal-ligatures-addon.ts | 1 +
...ession-write-subscriber-allocation.test.ts | 1 +
.../store/project-host-setup-selector.test.ts | 4 +-
src/renderer/src/store/selectors.test.ts | 1 +
.../slices/tab-group-reference-repair.test.ts | 1 +
...ted-workspace-reconciliation-batch.test.ts | 1 +
.../slices/terminal-tab-owner-index.test.ts | 1 +
.../slices/terminal-tab-title-batch.test.ts | 1 +
...ace-cleanup-enrichment-performance.test.ts | 1 +
.../src/web/preload-api/web-fallback-api.ts | 1 +
.../web/web-preload-api-composition.test.ts | 3 +-
.../web/web-preload-api-runtime-calls.test.ts | 2 +-
src/shared/automation-list-scope.test.ts | 1 +
.../host-balanced-listing-scaling.test.ts | 1 +
src/shared/pr-bot-author-overrides.test.ts | 1 +
src/shared/search-subprocess-lines.test.ts | 4 +-
src/shared/search-subprocess-lines.ts | 5 +++
.../host-terminal-runtime-stub.ts | 1 +
.../versioned-agent-session-wire.ts | 4 +-
.../github-url-smart-input-transition.spec.ts | 36 +++++++++++++----
tests/e2e/linear-url-workspace-entry.spec.ts | 14 +++++--
.../project-group-creation-visibility.spec.ts | 7 +++-
...tree-active-delete-scroll-position.spec.ts | 10 ++++-
68 files changed, 211 insertions(+), 96 deletions(-)
diff --git a/config/oxlint-anti-slop.json b/config/oxlint-anti-slop.json
index 9e5eda11ae0..bf41f269552 100644
--- a/config/oxlint-anti-slop.json
+++ b/config/oxlint-anti-slop.json
@@ -33,7 +33,7 @@
"anti-slop/no-object-parameters": "off",
"anti-slop/no-reduce-accumulator-copy": "error",
"anti-slop/no-reflect-apply": "error",
- "anti-slop/no-reflect-get": "off",
+ "anti-slop/no-reflect-get": "error",
"anti-slop/no-runtime-typeof": "off",
"anti-slop/no-shape-in-symbol-names": "off",
"anti-slop/no-unknown-parameters": "off",
diff --git a/mobile/src/test-support/rpc-recording/mutants/operation-mutations.ts b/mobile/src/test-support/rpc-recording/mutants/operation-mutations.ts
index 457bae3c2a8..6a4c488b296 100644
--- a/mobile/src/test-support/rpc-recording/mutants/operation-mutations.ts
+++ b/mobile/src/test-support/rpc-recording/mutants/operation-mutations.ts
@@ -36,8 +36,8 @@ export const OPERATION_MUTATIONS = {
// Reads the overrides one level above the settings envelope.
'bot-overrides-envelope': {
file: 'settings-read-operations.ts',
- before: "settings == null ? undefined : Reflect.get(Object(settings), 'prBotAuthorOverrides')",
- after: "raw == null ? undefined : Reflect.get(Object(raw), 'prBotAuthorOverrides')"
+ before: "settings == null ? undefined : settingsField(settings, 'prBotAuthorOverrides')",
+ after: "raw == null ? undefined : settingsField(raw, 'prBotAuthorOverrides')"
},
// Publishes the settings envelope instead of the accepted operation value.
'workspace-context-envelope': {
diff --git a/mobile/src/test-support/rpc-recording/recording-runner.test.ts b/mobile/src/test-support/rpc-recording/recording-runner.test.ts
index 0a92941e602..7b523be49ff 100644
--- a/mobile/src/test-support/rpc-recording/recording-runner.test.ts
+++ b/mobile/src/test-support/rpc-recording/recording-runner.test.ts
@@ -445,7 +445,7 @@ describe('recording boundaries', () => {
const root = mkdtempSync(join(tmpdir(), 'rpc-mutant-'))
try {
const anchor =
- "const overrides = settings == null ? undefined : Reflect.get(Object(settings), 'prBotAuthorOverrides')"
+ "const overrides = settings == null ? undefined : settingsField(settings, 'prBotAuthorOverrides')"
mkdirSync(join(root, 'mod'), { recursive: true })
writeFileSync(
join(root, 'mod/settings-read-operations.ts'),
diff --git a/mobile/src/transport/settings-read-operations.ts b/mobile/src/transport/settings-read-operations.ts
index 549b512c87c..d33e66458d2 100644
--- a/mobile/src/transport/settings-read-operations.ts
+++ b/mobile/src/transport/settings-read-operations.ts
@@ -7,6 +7,12 @@ function settingsMember(raw: unknown): unknown {
return boxed!.settings
}
+// Box primitives so a non-object settings value reads as absent instead of throwing.
+function settingsField(settings: unknown, key: string): unknown {
+ const boxed: Record = Object(settings)
+ return boxed[key]
+}
+
// Settings remain opaque: callers historically retain fields without validating their shapes.
const settingsReader: RpcCompatibleReader = (raw) => ({
compatible: true,
@@ -27,7 +33,7 @@ const optionalSettingsReader: RpcCompatibleReader = (raw) => {
const settings = raw == null ? undefined : settingsMember(raw)
const overrides: unknown =
- settings == null ? undefined : Reflect.get(Object(settings), 'prBotAuthorOverrides')
+ settings == null ? undefined : settingsField(settings, 'prBotAuthorOverrides')
return {
compatible: true,
variant: 'bot-logins',
@@ -85,7 +91,7 @@ export const newTabSettingsRead = bindDeferredRpcOperation(
const copyTrimsGutterReader: RpcCompatibleReader = (raw) => {
const settings = raw == null ? undefined : settingsMember(raw)
const trims: unknown =
- settings == null ? undefined : Reflect.get(Object(settings), 'terminalCopyTrimsGutter')
+ settings == null ? undefined : settingsField(settings, 'terminalCopyTrimsGutter')
return {
compatible: true,
variant: 'copy-trims-gutter',
diff --git a/src/main/agent-hooks/managed-hook-detection-commands.ts b/src/main/agent-hooks/managed-hook-detection-commands.ts
index b5183af7ec0..f9507160e27 100644
--- a/src/main/agent-hooks/managed-hook-detection-commands.ts
+++ b/src/main/agent-hooks/managed-hook-detection-commands.ts
@@ -53,10 +53,12 @@ export function readManagedHookDetectionResult(value: unknown): {
if (value === null || typeof value !== 'object') {
return { agents: [], claudeVersion: null }
}
- const agents = detectedManagedHookAgents(Reflect.get(value, 'agents'))
- const versions = Reflect.get(value, 'versions')
+ const agents = detectedManagedHookAgents('agents' in value ? value.agents : null)
+ const versions = 'versions' in value ? value.versions : null
const rawClaudeVersion =
- versions !== null && typeof versions === 'object' ? Reflect.get(versions, 'claude') : null
+ versions !== null && typeof versions === 'object' && 'claude' in versions
+ ? versions.claude
+ : null
return {
agents,
claudeVersion: parseClaudeCliVersion(
diff --git a/src/main/ai-vault/session-document-stream-boundaries.test.ts b/src/main/ai-vault/session-document-stream-boundaries.test.ts
index 67901707cbd..f6dfe349000 100644
--- a/src/main/ai-vault/session-document-stream-boundaries.test.ts
+++ b/src/main/ai-vault/session-document-stream-boundaries.test.ts
@@ -88,7 +88,7 @@ describe('independent JSON boundary review', () => {
expect(await parseHermesSessionDocument(file, bytes(content, 1), 'linux', options)).toEqual(
await parseHermesSessionContent(file, content, 'linux', options)
)
- expect(Reflect.get({}, 'polluted')).toBeUndefined()
+ expect('polluted' in {}).toBe(false)
})
for (const content of [
'{"messages":[],}',
diff --git a/src/main/codex/codex-prompt-registry.ts b/src/main/codex/codex-prompt-registry.ts
index c6d0d7f4bef..f3ba3fa3601 100644
--- a/src/main/codex/codex-prompt-registry.ts
+++ b/src/main/codex/codex-prompt-registry.ts
@@ -9,6 +9,7 @@ import {
readQuestionIds,
readQuestionOptionAnswers
} from './codex-prompt-registry-bounds'
+import { readRecord, readString as readRecordString } from './codex-item-field-readers'
export const CODEX_COMMAND_APPROVAL_METHOD = 'item/commandExecution/requestApproval'
export const CODEX_FILE_CHANGE_APPROVAL_METHOD = 'item/fileChange/requestApproval'
@@ -36,11 +37,7 @@ export type CodexPromptClaim = {
}
function readString(params: unknown, key: string): string | null {
- if (typeof params !== 'object' || params === null) {
- return null
- }
- const value = Reflect.get(params, key)
- return typeof value === 'string' && value.length > 0 ? value : null
+ return readRecordString(readRecord(params), key)
}
export function isCodexPromptMethod(method: string): boolean {
diff --git a/src/main/codex/codex-subagent-executions.test.ts b/src/main/codex/codex-subagent-executions.test.ts
index aceff7ab465..c7f7955bb23 100644
--- a/src/main/codex/codex-subagent-executions.test.ts
+++ b/src/main/codex/codex-subagent-executions.test.ts
@@ -13,8 +13,9 @@ describe('CodexSubagentExecutions retention and identity', () => {
executions.observeTurn(id, id, 'completed')
}
expect(executions.workingChildren().map((child) => child.agentThreadId)).toEqual(['long-lived'])
- expect(Reflect.get(executions, 'children').size).toBeLessThanOrEqual(128)
- expect(Reflect.get(executions, 'settledTurns').size).toBeLessThanOrEqual(256)
+ const { children, settledTurns } = executions.retentionSizes()
+ expect(children).toBeLessThanOrEqual(128)
+ expect(settledTurns).toBeLessThanOrEqual(256)
})
it('retains early live owner events at capacity and makes room only after settlement', () => {
@@ -45,7 +46,6 @@ describe('CodexSubagentExecutions retention and identity', () => {
executions.observeTurn('child', 'turn', 'failed')
expect(executions.workingChildren()[0]?.execution?.turnId).toBe('new-turn')
executions.clear()
- expect(Reflect.get(executions, 'children').size).toBe(0)
- expect(Reflect.get(executions, 'settledTurns').size).toBe(0)
+ expect(executions.retentionSizes()).toEqual({ children: 0, settledTurns: 0 })
})
})
diff --git a/src/main/codex/codex-subagent-executions.ts b/src/main/codex/codex-subagent-executions.ts
index cd33b4eb1d5..d5ac62bfa74 100644
--- a/src/main/codex/codex-subagent-executions.ts
+++ b/src/main/codex/codex-subagent-executions.ts
@@ -106,6 +106,11 @@ export class CodexSubagentExecutions {
this.settledTurns.clear()
}
+ /** Retention bounds are not observable through the child/turn API, so expose the two counts. */
+ retentionSizes(): { children: number; settledTurns: number } {
+ return { children: this.children.size, settledTurns: this.settledTurns.size }
+ }
+
private child(agentThreadId: string): CodexExecutionChild | undefined {
const existing = this.children.get(agentThreadId)
if (existing) {
diff --git a/src/main/daemon/daemon-client-rpc-request.ts b/src/main/daemon/daemon-client-rpc-request.ts
index fb72538eaa0..2bd4f6741de 100644
--- a/src/main/daemon/daemon-client-rpc-request.ts
+++ b/src/main/daemon/daemon-client-rpc-request.ts
@@ -59,8 +59,11 @@ export function requestDaemonRpc(opts: DaemonRpcRequestOptions): Promise {
const createTimeoutError = (): DaemonRequestTimeoutError =>
new DaemonRequestTimeoutError(`Request ${type} timed out after ${opts.timeoutMs}ms`)
const createSessionId =
- type === 'createOrAttach' && payload !== null && typeof payload === 'object'
- ? Reflect.get(payload, 'sessionId')
+ type === 'createOrAttach' &&
+ payload !== null &&
+ typeof payload === 'object' &&
+ 'sessionId' in payload
+ ? payload.sessionId
: null
const requestPayload =
type === 'createOrAttach' && payload !== null && typeof payload === 'object'
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts
index b79091c7f9f..1141f821174 100644
--- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts
+++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts
@@ -91,6 +91,7 @@ function flakyClose(journal: AgentSessionJournal, failures: number): AgentSessio
return new Proxy(journal, {
get(target, property, receiver) {
if (property !== 'close') {
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver.
return Reflect.get(target, property, receiver)
}
return async () => {
diff --git a/src/main/runtime/missing-worktree-terminal-reconciliation.ts b/src/main/runtime/missing-worktree-terminal-reconciliation.ts
index 11f888a5404..c8d5e06900c 100644
--- a/src/main/runtime/missing-worktree-terminal-reconciliation.ts
+++ b/src/main/runtime/missing-worktree-terminal-reconciliation.ts
@@ -24,6 +24,7 @@ function withSharedProcessSnapshot(provider: IPtyProvider): IPtyProvider {
// receiver, a provider whose own method called `this.listProcesses()`
// would silently read this sweep's cached snapshot instead of the live
// host — the batching must not leak past the calls it was built for.
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
const member: unknown = Reflect.get(target, property)
return typeof member === 'function' ? member.bind(target) : member
}
diff --git a/src/main/runtime/mobile-subscribe-integration.test.ts b/src/main/runtime/mobile-subscribe-integration.test.ts
index 61e064681c1..b3be748d5f0 100644
--- a/src/main/runtime/mobile-subscribe-integration.test.ts
+++ b/src/main/runtime/mobile-subscribe-integration.test.ts
@@ -87,8 +87,24 @@ const store = {
}
}
+/** Reclaim clears protected retention maps that no public reader exposes. */
+class ObservableRuntime extends OrcaRuntimeService {
+ get restoreTimers(): typeof this.pendingRestoreTimers {
+ return this.pendingRestoreTimers
+ }
+ get softLeavers(): typeof this.pendingSoftLeavers {
+ return this.pendingSoftLeavers
+ }
+ get fitOverrides(): typeof this.terminalFitOverrides {
+ return this.terminalFitOverrides
+ }
+ get drivers(): typeof this.terminalDrivers {
+ return this.terminalDrivers
+ }
+}
+
function createRuntime() {
- const runtime = new OrcaRuntimeService(store)
+ const runtime = new ObservableRuntime(store)
const ptySizes = new Map()
ptySizes.set('pty-1', { cols: 150, rows: 40 })
ptySizes.set('pty-2', { cols: 120, rows: 35 })
@@ -865,9 +881,9 @@ describe('mobile subscribe integration', () => {
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
await runtime.handleMobileSubscribe('pty-1', 'client-b', { cols: 40, rows: 18 })
- const pendingRestore = Reflect.get(runtime, 'pendingRestoreTimers') as Map
+ const pendingRestore = runtime.restoreTimers
pendingRestore.set('pty-1', { timer: setTimeout(() => {}, 60_000), clientId: 'client-b' })
- const pendingSoft = Reflect.get(runtime, 'pendingSoftLeavers') as Map
+ const pendingSoft = runtime.softLeavers
expect(pendingSoft.has('pty-1')).toBe(true)
await runtime.reclaimTerminalForDesktop('pty-1')
expect(pendingRestore.has('pty-1')).toBe(false)
@@ -878,10 +894,10 @@ describe('mobile subscribe integration', () => {
const { runtime } = createRuntime()
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
- ;(Reflect.get(runtime, 'terminalFitOverrides') as Map).delete('pty-1')
+ runtime.fitOverrides.delete('pty-1')
- const pendingRestore = Reflect.get(runtime, 'pendingRestoreTimers') as Map
- const pendingSoft = Reflect.get(runtime, 'pendingSoftLeavers') as Map
+ const pendingRestore = runtime.restoreTimers
+ const pendingSoft = runtime.softLeavers
await runtime.reclaimTerminalForDesktop('pty-1')
expect(pendingRestore.has('pty-1')).toBe(false)
expect(pendingSoft.has('pty-1')).toBe(false)
@@ -891,15 +907,11 @@ describe('mobile subscribe integration', () => {
const { runtime } = createRuntime()
await runtime.handleMobileSubscribe('pty-1', 'client-a', { cols: 45, rows: 20 })
runtime.handleMobileUnsubscribe('pty-1', 'client-a')
- ;(Reflect.get(runtime, 'terminalFitOverrides') as Map).delete('pty-1')
- ;(
- Reflect.get(runtime, 'terminalDrivers') as {
- set: (ptyId: string, driver: { kind: 'idle' }) => void
- }
- ).set('pty-1', { kind: 'idle' })
+ runtime.fitOverrides.delete('pty-1')
+ runtime.drivers.set('pty-1', { kind: 'idle' })
- const pendingRestore = Reflect.get(runtime, 'pendingRestoreTimers') as Map
- const pendingSoft = Reflect.get(runtime, 'pendingSoftLeavers') as Map
+ const pendingRestore = runtime.restoreTimers
+ const pendingSoft = runtime.softLeavers
expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(false)
expect(pendingRestore.has('pty-1')).toBe(false)
expect(pendingSoft.has('pty-1')).toBe(false)
diff --git a/src/main/runtime/orca-runtime-tests/terminal-listing.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-listing.spec.ts
index 9d87b49f904..fae08b482fb 100644
--- a/src/main/runtime/orca-runtime-tests/terminal-listing.spec.ts
+++ b/src/main/runtime/orca-runtime-tests/terminal-listing.spec.ts
@@ -436,10 +436,11 @@ describe('OrcaRuntimeService', () => {
throw new Error('onPtyData should use the PTY leaf index')
}
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
const value = Reflect.get(target, prop, target)
return typeof value === 'function' ? value.bind(target) : value
}
- }) as Map
+ })
runtime.onPtyData(`pty-${targetIndex}`, 'hello indexed\n', 123)
diff --git a/src/main/runtime/orchestration/mailbox-pointer-stage.test.ts b/src/main/runtime/orchestration/mailbox-pointer-stage.test.ts
index ed02d7e71ec..4f26d57555b 100644
--- a/src/main/runtime/orchestration/mailbox-pointer-stage.test.ts
+++ b/src/main/runtime/orchestration/mailbox-pointer-stage.test.ts
@@ -99,10 +99,11 @@ describe('mailbox pointer staging watermark', () => {
throw new Error('SQLITE_BUSY')
}
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver.
const value = Reflect.get(target, prop, receiver)
return typeof value === 'function' ? value.bind(target) : value
}
- }) as OrchestrationDb
+ })
const state = new OrchestrationMailboxPointerState()
const args = stageArgs(db, state)
@@ -178,10 +179,11 @@ describe('mailbox pointer staging watermark', () => {
stealNextClaim = false
return () => false
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver.
const value = Reflect.get(target, prop, receiver)
return typeof value === 'function' ? value.bind(target) : value
}
- }) as OrchestrationDb
+ })
const writePty = vi.fn(() => WRITE_ACCEPTED)
const delivery = new OrchestrationMailboxPointerDelivery({
diff --git a/src/main/runtime/remote-desktop-driver.test.ts b/src/main/runtime/remote-desktop-driver.test.ts
index bf2370ee924..bb25c20a94a 100644
--- a/src/main/runtime/remote-desktop-driver.test.ts
+++ b/src/main/runtime/remote-desktop-driver.test.ts
@@ -340,17 +340,18 @@ describe('remote desktop viewer width driver', () => {
const { runtime } = createRuntime()
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 100, 30)
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-B', 'viewer-B', 80, 24, false)
- const layoutQueues = Reflect.get(runtime, 'layoutQueues') as Map<
- string,
- { running: Promise; pending: { target: { ownerSubscriptionKey?: string } }[] }
- >
- layoutQueues.set('pty-1', { running: new Promise(() => {}), pending: [] })
+ const layoutQueues = runtime['layoutQueues']
+ layoutQueues.set('pty-1', { running: new Promise(() => {}), pending: [] })
void runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 90, 28)
void runtime.claimRemoteDesktopViewer('pty-1', 'sub-B')
expect(
- layoutQueues.get('pty-1')?.pending.map(({ target }) => target.ownerSubscriptionKey)
+ layoutQueues
+ .get('pty-1')
+ ?.pending.map(({ target }) =>
+ 'ownerSubscriptionKey' in target ? target.ownerSubscriptionKey : undefined
+ )
).toEqual(['sub-A', 'sub-B'])
layoutQueues.delete('pty-1')
})
@@ -358,11 +359,8 @@ describe('remote desktop viewer width driver', () => {
it('makes a host claim join a pending disconnect reclaim', async () => {
const { runtime } = createRuntime()
await runtime.updateRemoteDesktopViewer('pty-1', 'sub-A', 'viewer-A', 80, 24)
- const layoutQueues = Reflect.get(runtime, 'layoutQueues') as Map<
- string,
- { running: Promise; pending: { waiters: unknown[] }[] }
- >
- layoutQueues.set('pty-1', { running: new Promise(() => {}), pending: [] })
+ const layoutQueues = runtime['layoutQueues']
+ layoutQueues.set('pty-1', { running: new Promise(() => {}), pending: [] })
void runtime.unregisterRemoteDesktopViewer('pty-1', 'sub-A')
void runtime.claimRemoteDesktopHost('pty-1', 150, 40)
diff --git a/src/main/runtime/runtime-linear-command-surface.ts b/src/main/runtime/runtime-linear-command-surface.ts
index 0f234768203..cf9ad9e69df 100644
--- a/src/main/runtime/runtime-linear-command-surface.ts
+++ b/src/main/runtime/runtime-linear-command-surface.ts
@@ -47,6 +47,7 @@ function overrideAwareReceiver(
return override.bind(facade)
}
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: raw string|symbol pass-through; the receiver stays the target on purpose.
return Reflect.get(target, property, proxyReceiver)
}
})
diff --git a/src/main/runtime/runtime-terminal-orphan-topology-validation.test.ts b/src/main/runtime/runtime-terminal-orphan-topology-validation.test.ts
index cb8f22e864b..9fff4da0edf 100644
--- a/src/main/runtime/runtime-terminal-orphan-topology-validation.test.ts
+++ b/src/main/runtime/runtime-terminal-orphan-topology-validation.test.ts
@@ -35,6 +35,7 @@ it('validates large restored MRU lists with linear tab-order reads', () => {
if (typeof key === 'string' && /^\d+$/.test(key)) {
reads += 1
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
return Reflect.get(target, key, receiver)
}
})
diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts
index 2ce51b1c29b..29ee4740414 100644
--- a/src/main/runtime/structured-agent-session-runtime.test.ts
+++ b/src/main/runtime/structured-agent-session-runtime.test.ts
@@ -5,7 +5,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types'
import { agentSessionJournalCloseRetries } from '../native-chat/agent-session-journal/journal-close-retry'
import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open'
-import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store'
import type {
AgentSessionClaimStatus,
AgentSessionExecutionLocation,
@@ -346,6 +345,7 @@ describe('a teardown that fails is retried by the next stop', () => {
const flaky = new Proxy(real, {
get(target, property, receiver) {
if (property !== 'close') {
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver.
return Reflect.get(target, property, receiver)
}
return async () => {
@@ -356,7 +356,7 @@ describe('a teardown that fails is retried by the next stop', () => {
await target.close()
}
}
- }) as AgentSessionJournal
+ })
await agentSessionJournalCloseRetries.closeOrRetain(flaky)
// The host's teardown runs the registry retry, so this stop surfaces it.
diff --git a/src/main/skills/skill-bundle-install-service.test.ts b/src/main/skills/skill-bundle-install-service.test.ts
index 78c8eae0652..f5de4faf7eb 100644
--- a/src/main/skills/skill-bundle-install-service.test.ts
+++ b/src/main/skills/skill-bundle-install-service.test.ts
@@ -104,6 +104,7 @@ describe('skill bundle installation', () => {
}
}
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
const value = Reflect.get(target, property, target) as unknown
return typeof value === 'function' ? value.bind(target) : value
}
diff --git a/src/main/skills/skill-cloud-grant-installation.test.ts b/src/main/skills/skill-cloud-grant-installation.test.ts
index 5355e6f4869..8534ce7a176 100644
--- a/src/main/skills/skill-cloud-grant-installation.test.ts
+++ b/src/main/skills/skill-cloud-grant-installation.test.ts
@@ -195,6 +195,7 @@ it.each(['skill-install-cancelled', 'skill-install-filesystem-failed'])(
if (typeof key === 'string' && /^\d+$/.test(key)) {
reads += 1
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver.
return Reflect.get(target, key, receiver)
}
})
diff --git a/src/main/skills/skill-upload-session-admission-regression.test.ts b/src/main/skills/skill-upload-session-admission-regression.test.ts
index f0c7cd54405..9bbdb71ef68 100644
--- a/src/main/skills/skill-upload-session-admission-regression.test.ts
+++ b/src/main/skills/skill-upload-session-admission-regression.test.ts
@@ -4,6 +4,7 @@ import type * as NodeFsPromises from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { SkillUploadRetainedPaths } from './skill-upload-retained-paths'
import { SkillUploadSessionService } from './skill-upload-session-service'
const roots: string[] = []
@@ -30,10 +31,6 @@ vi.mock('node:fs/promises', async (importOriginal) => {
}
})
-type RetainedPathCleanup = {
- removeFailedCleanup(path: string): Promise
-}
-
afterEach(async () => {
vi.useRealTimers()
openGate.release = null
@@ -51,8 +48,8 @@ function identity(bytes: Buffer) {
}
}
-function retainedPathCleanup(service: SkillUploadSessionService): RetainedPathCleanup {
- return Reflect.get(service, 'retainedPaths') as RetainedPathCleanup
+function retainedPathCleanup(service: SkillUploadSessionService): SkillUploadRetainedPaths {
+ return service['retainedPaths']
}
async function stagedArchiveCount(uploads: string): Promise {
diff --git a/src/main/updater-test-harness.ts b/src/main/updater-test-harness.ts
index 36687d0a79e..83379b7ac9b 100644
--- a/src/main/updater-test-harness.ts
+++ b/src/main/updater-test-harness.ts
@@ -146,6 +146,7 @@ export function createUpdaterMocks(): UpdaterMocks {
const loadedGeneration = currentGeneration
return new Proxy(autoUpdaterMock, {
get(target, property) {
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: raw string|symbol pass-through; the receiver stays the target on purpose.
const value = Reflect.get(target, property)
if (loadedGeneration === currentGeneration || typeof value !== 'function') {
return value
@@ -155,7 +156,7 @@ export function createUpdaterMocks(): UpdaterMocks {
set(target, property, value) {
return loadedGeneration === currentGeneration ? Reflect.set(target, property, value) : true
}
- }) as AutoUpdaterMock
+ })
}
const reset = () => {
diff --git a/src/main/workspace-space-repo-scan.test.ts b/src/main/workspace-space-repo-scan.test.ts
index fbf570f12c1..5447ca93550 100644
--- a/src/main/workspace-space-repo-scan.test.ts
+++ b/src/main/workspace-space-repo-scan.test.ts
@@ -15,6 +15,7 @@ describe('summarizeWorkspaceSpaceRows', () => {
) {
reads[property] += 1
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
return Reflect.get(target, property, receiver)
}
})
diff --git a/src/main/worktree-name-retirement.ts b/src/main/worktree-name-retirement.ts
index 58ffc99bbfd..99a6fdd559c 100644
--- a/src/main/worktree-name-retirement.ts
+++ b/src/main/worktree-name-retirement.ts
@@ -75,7 +75,12 @@ export function normalizeRetirableGeneratedName(name: string): string | null {
/** A sparse create error carries this marker only when its rollback also failed, leaving the path
* occupied even though creation rejected. */
export function failedWorktreeCreationNeedsRetirement(error: unknown): boolean {
- return typeof error === 'object' && error !== null && Reflect.get(error, 'cleanupFailed') === true
+ return (
+ typeof error === 'object' &&
+ error !== null &&
+ 'cleanupFailed' in error &&
+ error.cleanupFailed === true
+ )
}
async function getRetirementProbePath(
diff --git a/src/relay/managed-hook-installer.ts b/src/relay/managed-hook-installer.ts
index bdd65a789f6..3fa57dd5ed8 100644
--- a/src/relay/managed-hook-installer.ts
+++ b/src/relay/managed-hook-installer.ts
@@ -45,7 +45,9 @@ function readAgents(params: unknown): AgentHookTarget[] {
function readClaudeVersion(params: unknown): string | undefined {
const raw =
- params !== null && typeof params === 'object' ? Reflect.get(params, 'claudeVersion') : null
+ params !== null && typeof params === 'object' && 'claudeVersion' in params
+ ? params.claudeVersion
+ : null
return parseClaudeCliVersion(typeof raw === 'string' ? raw : null) ?? undefined
}
diff --git a/src/relay/pty-handler-inventory-process-evidence.test.ts b/src/relay/pty-handler-inventory-process-evidence.test.ts
index c12e6da62b4..f0d5b068304 100644
--- a/src/relay/pty-handler-inventory-process-evidence.test.ts
+++ b/src/relay/pty-handler-inventory-process-evidence.test.ts
@@ -100,6 +100,7 @@ function countingRows(rows: ProcessTableRow[]): {
if (typeof key === 'string' && /^\d+$/.test(key)) {
reads += 1
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver.
return Reflect.get(target, key, receiver)
}
})
diff --git a/src/relay/pty-source-credit-ledger.test.ts b/src/relay/pty-source-credit-ledger.test.ts
index f4ff366c946..57c00d1adec 100644
--- a/src/relay/pty-source-credit-ledger.test.ts
+++ b/src/relay/pty-source-credit-ledger.test.ts
@@ -104,6 +104,7 @@ describe('RelayPtySourceCreditLedger', () => {
if (typeof property === 'string' && /^\d+$/.test(property)) {
indexedReads += 1
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
return Reflect.get(target, property, receiver)
}
})
diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot-orchestration-routing.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot-orchestration-routing.test.ts
index 424c936ab0f..97697f5f8a4 100644
--- a/src/renderer/src/components/dashboard/build-dashboard-snapshot-orchestration-routing.test.ts
+++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot-orchestration-routing.test.ts
@@ -109,6 +109,7 @@ describe('buildDashboardSnapshot orchestration routing', () => {
if (typeof key === 'string' && Object.hasOwn(target, key)) {
runtimeValueReads += 1
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver.
return Reflect.get(target, key, receiver)
}
})
diff --git a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts
index dbf5cae455b..9065cc64e34 100644
--- a/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts
+++ b/src/renderer/src/components/dashboard/use-agent-row-conversation-name.test.ts
@@ -113,6 +113,7 @@ describe('useAgentRowConversationName', () => {
if (typeof property === 'string' && /^\d+$/.test(property)) {
tabReads += 1
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
return Reflect.get(target, property, receiver)
}
}
diff --git a/src/renderer/src/components/editor/tiptap-marked-facade.ts b/src/renderer/src/components/editor/tiptap-marked-facade.ts
index 823217be6ef..80cadb343f9 100644
--- a/src/renderer/src/components/editor/tiptap-marked-facade.ts
+++ b/src/renderer/src/components/editor/tiptap-marked-facade.ts
@@ -74,6 +74,7 @@ export function createTiptapMarkedFacade(): typeof marked {
return facade
}
default:
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver.
return Reflect.get(target, property, receiver)
}
}
diff --git a/src/renderer/src/components/linear-issue-attribute-filter-primary-team.test.ts b/src/renderer/src/components/linear-issue-attribute-filter-primary-team.test.ts
index 5a4bfe471a2..9adb06f6586 100644
--- a/src/renderer/src/components/linear-issue-attribute-filter-primary-team.test.ts
+++ b/src/renderer/src/components/linear-issue-attribute-filter-primary-team.test.ts
@@ -55,6 +55,7 @@ it('selects a primary team without pairwise membership checks or sorting all tea
if (typeof key === 'string' && /^\d+$/.test(key)) {
reads += 1
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
return Reflect.get(target, key, receiver)
}
}
diff --git a/src/renderer/src/components/new-workspace/ProjectCombobox.dialog-handoff.test.tsx b/src/renderer/src/components/new-workspace/ProjectCombobox.dialog-handoff.test.tsx
index 339fd60840f..f77c1a34928 100644
--- a/src/renderer/src/components/new-workspace/ProjectCombobox.dialog-handoff.test.tsx
+++ b/src/renderer/src/components/new-workspace/ProjectCombobox.dialog-handoff.test.tsx
@@ -68,7 +68,8 @@ beforeEach(() => {
? element.getAttribute('data-state') === 'closed'
? 'exit'
: 'enter'
- : Reflect.get(target, property)
+ : // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: raw string|symbol pass-through; the receiver stays the target on purpose.
+ Reflect.get(target, property)
})
}
return style
diff --git a/src/renderer/src/components/right-sidebar/active-checks-status.test.ts b/src/renderer/src/components/right-sidebar/active-checks-status.test.ts
index 5bf09276c72..84b65d83f5b 100644
--- a/src/renderer/src/components/right-sidebar/active-checks-status.test.ts
+++ b/src/renderer/src/components/right-sidebar/active-checks-status.test.ts
@@ -182,6 +182,7 @@ describe('getActiveChecksStatus caching', () => {
{
get(target, prop, receiver) {
reads.add(prop)
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
return Reflect.get(target, prop, receiver)
},
has(target, prop) {
diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts
index 0616bb510e9..7d7e1b1e4ad 100644
--- a/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts
+++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.test.ts
@@ -61,9 +61,10 @@ describe('parent PR checks projection selector', () => {
const observedCache = new Proxy(
{},
{
- get: (target, property, receiver) => {
+ get: (target, property) => {
cacheRead(property)
- return Reflect.get(target, property, receiver)
+ const entries: Record = target
+ return entries[property]
}
}
)
diff --git a/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.ts b/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.ts
index 02677575e48..42ad55de0a8 100644
--- a/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.ts
+++ b/src/renderer/src/components/right-sidebar/parent-pr-checks-projection-selector.ts
@@ -25,6 +25,7 @@ function trackCacheReads(
): ReviewCacheState[K] {
return new Proxy(state[cacheName], {
get: (target, property, receiver) => {
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
const value = Reflect.get(target, property, receiver)
if (typeof property === 'string') {
dependencies.push({ cacheName, key: property, value })
@@ -41,7 +42,7 @@ function dependenciesAreCurrent(
): boolean {
return dependencies.every(
({ cacheName, key, value }) =>
- state[cacheName] === previousState[cacheName] || Reflect.get(state[cacheName], key) === value
+ state[cacheName] === previousState[cacheName] || state[cacheName][key] === value
)
}
diff --git a/src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.test.ts b/src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.test.ts
index 705a1e0c43e..3d5a5e14c8c 100644
--- a/src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-agent-orchestration-batch.test.ts
@@ -364,6 +364,7 @@ describe('selectRuntimeAgentOrchestrationBatch', () => {
if (typeof key === 'string' && Object.hasOwn(target, key)) {
runtimeValueReads += 1
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver.
return Reflect.get(target, key, receiver)
}
})
@@ -456,6 +457,7 @@ describe('selectRuntimeAgentOrchestrationBatch', () => {
if (typeof key === 'string' && Object.hasOwn(target, key)) {
runtimeValueReads += 1
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver.
return Reflect.get(target, key, receiver)
}
})
@@ -636,6 +638,7 @@ describe('selectRuntimeAgentOrchestrationBatch live-map churn', () => {
if (typeof key === 'string') {
reads.push(key)
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver.
return Reflect.get(source, key, receiver)
}
})
diff --git a/src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts b/src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts
index 2d9ba917520..d97a7d41906 100644
--- a/src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts
+++ b/src/renderer/src/components/sidebar/worktree-agent-orchestration-index.test.ts
@@ -312,6 +312,7 @@ describe('selectWorktreeAgentOrchestration', () => {
if (typeof key === 'string') {
onRead()
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
return Reflect.get(source, key, receiver)
}
})
diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts
index 8511fff1913..fc626b99132 100644
--- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts
+++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts
@@ -356,6 +356,7 @@ describe('hasUnreadAgentCompletionForTerminalTab', () => {
ownKeys,
get: (target, property, receiver) => {
valueReads += 1
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver.
return Reflect.get(target, property, receiver)
}
})
diff --git a/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx b/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx
index 1e385354c21..bb2f44320cd 100644
--- a/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx
+++ b/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx
@@ -218,6 +218,7 @@ describe('parked terminal watcher synchronization', () => {
if (typeof property === 'string') {
harness.reconciliationPtyReads += 1
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
return Reflect.get(target, property, receiver)
}
})
diff --git a/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts b/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts
index 8825f0489c5..e1985aa2555 100644
--- a/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts
+++ b/src/renderer/src/components/terminal/terminal-provider-snapshot-capability.test.ts
@@ -79,6 +79,7 @@ describe('terminal provider snapshot capabilities', () => {
if (typeof property === 'string' && /^\d+$/.test(property)) {
indexedReads += 1
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver.
return Reflect.get(target, property, receiver)
}
})
diff --git a/src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts b/src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts
index a95d1095d5e..f1116ad3001 100644
--- a/src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts
+++ b/src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts
@@ -118,8 +118,8 @@ describe('useIpcEvents rate-limit hydration', () => {
const makeEvents = (target: Record = {}): Record =>
new Proxy(target, {
get: (namespace, prop) => {
- if (prop in namespace) {
- return Reflect.get(namespace, prop)
+ if (typeof prop === 'string' && prop in namespace) {
+ return namespace[prop]
}
return () => () => {}
}
diff --git a/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts b/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts
index 1ff5554f59c..002e071a19d 100644
--- a/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts
+++ b/src/renderer/src/hooks/useIpcEvents-updater-status.test.ts
@@ -281,10 +281,11 @@ describe('useIpcEvents updater integration', () => {
}))
vi.doMock('@/lib/zoom-events', () => ({ dispatchZoomLevelChanged: vi.fn() }))
- const makeEvents = (target: Record = {}): Record =>
+ const makeEvents = (
+ target: Record = {}
+ ): Record =>
new Proxy(target, {
- get: (namespace, prop) =>
- prop in namespace ? Reflect.get(namespace, prop) : () => () => {}
+ get: (namespace, prop) => (prop in namespace ? namespace[prop] : () => () => {})
})
vi.stubGlobal('window', {
diff --git a/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts b/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts
index dc954e451a6..5e20aa0994b 100644
--- a/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts
+++ b/src/renderer/src/hooks/useIpcEvents-zoom-routing.test.ts
@@ -180,8 +180,8 @@ describe('useIpcEvents zoom routing', () => {
const makeEvents = (target: Record = {}): Record =>
new Proxy(target, {
get: (namespace, prop) => {
- if (prop in namespace) {
- return Reflect.get(namespace, prop)
+ if (typeof prop === 'string' && prop in namespace) {
+ return namespace[prop]
}
return () => () => {}
}
@@ -326,8 +326,8 @@ describe('useIpcEvents zoom routing', () => {
const makeEvents = (target: Record = {}): Record =>
new Proxy(target, {
get: (namespace, prop) => {
- if (prop in namespace) {
- return Reflect.get(namespace, prop)
+ if (typeof prop === 'string' && prop in namespace) {
+ return namespace[prop]
}
return () => () => {}
}
diff --git a/src/renderer/src/lib/codex-pane-selection-lane.test.ts b/src/renderer/src/lib/codex-pane-selection-lane.test.ts
index a4b1dcefc56..c6729d291ae 100644
--- a/src/renderer/src/lib/codex-pane-selection-lane.test.ts
+++ b/src/renderer/src/lib/codex-pane-selection-lane.test.ts
@@ -367,9 +367,10 @@ describe('resolveCodexPaneSelectionLane', () => {
if (property === 'worktreesByRepo') {
throw new Error('state read blew up')
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: raw string|symbol pass-through; the receiver stays the target on purpose.
return Reflect.get(target, property)
}
- }) as LaneState
+ })
// Why: this call sits outside the scan's per-pane failure guard, so a throw
// would lose the notice for every pane in the batch, not just this one.
expect(
diff --git a/src/renderer/src/lib/pane-manager/terminal-ligatures-addon.ts b/src/renderer/src/lib/pane-manager/terminal-ligatures-addon.ts
index 256f38befdc..774eb7e8fb7 100644
--- a/src/renderer/src/lib/pane-manager/terminal-ligatures-addon.ts
+++ b/src/renderer/src/lib/pane-manager/terminal-ligatures-addon.ts
@@ -111,6 +111,7 @@ export class TerminalLigaturesAddon extends LigaturesAddon {
target.refresh(start, end)
}
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
const value = Reflect.get(target, property, target) as unknown
return typeof value === 'function' ? value.bind(target) : value
}
diff --git a/src/renderer/src/lib/session-write-subscriber-allocation.test.ts b/src/renderer/src/lib/session-write-subscriber-allocation.test.ts
index 74c9c6db968..3f2c5ce284c 100644
--- a/src/renderer/src/lib/session-write-subscriber-allocation.test.ts
+++ b/src/renderer/src/lib/session-write-subscriber-allocation.test.ts
@@ -210,6 +210,7 @@ describe('session write subscriber allocation', () => {
if (typeof property === 'string') {
read.add(property)
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver.
return Reflect.get(target, property, receiver)
}
})
diff --git a/src/renderer/src/store/project-host-setup-selector.test.ts b/src/renderer/src/store/project-host-setup-selector.test.ts
index 610e44c1feb..c6de8f65820 100644
--- a/src/renderer/src/store/project-host-setup-selector.test.ts
+++ b/src/renderer/src/store/project-host-setup-selector.test.ts
@@ -20,8 +20,7 @@ function countCollectionReads(items: readonly T[]): {
get(array, property) {
if (property === 'map' || property === 'flatMap') {
counters[property] += 1
- const method = Reflect.get(array, property) as (...args: unknown[]) => unknown
- return method.bind(array)
+ return array[property].bind(array)
}
if (property === Symbol.iterator) {
counters.iterator += 1
@@ -30,6 +29,7 @@ function countCollectionReads(items: readonly T[]): {
if (property === 'length') {
counters.length += 1
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
return Reflect.get(array, property)
}
})
diff --git a/src/renderer/src/store/selectors.test.ts b/src/renderer/src/store/selectors.test.ts
index 45278caf40d..43491683ce3 100644
--- a/src/renderer/src/store/selectors.test.ts
+++ b/src/renderer/src/store/selectors.test.ts
@@ -695,6 +695,7 @@ describe('selectFloatingWorkspaceHasUnread', () => {
if (typeof property === 'string') {
terminalUnreadReads += 1
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver.
return Reflect.get(target, property, receiver)
}
}
diff --git a/src/renderer/src/store/slices/tab-group-reference-repair.test.ts b/src/renderer/src/store/slices/tab-group-reference-repair.test.ts
index a7cb3dca125..e2f265ba6a6 100644
--- a/src/renderer/src/store/slices/tab-group-reference-repair.test.ts
+++ b/src/renderer/src/store/slices/tab-group-reference-repair.test.ts
@@ -35,6 +35,7 @@ describe('appendOwnedTabIdsToGroups', () => {
if (typeof property === 'string' && /^\d+$/.test(property)) {
reads++
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
return Reflect.get(target, property, receiver)
}
})
diff --git a/src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-batch.test.ts b/src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-batch.test.ts
index e6a7bfe7002..ebe87a28ab5 100644
--- a/src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-batch.test.ts
+++ b/src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-batch.test.ts
@@ -136,6 +136,7 @@ describe('whole-session workspace tab-model reconciliation', () => {
if (property === 'filter') {
scans += 1
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver.
return Reflect.get(target, property, receiver)
}
})
diff --git a/src/renderer/src/store/slices/terminal-tab-owner-index.test.ts b/src/renderer/src/store/slices/terminal-tab-owner-index.test.ts
index 0600296d1d4..09ecdf4ccb9 100644
--- a/src/renderer/src/store/slices/terminal-tab-owner-index.test.ts
+++ b/src/renderer/src/store/slices/terminal-tab-owner-index.test.ts
@@ -73,6 +73,7 @@ describe('terminal tab owner index', () => {
if (typeof property === 'string' && property.startsWith('wt-')) {
bucketVisits += 1
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
return Reflect.get(target, property, receiver)
}
})
diff --git a/src/renderer/src/store/slices/terminal-tab-title-batch.test.ts b/src/renderer/src/store/slices/terminal-tab-title-batch.test.ts
index e269ffaae86..5fa1700357e 100644
--- a/src/renderer/src/store/slices/terminal-tab-title-batch.test.ts
+++ b/src/renderer/src/store/slices/terminal-tab-title-batch.test.ts
@@ -140,6 +140,7 @@ describe('terminal tab title batches', () => {
if (typeof property === 'string' && property.startsWith('wt-')) {
bucketVisits += 1
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver.
return Reflect.get(target, property, receiver)
}
})
diff --git a/src/renderer/src/store/slices/workspace-cleanup-enrichment-performance.test.ts b/src/renderer/src/store/slices/workspace-cleanup-enrichment-performance.test.ts
index ed710d1af7c..5478cdd973a 100644
--- a/src/renderer/src/store/slices/workspace-cleanup-enrichment-performance.test.ts
+++ b/src/renderer/src/store/slices/workspace-cleanup-enrichment-performance.test.ts
@@ -42,6 +42,7 @@ function countOpenFileScans(
return target.filter(predicate)
}
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
return Reflect.get(target, property, receiver)
}
})
diff --git a/src/renderer/src/web/preload-api/web-fallback-api.ts b/src/renderer/src/web/preload-api/web-fallback-api.ts
index 3cfef000ef1..2f59cd72615 100644
--- a/src/renderer/src/web/preload-api/web-fallback-api.ts
+++ b/src/renderer/src/web/preload-api/web-fallback-api.ts
@@ -4,6 +4,7 @@ export function withFallback(target: T, path: string[]): T {
return new Proxy(target, {
get(current, property, receiver) {
if (property in current) {
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver.
const value = Reflect.get(current, property, receiver) as unknown
if (value && typeof value === 'object' && !Array.isArray(value)) {
return withFallback(value as object, [...path, String(property)])
diff --git a/src/renderer/src/web/web-preload-api-composition.test.ts b/src/renderer/src/web/web-preload-api-composition.test.ts
index f7091cac9bc..8bac20f5097 100644
--- a/src/renderer/src/web/web-preload-api-composition.test.ts
+++ b/src/renderer/src/web/web-preload-api-composition.test.ts
@@ -78,7 +78,8 @@ describe('web preload API composition', () => {
'telemetryAcknowledgeBanner'
])
expect(Object.keys(globals.window.api.projects)).toEqual([])
- expect(Reflect.get(globals.window.api.projects, 'then')).toBeUndefined()
+ const projects: Record = globals.window.api.projects
+ expect(projects.then).toBeUndefined()
})
it('snapshots E2E config before runtime storage initialization', async () => {
diff --git a/src/renderer/src/web/web-preload-api-runtime-calls.test.ts b/src/renderer/src/web/web-preload-api-runtime-calls.test.ts
index fcf24437595..48d366d1a72 100644
--- a/src/renderer/src/web/web-preload-api-runtime-calls.test.ts
+++ b/src/renderer/src/web/web-preload-api-runtime-calls.test.ts
@@ -102,7 +102,7 @@ describe('web preload runtime calls', () => {
if (!(rejection instanceof Error)) {
throw new Error('Expected a domain Error rejection')
}
- expect(Reflect.get(rejection, 'code')).toBe('repo_unavailable')
+ expect('code' in rejection ? rejection.code : undefined).toBe('repo_unavailable')
expect(
JSON.parse(globals.storage.getItem('orca.web.runtimeEnvironment.v1') ?? '{}')
).toMatchObject({ runtimeId: 'runtime-domain-failure' })
diff --git a/src/shared/automation-list-scope.test.ts b/src/shared/automation-list-scope.test.ts
index d9ff87eb99c..90915b4da06 100644
--- a/src/shared/automation-list-scope.test.ts
+++ b/src/shared/automation-list-scope.test.ts
@@ -210,6 +210,7 @@ describe('projectAutomationList', () => {
if (property === 'map' || property === 'filter') {
collectionMethodReads.push(String(property))
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
return Reflect.get(target, property, receiver)
}
})
diff --git a/src/shared/host-balanced-listing-scaling.test.ts b/src/shared/host-balanced-listing-scaling.test.ts
index aa7e40c2eb6..0a0d2a093bf 100644
--- a/src/shared/host-balanced-listing-scaling.test.ts
+++ b/src/shared/host-balanced-listing-scaling.test.ts
@@ -22,6 +22,7 @@ it('retires exhausted host buckets from subsequent listing rounds', () => {
if (typeof key === 'string' && /^\d+$/.test(key)) {
reads += 1
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver.
return Reflect.get(target, key, receiver)
}
})
diff --git a/src/shared/pr-bot-author-overrides.test.ts b/src/shared/pr-bot-author-overrides.test.ts
index 46476f0c5d1..e9e8d8a4e49 100644
--- a/src/shared/pr-bot-author-overrides.test.ts
+++ b/src/shared/pr-bot-author-overrides.test.ts
@@ -18,6 +18,7 @@ describe('PR bot author override normalization', () => {
if (typeof property === 'string' && /^\d+$/.test(property)) {
reads += 1
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
return Reflect.get(target, property, receiver)
}
})
diff --git a/src/shared/search-subprocess-lines.test.ts b/src/shared/search-subprocess-lines.test.ts
index 34425801105..1a2c72de5fc 100644
--- a/src/shared/search-subprocess-lines.test.ts
+++ b/src/shared/search-subprocess-lines.test.ts
@@ -69,9 +69,9 @@ describe('SearchSubprocessLineAccumulator', () => {
}
expect(accepted).toBe(true)
- expect(Reflect.get(parser, 'buffer')).toBeInstanceOf(Buffer)
+ expect(parser.retainedCapacityBytes()).toBeGreaterThanOrEqual(200_000)
expect(parser.finish()).toBe('x'.repeat(200_000))
- expect(Reflect.get(parser, 'buffer')).toBeNull()
+ expect(parser.retainedCapacityBytes()).toBeNull()
})
it('rejects invalid byte limits', () => {
diff --git a/src/shared/search-subprocess-lines.ts b/src/shared/search-subprocess-lines.ts
index 26f98b4d348..54e1defa5c0 100644
--- a/src/shared/search-subprocess-lines.ts
+++ b/src/shared/search-subprocess-lines.ts
@@ -65,6 +65,11 @@ export class SearchSubprocessLineAccumulator {
this.bytes = 0
}
+ /** Capacity of the retained growable buffer, or null once it has been released. */
+ retainedCapacityBytes(): number | null {
+ return this.buffer?.length ?? null
+ }
+
private append(segment: Buffer): void {
const requiredBytes = this.bytes + segment.length
if (!this.buffer || this.buffer.length < requiredBytes) {
diff --git a/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts b/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts
index f353ec3a097..db7bddcf2cf 100644
--- a/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts
+++ b/tests/e2e/cross-version-wire/host-terminal-runtime-stub.ts
@@ -198,6 +198,7 @@ export function createHostTerminalRuntimeStub(
}
return () => undefined
}
+ // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward.
return Reflect.get(target, property, receiver)
}
})
diff --git a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts
index 4d9e2de68ec..78efd420f83 100644
--- a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts
+++ b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts
@@ -69,10 +69,10 @@ type DispatcherModule = {
function registeredMethodNames(methods: readonly unknown[]): string[] {
return methods
.flatMap((method) => {
- if (!method || typeof method !== 'object') {
+ if (!method || typeof method !== 'object' || !('name' in method)) {
return []
}
- const name = Reflect.get(method, 'name')
+ const { name } = method
return typeof name === 'string' ? [name] : []
})
.sort()
diff --git a/tests/e2e/github-url-smart-input-transition.spec.ts b/tests/e2e/github-url-smart-input-transition.spec.ts
index 3e2b0198812..f7e988a9c29 100644
--- a/tests/e2e/github-url-smart-input-transition.spec.ts
+++ b/tests/e2e/github-url-smart-input-transition.spec.ts
@@ -66,13 +66,24 @@ type TransitionFrame = {
targetSelected: boolean
}
+declare global {
+ // oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging requires interface
+ interface Window {
+ // Per-provider capture buffers written by startTransitionCapture below.
+ __githubUrlTransitionFrames?: TransitionFrame[]
+ __gitlabUrlTransitionFrames?: TransitionFrame[]
+ }
+}
+
+type TransitionFrameKey = '__githubUrlTransitionFrames' | '__gitlabUrlTransitionFrames'
+
function pasteChord(): string {
return process.platform === 'darwin' ? 'Meta+V' : 'Control+V'
}
async function startTransitionCapture(
page: Page,
- frameKey: string,
+ frameKey: TransitionFrameKey,
wrongTitle: string,
targetTitle: string
): Promise {
@@ -95,20 +106,29 @@ async function startTransitionCapture(
requestAnimationFrame(capture)
}
}
- Reflect.set(window, frameKey, frames)
+ window[frameKey] = frames
capture()
},
{ frameKey, frameLimit: TRANSITION_FRAME_LIMIT, wrongTitle, targetTitle }
)
}
-async function readTransitionFrames(page: Page, frameKey: string): Promise {
- return page.evaluate((key) => Reflect.get(window, key) as TransitionFrame[], frameKey)
+async function readTransitionFrames(
+ page: Page,
+ frameKey: TransitionFrameKey
+): Promise {
+ return page.evaluate((key) => {
+ const frames = window[key]
+ if (!frames) {
+ throw new Error(`Transition capture ${key} was never installed`)
+ }
+ return frames
+ }, frameKey)
}
async function expectLookupHeldWithoutStaleRow(
page: Page,
- frameKey: string,
+ frameKey: TransitionFrameKey,
targetUrl: string,
wrongOption: Locator,
targetOption: Locator
@@ -125,7 +145,7 @@ async function expectLookupHeldWithoutStaleRow(
async function expectExactTargetAfterLookup(
page: Page,
- frameKey: string,
+ frameKey: TransitionFrameKey,
targetUrl: string,
targetOption: Locator
): Promise {
@@ -268,7 +288,7 @@ test('a pasted GitHub URL never selects a stale cached issue', async ({
})
await expect(wrongOption).toBeVisible()
- const frameKey = '__githubUrlTransitionFrames'
+ const frameKey: TransitionFrameKey = '__githubUrlTransitionFrames'
await startTransitionCapture(orcaPage, frameKey, WRONG_TITLE, TARGET_TITLE)
await orcaPage.evaluate((text) => window.api.ui.writeClipboardText(text), TARGET_URL)
@@ -317,7 +337,7 @@ test('a pasted GitLab URL never selects a stale cached merge request', async ({
})
await expect(wrongOption).toBeVisible()
- const frameKey = '__gitlabUrlTransitionFrames'
+ const frameKey: TransitionFrameKey = '__gitlabUrlTransitionFrames'
await startTransitionCapture(orcaPage, frameKey, GITLAB_WRONG_TITLE, GITLAB_TARGET_TITLE)
await orcaPage.evaluate((text) => window.api.ui.writeClipboardText(text), GITLAB_TARGET_URL)
diff --git a/tests/e2e/linear-url-workspace-entry.spec.ts b/tests/e2e/linear-url-workspace-entry.spec.ts
index d1cb2677257..16491a785db 100644
--- a/tests/e2e/linear-url-workspace-entry.spec.ts
+++ b/tests/e2e/linear-url-workspace-entry.spec.ts
@@ -25,6 +25,14 @@ const LINEAR_ISSUE: LinearIssue = {
updatedAt: '2026-08-12T00:00:00.000Z'
}
+declare global {
+ // oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging requires interface
+ interface Window {
+ // Set by the fixture below while a Linear lookup is deliberately held open.
+ __orcaTestReleaseLinearLookup?: () => void
+ }
+}
+
function pasteChord(): string {
return process.platform === 'darwin' ? 'Meta+V' : 'Control+V'
}
@@ -86,7 +94,7 @@ async function installLinearFixture(
async function releaseHeldLinearLookup(page: Page): Promise {
await page.evaluate(() => {
- const release = Reflect.get(window, '__orcaTestReleaseLinearLookup')
+ const release = window.__orcaTestReleaseLinearLookup
if (typeof release !== 'function') {
throw new Error('Linear lookup is not held')
}
@@ -131,9 +139,7 @@ test.describe('Linear URL workspace entry', () => {
await pasteLinearUrl(orcaPage, input)
await expect
.poll(() =>
- orcaPage.evaluate(
- () => typeof Reflect.get(window, '__orcaTestReleaseLinearLookup') === 'function'
- )
+ orcaPage.evaluate(() => typeof window.__orcaTestReleaseLinearLookup === 'function')
)
.toBe(true)
await input.press('Enter')
diff --git a/tests/e2e/project-group-creation-visibility.spec.ts b/tests/e2e/project-group-creation-visibility.spec.ts
index d659734570a..90deffecb1f 100644
--- a/tests/e2e/project-group-creation-visibility.spec.ts
+++ b/tests/e2e/project-group-creation-visibility.spec.ts
@@ -7,6 +7,11 @@ import { runProcess } from '../../src/shared/child-process/run-process'
test.use({ seedTestRepo: false })
+declare global {
+ // Resolved by the main-process gate this spec installs around the group-create response.
+ var __releaseGroupCreateResponse: (() => void) | undefined
+}
+
for (const delayCreateResponse of [false, true]) {
test(`created groups survive sidebar expansion (${delayCreateResponse ? 'refresh first' : 'ordinary timing'})`, async ({
orcaPage,
@@ -97,7 +102,7 @@ for (const delayCreateResponse of [false, true]) {
.toBe(true)
} finally {
await electronApp.evaluate(() => {
- const release = Reflect.get(globalThis, '__releaseGroupCreateResponse')
+ const release = globalThis.__releaseGroupCreateResponse
if (typeof release !== 'function') {
throw new Error('Group create response gate unavailable')
}
diff --git a/tests/e2e/worktree-active-delete-scroll-position.spec.ts b/tests/e2e/worktree-active-delete-scroll-position.spec.ts
index 5e2f15f5b75..15cf6c97914 100644
--- a/tests/e2e/worktree-active-delete-scroll-position.spec.ts
+++ b/tests/e2e/worktree-active-delete-scroll-position.spec.ts
@@ -17,6 +17,14 @@ type RowRemovalFrame = {
targetExists: boolean
}
+declare global {
+ // oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging requires interface
+ interface Window {
+ // Frame sampling started in the page and awaited once the removal animation settles.
+ __activeDeleteRowRemovalFrames?: Promise
+ }
+}
+
async function pauseForVisualProof(page: Page): Promise {
if (process.env.ORCA_E2E_RECORD_VIDEO === '1') {
await page.waitForTimeout(VISUAL_PROOF_PAUSE_MS)
@@ -215,7 +223,7 @@ async function startRowRemovalSampling(
async function finishRowRemovalSampling(page: Page): Promise {
return page.evaluate(async () => {
- const pending = Reflect.get(window, '__activeDeleteRowRemovalFrames')
+ const pending = window.__activeDeleteRowRemovalFrames
if (!(pending instanceof Promise)) {
throw new Error('Row removal sampling was not started')
}
From c0fb04c8d21ab67ca06e6df283ee120166ed22e1 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Tue, 15 Sep 2026 01:25:41 -0700
Subject: [PATCH 28/34] fix(relay): open the real null device when detaching
Windows stdio (#20808)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(relay): open the real null device when detaching Windows stdio
`openSync('NUL')` does not reach the null device on Windows. node's fs runs
the path through `toNamespacedPath`, which resolves it against cwd and
prefixes `\\?\` — and that prefix turns off DOS device-name mapping, so
CreateFileW creates a regular file named `NUL` in the relay's install dir
and pins fds 0/1 to it instead of to a discard sink.
Verified on a Windows 11 host: `fs.openSync('NUL', 'w')` + a 5-byte write
produced a 5-byte file named `NUL` in cwd. `\\.\NUL` is passed through
`toNamespacedPath` verbatim; the same write discards and a read answers
EOF, with no file created.
It also escaped into shipped artifacts. release-cut.yml runs the relay
watcher fault harness with cwd = out/relay/win32-x64, so every Windows
installer since v1.4.169 carries `resources/relay/win32-x64/NUL`, which
NSIS extracts as `_NUL`.
* test(relay): prove the `\\?\` rewrite on a drive-letter path
`toNamespacedPath('NUL')` off Windows only resolves against a POSIX cwd and
stops; with no drive letter it never reaches the branch that adds `\\?\`. So
the assertion held for the wrong reason and did not demonstrate the rewrite
the comment describes. Assert it on an absolute drive path, which takes the
same branch on every host.
---
src/relay/relay-primary-channel.test.ts | 29 +++++++++++++++++++++++++
src/relay/relay-primary-channel.ts | 16 +++++++++++---
2 files changed, 42 insertions(+), 3 deletions(-)
create mode 100644 src/relay/relay-primary-channel.test.ts
diff --git a/src/relay/relay-primary-channel.test.ts b/src/relay/relay-primary-channel.test.ts
new file mode 100644
index 00000000000..d1ac209e161
--- /dev/null
+++ b/src/relay/relay-primary-channel.test.ts
@@ -0,0 +1,29 @@
+import { win32 } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { nullDevicePath } from './relay-primary-channel'
+
+describe('nullDevicePath', () => {
+ it('names the POSIX null device off win32', () => {
+ expect(nullDevicePath('linux')).toBe('/dev/null')
+ expect(nullDevicePath('darwin')).toBe('/dev/null')
+ })
+
+ /**
+ * The defect this pins: `openSync('NUL')` on Windows does NOT open the null device.
+ * node runs the path through `toNamespacedPath`, which resolves it against cwd and
+ * prefixes `\\?\` — and `\\?\` turns off DOS device-name mapping, so CreateFileW makes
+ * a real file. v1.4.203's Windows installer shipped one at
+ * `resources/relay/win32-x64/NUL` because of it.
+ */
+ it('uses a device path win32 cannot rewrite into a file in the relay cwd', () => {
+ const path = nullDevicePath('win32')
+
+ expect(path).toBe('\\\\.\\NUL')
+ expect(win32.toNamespacedPath(path)).toBe(path)
+ // Bare `NUL` never survives as a device name: it is resolved against cwd, and a
+ // drive-letter cwd then also takes the `\\?\` prefix. Spelled absolute because off
+ // Windows `resolve` finds no drive letter and stops before that second rewrite.
+ expect(win32.toNamespacedPath('NUL')).not.toBe('NUL')
+ expect(win32.toNamespacedPath(String.raw`C:\relay\NUL`)).toBe(String.raw`\\?\C:\relay\NUL`)
+ })
+})
diff --git a/src/relay/relay-primary-channel.ts b/src/relay/relay-primary-channel.ts
index 9b2e50eaaa1..e3dbffeae7f 100644
--- a/src/relay/relay-primary-channel.ts
+++ b/src/relay/relay-primary-channel.ts
@@ -2,6 +2,17 @@ import { closeSync, openSync } from 'node:fs'
import { RelayDispatcher } from './dispatcher'
import { RELAY_SENTINEL } from './protocol'
+/**
+ * Why the `\\.\` device prefix and not bare `NUL`: node's fs resolves a relative path
+ * through `toNamespacedPath`, which hands CreateFileW a `\\?\C:\…\NUL` — and that prefix
+ * disables DOS device-name mapping, so the open creates a real FILE named `NUL` in the
+ * relay's cwd and pins fds 0/1 to it. One shipped in the 1.4.203 Windows installer as
+ * `resources/relay/win32-x64/NUL`. A `\\.\` path is passed through verbatim.
+ */
+export function nullDevicePath(platform: NodeJS.Platform = process.platform): string {
+ return platform === 'win32' ? String.raw`\\.\NUL` : '/dev/null'
+}
+
export class RelayPrimaryChannel {
readonly dispatcher: RelayDispatcher
private stdoutAlive = true
@@ -111,14 +122,13 @@ export class RelayPrimaryChannel {
// Already closed by the peer.
}
}
- const devNull = process.platform === 'win32' ? 'NUL' : '/dev/null'
try {
- openSync(devNull, 'r')
+ openSync(nullDevicePath(), 'r')
} catch {
// Best-effort pin of the lowest free descriptor.
}
try {
- openSync(devNull, 'w')
+ openSync(nullDevicePath(), 'w')
} catch {
// Best-effort pin of the next free descriptor.
}
From 37394e9cb75e61fd3c4145c15a90fc38e670874a Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Tue, 15 Sep 2026 01:25:45 -0700
Subject: [PATCH 29/34] build(release): compile the Windows relay process-table
addon (#20809)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* build(release): compile the Windows relay process-table addon
#16598 added build-windows-process-tree-relay-addon.mjs and the
ORCA_REQUIRE_RELAY_NATIVE_ADDONS gate, but wired both into
dev-channel-win-build.yml only. release-cut.yml was never touched, and
stageWindowsProcessTreeAddon merely logs when the addon is absent, so
every stable release has shipped Windows relays without
windows-process-tree.node.
Confirmed by extracting the installers: v1.4.191 (the first stable
carrying the feature), v1.4.198 and v1.4.203 all have no
windows-process-tree.node in relay/win32-x64 or relay/win32-arm64. Those
hosts have been taking the CIM fallback the whole time — 1247ms and a
powershell.exe per scan against 57ms native, on #16598's own ~1490-process
measurement host.
Mirror the dev-channel steps. Same windows-2022 image, so the MSVC ARM64
cross toolset the arm64 leg needs is already proven there, and the addon
build runs before the long packaging step so a missing component fails in
seconds with MSB8020 naming it.
* build(release): keep the Build app env rationale attached to its step
The new addon step landed between the ORCA_POSTHOG_WRITE_KEY / BUILD_IDENTITY
/ DIAGNOSTICS_TOKEN_URL comment block and the Build app step it documents,
orphaning it. Move the step above the block and record why it carries no
run_attempt guard: Build app is ungated, so a guarded addon step would let a
rerun reach the required-addon check with nothing staged.
---
.github/workflows/release-cut.yml | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml
index a644aef53c1..ac2e7f904e3 100644
--- a/.github/workflows/release-cut.yml
+++ b/.github/workflows/release-cut.yml
@@ -1311,6 +1311,20 @@ jobs:
echo "identity=$identity" >>"$GITHUB_OUTPUT"
echo "Classified $TAG as $identity"
+ # Why here and not in build:relay: only a Windows runner can compile it, and
+ # arm64 cross-compiles from this same x64 agent. Mirrors dev-channel-win-build.yml,
+ # which had it while release-cut did not — so every stable installer through
+ # v1.4.203 shipped Windows relays with no windows-process-tree.node, silently
+ # falling back to the PowerShell scan on every Windows SSH host.
+ # Why no run_attempt guard, unlike the artifact steps below: Build app is ungated,
+ # so a rerun would reach the required-addon check with nothing staged and fail.
+ - name: Build Windows process-table addon for the relay
+ if: matrix.platform == 'win'
+ shell: bash
+ run: |
+ node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=x64
+ node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=arm64
+
# Why ORCA_POSTHOG_WRITE_KEY here: this is the only build that
# produces a published binary, so this is the only place the secret
# needs to be in scope. The key is a PostHog *project* API key, not
@@ -1333,6 +1347,9 @@ jobs:
ORCA_BUILD_IDENTITY: ${{ steps.tag-classify.outputs.identity }}
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
+ # Fail the release rather than ship a relay that silently falls back to
+ # the PowerShell scan on every Windows SSH host.
+ ORCA_REQUIRE_RELAY_NATIVE_ADDONS: ${{ matrix.platform == 'win' && 'x64,arm64' || '' }}
- name: Gate runtime file-watcher process isolation
if: runner.os == 'Linux'
From e4a9d24e0c261f81a82a81d060dc909c90dede91 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Tue, 15 Sep 2026 01:28:23 -0700
Subject: [PATCH 30/34] fix(automations): repair cron step expansion and day
restriction (#20202)
The semantic half of the cron repair. Both defects change what an already-saved
schedule does, so they ship together and behind a decision.
#15723: parseCronField set end = start for a bare numeric field even with a
slash step, so 5/15 expanded to [5] and fired hourly instead of every fifteen
minutes. N/step is the open-ended N-max/step sequence now.
#15896: day restriction came from expanded set cardinality, so 1-31 read as
unrestricted and */2 as restricted. Restriction is lexical now: a day field
restricts iff no term of it ranges over a star, matching vixie cron and
robfig/cron rather than crontab(5)'s prose. Verified differentially against
robfig/cron v1.2.0 across 22 expressions, 424 days, zero divergences.
The two cannot ship apart: 0 9 1/1 * 1 matches 124 days under the old parser,
104 under #15723 alone, and 730 under both, because the old cardinality flags
react to the corrected expansion.
describeAutomationScheduleDrift reads a saved expression under both semantics
and reports the ones that moved, so neither direction is silent; the service
names them once at startup. No expression Orca's own presets generate drifts.
Fixes #15723
Fixes #15896
---
.../automations/schedule-drift-report.test.ts | 61 ++++++++
src/main/automations/schedule-drift-report.ts | 31 ++++
src/main/automations/service.ts | 2 +
src/shared/automation-cron-dialect.test.ts | 132 ++++++++++++++++++
src/shared/automation-cron-field-parsing.ts | 22 ++-
src/shared/automation-cron-occurrence.ts | 1 +
src/shared/automation-schedule-drift.test.ts | 83 +++++++++++
src/shared/automation-schedule-drift.ts | 123 ++++++++++++++++
src/shared/automation-schedule-parsing.ts | 41 +++---
src/shared/automation-schedules.test.ts | 4 +-
src/shared/automation-schedules.ts | 28 ++--
11 files changed, 489 insertions(+), 39 deletions(-)
create mode 100644 src/main/automations/schedule-drift-report.test.ts
create mode 100644 src/main/automations/schedule-drift-report.ts
create mode 100644 src/shared/automation-cron-dialect.test.ts
create mode 100644 src/shared/automation-schedule-drift.test.ts
create mode 100644 src/shared/automation-schedule-drift.ts
diff --git a/src/main/automations/schedule-drift-report.test.ts b/src/main/automations/schedule-drift-report.test.ts
new file mode 100644
index 00000000000..f719998576b
--- /dev/null
+++ b/src/main/automations/schedule-drift-report.test.ts
@@ -0,0 +1,61 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { Automation } from '../../shared/automations-types'
+import { reportAutomationScheduleDrift } from './schedule-drift-report'
+
+const makeAutomation = (name: string, rrule: string): Automation => ({
+ id: `id-${name}`,
+ name,
+ prompt: 'Check the repo',
+ precheck: null,
+ agentId: 'claude',
+ projectId: 'r1',
+ executionTargetType: 'local',
+ executionTargetId: 'local',
+ schedulerOwner: 'local_host_service',
+ workspaceMode: 'existing',
+ workspaceId: 'wt1',
+ baseBranch: null,
+ reuseSession: false,
+ timezone: 'UTC',
+ rrule,
+ dtstart: 0,
+ enabled: true,
+ nextRunAt: 0,
+ missedRunPolicy: 'run_once_within_grace',
+ missedRunGraceMinutes: 720,
+ createdAt: 0,
+ updatedAt: 0
+})
+
+describe('automation schedule drift report', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('names each affected record and which way it moved', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const count = reportAutomationScheduleDrift([
+ makeAutomation('Quarter-hourly sweep', '5/15 * * * *'),
+ makeAutomation('Odd days and Mondays', '0 9 */2 * 1'),
+ makeAutomation('Weekday standup', '30 9 * * 1-5')
+ ])
+
+ expect(count).toBe(2)
+ const lines = warn.mock.calls.map((call) => String(call[0]))
+ expect(lines[0]).toContain('2 saved schedule(s) changed meaning')
+ expect(
+ lines.some((l) => l.includes('Quarter-hourly sweep') && l.includes('now runs more'))
+ ).toBe(true)
+ expect(
+ lines.some((l) => l.includes('Odd days and Mondays') && l.includes('now runs fewer'))
+ ).toBe(true)
+ // The untouched preset must not be named, or the report trains the reader to skip it.
+ expect(lines.some((l) => l.includes('Weekday standup'))).toBe(false)
+ })
+
+ it('says nothing when no saved schedule drifted', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ expect(reportAutomationScheduleDrift([makeAutomation('Hourly', '0 * * * *')])).toBe(0)
+ expect(warn).not.toHaveBeenCalled()
+ })
+})
diff --git a/src/main/automations/schedule-drift-report.ts b/src/main/automations/schedule-drift-report.ts
new file mode 100644
index 00000000000..a2e38d65ac7
--- /dev/null
+++ b/src/main/automations/schedule-drift-report.ts
@@ -0,0 +1,31 @@
+/**
+ * Reports saved schedules whose meaning changed in the release that repaired the cron parser.
+ *
+ * Both repairs were correct, but a persisted cadence can now fire several times more — or
+ * several times less — than it did yesterday. The louder direction announces itself through
+ * spend; the quieter one does not, because nobody notices a job that stopped running. One
+ * line per affected record at startup is the smallest signal that makes either detectable.
+ */
+import type { Automation } from '../../shared/automations-types'
+import { describeAutomationScheduleDrift } from '../../shared/automation-schedule-drift'
+
+export function reportAutomationScheduleDrift(automations: readonly Automation[]): number {
+ const drifted = automations.flatMap((automation) => {
+ const drift = describeAutomationScheduleDrift(automation.rrule)
+ return drift ? [{ automation, drift }] : []
+ })
+ if (drifted.length === 0) {
+ return 0
+ }
+ console.warn(
+ `[automations] ${drifted.length} saved schedule(s) changed meaning when the cron parser was repaired; review them:`
+ )
+ for (const { automation, drift } of drifted) {
+ const direction = drift.currentRunsPerYear > drift.previousRunsPerYear ? 'more' : 'fewer'
+ console.warn(
+ `[automations] "${automation.name}" (${automation.id}) "${drift.expression}" now runs ` +
+ `${direction}: about ${drift.currentRunsPerYear}/year, was about ${drift.previousRunsPerYear}/year`
+ )
+ }
+ return drifted.length
+}
diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts
index b683fb6c5a2..4be15f095db 100644
--- a/src/main/automations/service.ts
+++ b/src/main/automations/service.ts
@@ -25,6 +25,7 @@ import {
type AutomationRunTerminalObserver
} from './run-completion-watcher'
import { createAutomationRunWriter, type AutomationRunWriter } from './automation-run-writer'
+import { reportAutomationScheduleDrift } from './schedule-drift-report'
import {
describeScheduledRefusal,
recordRefusedAutomationRun,
@@ -115,6 +116,7 @@ export class AutomationService {
void this.evaluateDueRuns()
}, this.tickMs)
this.completionWatcher?.reconcileRetainedRuns(this.store.listAutomationRuns())
+ reportAutomationScheduleDrift(this.store.listAutomations())
// Why: headless serve never gets a renderer-ready IPC, but due runs still
// need the same startup catch-up pass desktop gets after renderer attach.
if (this.rendererReady || this.headlessDispatcher) {
diff --git a/src/shared/automation-cron-dialect.test.ts b/src/shared/automation-cron-dialect.test.ts
new file mode 100644
index 00000000000..dd6500b1549
--- /dev/null
+++ b/src/shared/automation-cron-dialect.test.ts
@@ -0,0 +1,132 @@
+import { describe, expect, it } from 'vitest'
+import { cronMatches } from './automation-cron-occurrence'
+import { parseCronExpression } from './automation-schedule-parsing'
+
+const ascending = (values: Set): number[] => [...values].sort((left, right) => left - right)
+
+const EVERY_DAY_OF_MAY = Array.from({ length: 31 }, (_, index) => index + 1)
+
+/**
+ * Independent calendar oracle. May 2026 opens on a Friday, so its Mondays are 4/11/18/25,
+ * its Sundays 3/10/17/24/31, and its weekend days 2/3, 9/10, 16/17, 23/24 and 30/31. Every
+ * expectation below is that hand calendar, never a second call into the parser.
+ */
+function matchingDaysOfMay2026(expression: string): number[] {
+ const rule = parseCronExpression(expression)
+ const days: number[] = []
+ for (let day = 1; day <= 31; day += 1) {
+ if (cronMatches(rule, new Date(2026, 4, day, 9, 0, 0, 0).getTime())) {
+ days.push(day)
+ }
+ }
+ return days
+}
+
+describe('cron bare stepped values (#15723)', () => {
+ it('expands `N/step` as the open-ended `N-max/step` sequence in every field', () => {
+ expect(ascending(parseCronExpression('5/15 * * * *').minutes)).toEqual([5, 20, 35, 50])
+ expect(ascending(parseCronExpression('* 2/7 * * *').hours)).toEqual([2, 9, 16, 23])
+ expect(ascending(parseCronExpression('0 9 5/10 * *').daysOfMonth)).toEqual([5, 15, 25])
+ expect(ascending(parseCronExpression('0 9 * MAR/3 *').months)).toEqual([3, 6, 9, 12])
+ // 1, 4 and 7, with Sunday normalized off 7.
+ expect(ascending(parseCronExpression('0 9 * * 1/3').daysOfWeek)).toEqual([0, 1, 4])
+ })
+
+ it('matches the explicit `N-max/step` range it is defined to mean', () => {
+ const equivalents: [string, string][] = [
+ ['5/15 * * * *', '5-59/15 * * * *'],
+ ['* 2/7 * * *', '* 2-23/7 * * *'],
+ ['0 9 5/10 * *', '0 9 5-31/10 * *'],
+ ['0 9 * MAR/3 *', '0 9 * MAR-DEC/3 *'],
+ ['0 9 * * 1/3', '0 9 * * 1-7/3']
+ ]
+ for (const [bare, explicit] of equivalents) {
+ const left = parseCronExpression(bare)
+ const right = parseCronExpression(explicit)
+ expect([
+ ascending(left.minutes),
+ ascending(left.hours),
+ ascending(left.daysOfMonth),
+ ascending(left.months),
+ ascending(left.daysOfWeek)
+ ]).toEqual([
+ ascending(right.minutes),
+ ascending(right.hours),
+ ascending(right.daysOfMonth),
+ ascending(right.months),
+ ascending(right.daysOfWeek)
+ ])
+ }
+ })
+
+ it('leaves a bare value with no step as itself', () => {
+ expect(ascending(parseCronExpression('5 * * * *').minutes)).toEqual([5])
+ expect(ascending(parseCronExpression('0 9 5 * *').daysOfMonth)).toEqual([5])
+ expect(ascending(parseCronExpression('0 9 * MAR *').months)).toEqual([3])
+ expect(ascending(parseCronExpression('0 9 * * FRI').daysOfWeek)).toEqual([5])
+ })
+
+ // Separateness probe: the #15723 repair only reaches the bare-value branch, so it moves
+ // neither an oversized step nor the day-restriction flags (#15896).
+ it('leaves oversized-step and full-range-day expansions exactly where they were', () => {
+ expect(ascending(parseCronExpression('*/90 * * * *').minutes)).toEqual([0])
+ expect(ascending(parseCronExpression('5/90 * * * *').minutes)).toEqual([5])
+ expect(ascending(parseCronExpression('0 9 1-31 * 1').daysOfMonth)).toEqual(EVERY_DAY_OF_MAY)
+ })
+})
+
+describe('cron day restriction (#15896)', () => {
+ // Dialect: a day field is restricted iff no term of it ranges over a star; when both day
+ // fields are restricted the day matches on either, otherwise on both. Every expectation
+ // below was taken from robfig/cron v1.2.0, an independent implementation of the same rule.
+ it('ORs an explicit full day-of-month range against a restricted day-of-week', () => {
+ expect(matchingDaysOfMay2026('0 9 1-31 * 1')).toEqual(EVERY_DAY_OF_MAY)
+ })
+
+ it('ANDs a wildcard day-of-month against a restricted day-of-week', () => {
+ expect(matchingDaysOfMay2026('0 9 * * 1')).toEqual([4, 11, 18, 25])
+ })
+
+ it('ORs two partially restricted day fields', () => {
+ expect(matchingDaysOfMay2026('0 9 1,15 * 1')).toEqual([1, 4, 11, 15, 18, 25])
+ })
+
+ it('ANDs a restricted day-of-month against a wildcard day-of-week', () => {
+ expect(matchingDaysOfMay2026('0 9 1,15 * *')).toEqual([1, 15])
+ })
+
+ // A star step is still a star, so it does not flip the day rule to OR. Reading `*/2` as
+ // restricted would fire this ~8x more: the 18 odd-or-Monday days, not the 2 that are both.
+ it('keeps AND when a day field steps over a star', () => {
+ expect(matchingDaysOfMay2026('0 9 */2 * 1')).toEqual([11, 25])
+ expect(matchingDaysOfMay2026('0 9 */1 * 1')).toEqual([4, 11, 18, 25])
+ expect(matchingDaysOfMay2026('0 9 * * */2')).toEqual([
+ 2, 3, 5, 7, 9, 10, 12, 14, 16, 17, 19, 21, 23, 24, 26, 28, 30, 31
+ ])
+ })
+
+ // The star test is per comma term, so a list that reaches a star anywhere is unrestricted.
+ it('treats a day list containing a star term as a star', () => {
+ expect(matchingDaysOfMay2026('0 9 */3 * 1,5')).toEqual([1, 4, 22, 25])
+ })
+
+ it('normalizes Sunday from 0, from 7 and from the name', () => {
+ expect(matchingDaysOfMay2026('0 9 * * 0')).toEqual([3, 10, 17, 24, 31])
+ expect(matchingDaysOfMay2026('0 9 * * 7')).toEqual([3, 10, 17, 24, 31])
+ expect(matchingDaysOfMay2026('0 9 * * SUN')).toEqual([3, 10, 17, 24, 31])
+ })
+
+ it('reads month and day names on both sides of the restriction rule', () => {
+ expect(matchingDaysOfMay2026('0 9 * MAY MON')).toEqual([4, 11, 18, 25])
+ expect(matchingDaysOfMay2026('0 9 * JUN MON')).toEqual([])
+ expect(matchingDaysOfMay2026('0 9 1-31 MAY MON')).toEqual(EVERY_DAY_OF_MAY)
+ })
+
+ // Preset-built schedules always write a literal `*` day-of-month, so they keep AND.
+ it('leaves preset-shaped schedules on AND semantics', () => {
+ expect(matchingDaysOfMay2026('0 9 * * 1-5')).toEqual([
+ 1, 4, 5, 6, 7, 8, 11, 12, 13, 14, 15, 18, 19, 20, 21, 22, 25, 26, 27, 28, 29
+ ])
+ expect(matchingDaysOfMay2026('0 9 * * *')).toEqual(EVERY_DAY_OF_MAY)
+ })
+})
diff --git a/src/shared/automation-cron-field-parsing.ts b/src/shared/automation-cron-field-parsing.ts
index b43e4d4fe5a..306b44032a3 100644
--- a/src/shared/automation-cron-field-parsing.ts
+++ b/src/shared/automation-cron-field-parsing.ts
@@ -1,6 +1,10 @@
-// Cron field parsing for Orca's automation schedules.
-// A field step is bounded by the count of distinct values the field holds: a step of 90 on
-// minutes is one value at :00, never "every 90 minutes", so it is refused as input (#15895).
+// Orca's cron dialect (vixie/POSIX):
+// - `N/step` is the open-ended sequence `N-max/step`; a bare `N` is only itself (#15723).
+// - A day field is restricted iff no term of it ranges over a star, so `1-31` restricts but
+// `*/2` does not (#15896). Restriction is lexical: the expanded set cannot tell `1-31` from
+// `*`. When both day fields are restricted the day matches on either; otherwise on both.
+// - A field step is bounded by the count of distinct values the field holds; a step of 90 on
+// minutes is one value at :00, never "every 90 minutes" (#15895).
export type CronParseOptions = {
/** Input-time gate: reject a step wider than the field's domain instead of silently
* degenerating to a single value. Off for persisted rows, which must keep running the
@@ -104,7 +108,8 @@ export function parseCronField(args: {
end = parseCronNumber(endPart, args.names ?? null, args.field)
} else {
start = parseCronNumber(rangePart, args.names ?? null, args.field)
- end = start
+ // `N/step` is the open-ended `N-max/step` sequence; a bare `N` is only itself.
+ end = stepPart === undefined ? start : args.max
}
const normalizedStart = args.normalize?.(start) ?? start
@@ -131,3 +136,12 @@ export function parseCronField(args: {
}
return result
}
+
+// A day field restricts iff none of its terms ranges over a star, matching what vixie cron
+// and robfig/cron both do. crontab(5) says "restricted (ie, are not *)", which reads as a
+// literal-`*` test, but vixie's own entry.c sets DOM_STAR/DOW_STAR off the field's leading
+// character, so `*/2` is a star there too; we follow the implementations over the prose,
+// because reading `*/2` as restricted flips its day rule to OR and fires it ~8x more.
+export function isCronDayFieldRestricted(field: string): boolean {
+ return !field.split(',').some((term) => term.split('/')[0].trim() === '*')
+}
diff --git a/src/shared/automation-cron-occurrence.ts b/src/shared/automation-cron-occurrence.ts
index 6ad88468e16..3cb37a83034 100644
--- a/src/shared/automation-cron-occurrence.ts
+++ b/src/shared/automation-cron-occurrence.ts
@@ -30,6 +30,7 @@ export function cronDateMatches(rule: ParsedCron, timestamp: number): boolean {
}
const dayOfMonthMatches = rule.daysOfMonth.has(date.getDate())
const dayOfWeekMatches = rule.daysOfWeek.has(date.getDay())
+ // Dialect rule; the flags are lexical (`isCronDayFieldRestricted`), not set sizes.
if (rule.dayOfMonthRestricted && rule.dayOfWeekRestricted) {
return dayOfMonthMatches || dayOfWeekMatches
}
diff --git a/src/shared/automation-schedule-drift.test.ts b/src/shared/automation-schedule-drift.test.ts
new file mode 100644
index 00000000000..751b411bd73
--- /dev/null
+++ b/src/shared/automation-schedule-drift.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from 'vitest'
+import { describeAutomationScheduleDrift } from './automation-schedule-drift'
+
+// Both lists were recorded by running the same corpus through the real parent build
+// (729491597f3) and this branch, not by re-deriving them from the detector under test.
+const DRIFTED = [
+ '5/15 * * * *',
+ '0/30 * * * *',
+ '5/15 9 * * *',
+ '0 9/4 * * *',
+ '0 9 1/7 * *',
+ '0 9 * 1/3 *',
+ '0 9 * * 1/2',
+ '0 9 1-31 * 1',
+ '0 9 */2 * 1',
+ '0 9 */3 * 1',
+ '0 9 * MAR/3 *'
+]
+
+const STABLE = [
+ '0 * * * *',
+ '30 9 * * *',
+ '30 9 * * 1-5',
+ '30 9 * * 3',
+ '*/15 * * * *',
+ '*/5 * * * *',
+ '5 * * * *',
+ '0 9 */1 * 1',
+ '0 9 1,15 * 1',
+ '0 9 * * 0-6',
+ '0 9 * * 0-7',
+ '0 9 * * 1-7',
+ '0 9 */2 * *',
+ '0 9 * * */2',
+ '0 9 1-31 * *',
+ '0 9 * * *',
+ '0 9 15 * *',
+ '0 9 1-15 * 1',
+ '*/90 * * * *',
+ '5/90 * * * *',
+ '0 9 * * */8',
+ '0 9 * MAY MON',
+ '0 9 * * FRI'
+]
+
+const ANCHOR = new Date(2026, 0, 1).getTime()
+
+describe('automation schedule drift', () => {
+ it('flags every schedule the repair changed', () => {
+ for (const expression of DRIFTED) {
+ expect(describeAutomationScheduleDrift(expression, ANCHOR), expression).not.toBeNull()
+ }
+ })
+
+ // The restriction flags move on several of these while the days they fire do not; reporting
+ // those would train the reader to ignore the notice.
+ it('stays silent on schedules the repair left alone', () => {
+ for (const expression of STABLE) {
+ expect(describeAutomationScheduleDrift(expression, ANCHOR), expression).toBeNull()
+ }
+ })
+
+ it('reports the direction and size of the change', () => {
+ // 1x/hour -> 4x/hour: the cadence users will feel as spend.
+ expect(describeAutomationScheduleDrift('5/15 * * * *', ANCHOR)).toEqual({
+ expression: '5/15 * * * *',
+ previousRunsPerYear: 8760,
+ currentRunsPerYear: 35040
+ })
+ // The quiet direction: an automation that now skips most of the days it used to run.
+ const fewer = describeAutomationScheduleDrift('0 9 */2 * 1', ANCHOR)
+ expect(fewer!.currentRunsPerYear).toBeLessThan(fewer!.previousRunsPerYear / 4)
+ })
+
+ it('ignores RRULE presets, which never used the repaired parser', () => {
+ expect(describeAutomationScheduleDrift('FREQ=DAILY;BYHOUR=9;BYMINUTE=0', ANCHOR)).toBeNull()
+ })
+
+ it('reports nothing for a schedule that cannot be read at all', () => {
+ expect(describeAutomationScheduleDrift('0 9 32 * *', ANCHOR)).toBeNull()
+ expect(describeAutomationScheduleDrift('not a cron', ANCHOR)).toBeNull()
+ })
+})
diff --git a/src/shared/automation-schedule-drift.ts b/src/shared/automation-schedule-drift.ts
new file mode 100644
index 00000000000..fc4f18564f9
--- /dev/null
+++ b/src/shared/automation-schedule-drift.ts
@@ -0,0 +1,123 @@
+// Detects saved cron schedules whose meaning changed in the release that repaired the parser
+// (#15723, #15896). Both repairs were correct, but a persisted cadence can now fire several
+// times more — or several times less — than it did yesterday, with nothing to notice it by.
+import {
+ getAutomationCronExpressionFields,
+ parseCronExpression,
+ type ParsedCron
+} from './automation-schedule-parsing'
+import { cronDateMatches } from './automation-cron-occurrence'
+
+// Two years covers every day-of-month against day-of-week pairing a schedule can land on,
+// which is the only part of matching that depends on the calendar rather than the sets.
+const DRIFT_SCAN_DAYS = 730
+
+export type AutomationScheduleDrift = {
+ expression: string
+ /** Runs a year under the cadence as it was read before the repair, and as it reads now. */
+ previousRunsPerYear: number
+ currentRunsPerYear: number
+}
+
+/**
+ * The pre-repair reading of a field: a bare value carrying a step lost the step, so `5/15`
+ * meant `5`. A star or a range kept its step, and is left alone.
+ */
+function toPreRepairField(field: string): string {
+ return field
+ .split(',')
+ .map((term) => {
+ const [range, step] = term.split('/')
+ if (step === undefined || range.includes('*') || range.includes('-')) {
+ return term
+ }
+ return range
+ })
+ .join(',')
+}
+
+/**
+ * The pre-repair reading of a whole expression. Day restriction came from how many values a
+ * field expanded to rather than from what the user wrote, so `1-31` read as unrestricted.
+ */
+function parsePreRepairCron(expression: string): ParsedCron {
+ const fields = getAutomationCronExpressionFields(expression, 6)
+ const parsed = parseCronExpression(fields.map(toPreRepairField).join(' '))
+ return {
+ ...parsed,
+ dayOfMonthRestricted: parsed.daysOfMonth.size !== 31,
+ dayOfWeekRestricted: parsed.daysOfWeek.size !== 7
+ }
+}
+
+/** Walks both readings over the same calendar so the comparison is which days, not how many. */
+function compareMatchingDays(
+ previous: ParsedCron,
+ current: ParsedCron,
+ anchor: number
+): { previousDays: number; currentDays: number; sameDays: boolean } {
+ const cursor = new Date(anchor)
+ cursor.setHours(12, 0, 0, 0)
+ let previousDays = 0
+ let currentDays = 0
+ let sameDays = true
+ for (let i = 0; i < DRIFT_SCAN_DAYS; i += 1) {
+ const at = cursor.getTime()
+ const previousMatch = cronDateMatches(previous, at)
+ const currentMatch = cronDateMatches(current, at)
+ if (previousMatch) {
+ previousDays += 1
+ }
+ if (currentMatch) {
+ currentDays += 1
+ }
+ if (previousMatch !== currentMatch) {
+ sameDays = false
+ }
+ cursor.setDate(cursor.getDate() + 1)
+ }
+ return { previousDays, currentDays, sameDays }
+}
+
+function runsPerYear(rule: ParsedCron, days: number): number {
+ return Math.round((days / 2) * rule.hours.size * rule.minutes.size)
+}
+
+/**
+ * Null when the saved cadence still means what it did before the repair. Only cron schedules
+ * can drift; RRULE presets never went through the repaired field parser.
+ */
+export function describeAutomationScheduleDrift(
+ schedule: string,
+ anchor = Date.now()
+): AutomationScheduleDrift | null {
+ const expression = schedule.trim()
+ if (expression.includes('=')) {
+ return null
+ }
+ let current: ParsedCron
+ let previous: ParsedCron
+ try {
+ current = parseCronExpression(expression)
+ previous = parsePreRepairCron(expression)
+ } catch {
+ // An unreadable schedule drifts nowhere; the tick reports it separately (#16303).
+ return null
+ }
+ const sameClock =
+ previous.minutes.size === current.minutes.size &&
+ previous.hours.size === current.hours.size &&
+ [...current.minutes].every((minute) => previous.minutes.has(minute)) &&
+ [...current.hours].every((hour) => previous.hours.has(hour))
+ const { previousDays, currentDays, sameDays } = compareMatchingDays(previous, current, anchor)
+ // Compare what the schedule fires, not how it parsed: the restriction flags move on
+ // expressions whose matched days do not, and those are not worth telling anyone about.
+ if (sameClock && sameDays) {
+ return null
+ }
+ return {
+ expression,
+ previousRunsPerYear: runsPerYear(previous, previousDays),
+ currentRunsPerYear: runsPerYear(current, currentDays)
+ }
+}
diff --git a/src/shared/automation-schedule-parsing.ts b/src/shared/automation-schedule-parsing.ts
index af3ce324f1a..497809bcc0f 100644
--- a/src/shared/automation-schedule-parsing.ts
+++ b/src/shared/automation-schedule-parsing.ts
@@ -5,6 +5,7 @@ import { isClipboardTextByteLengthOverLimit } from './clipboard-text'
import {
DAY_NAMES,
MONTH_NAMES,
+ isCronDayFieldRestricted,
parseCronField,
type CronParseOptions
} from './automation-cron-field-parsing'
@@ -76,23 +77,6 @@ export function parseCronExpression(
}
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
const rejectOversizedStep = options.rejectOversizedStep ?? false
- const daysOfMonth = parseCronField({
- value: dayOfMonth,
- min: 1,
- max: 31,
- field: 'day of month',
- rejectOversizedStep
- })
- const daysOfWeek = parseCronField({
- value: dayOfWeek,
- min: 0,
- max: 7,
- field: 'day of week',
- names: DAY_NAMES,
- normalize: (value) => (value === 7 ? 0 : value),
- distinctValueCount: 7,
- rejectOversizedStep
- })
return {
kind: 'cron',
minutes: parseCronField({
@@ -103,7 +87,13 @@ export function parseCronExpression(
rejectOversizedStep
}),
hours: parseCronField({ value: hour, min: 0, max: 23, field: 'hour', rejectOversizedStep }),
- daysOfMonth,
+ daysOfMonth: parseCronField({
+ value: dayOfMonth,
+ min: 1,
+ max: 31,
+ field: 'day of month',
+ rejectOversizedStep
+ }),
months: parseCronField({
value: month,
min: 1,
@@ -112,9 +102,18 @@ export function parseCronExpression(
names: MONTH_NAMES,
rejectOversizedStep
}),
- daysOfWeek,
- dayOfMonthRestricted: daysOfMonth.size !== 31,
- dayOfWeekRestricted: daysOfWeek.size !== 7
+ daysOfWeek: parseCronField({
+ value: dayOfWeek,
+ min: 0,
+ max: 7,
+ field: 'day of week',
+ names: DAY_NAMES,
+ normalize: (value) => (value === 7 ? 0 : value),
+ distinctValueCount: 7,
+ rejectOversizedStep
+ }),
+ dayOfMonthRestricted: isCronDayFieldRestricted(dayOfMonth),
+ dayOfWeekRestricted: isCronDayFieldRestricted(dayOfWeek)
}
}
diff --git a/src/shared/automation-schedules.test.ts b/src/shared/automation-schedules.test.ts
index 7005b7d59e0..71b1a2bf70b 100644
--- a/src/shared/automation-schedules.test.ts
+++ b/src/shared/automation-schedules.test.ts
@@ -242,7 +242,9 @@ describe('automation schedules', () => {
expect(formatAutomationSchedule('0 9,17 * * MON-FRI')).toBe('Custom schedule')
})
- it('treats all-value cron day fields as unrestricted for DOM/DOW matching', () => {
+ // Restriction is lexical (#15896), but a star step is still a star: `*/1` does not
+ // restrict, so the day rule stays AND and this fires on Mondays only.
+ it('treats a stepped cron day-of-month field as unrestricted for DOM/DOW matching', () => {
const next = nextAutomationOccurrenceAfter(
'0 9 */1 * MON',
new Date('2026-05-01T00:00:00').getTime(),
diff --git a/src/shared/automation-schedules.ts b/src/shared/automation-schedules.ts
index 6d5a0053595..993d8fbf831 100644
--- a/src/shared/automation-schedules.ts
+++ b/src/shared/automation-schedules.ts
@@ -94,27 +94,29 @@ function classifyParsedCronSchedule(rule: ParsedCron): AutomationCronScheduleCla
}
const minute = getSingleSetValue(rule.minutes)
const hour = getSingleSetValue(rule.hours)
- const unrestrictedDayOfMonth = !rule.dayOfMonthRestricted
const unrestrictedMonth = setContainsRange(rule.months, 1, 12)
- const unrestrictedDayOfWeek = !rule.dayOfWeekRestricted
- const unrestrictedCalendar = unrestrictedDayOfMonth && unrestrictedMonth
- if (
- minute !== null &&
- setContainsRange(rule.hours, 0, 23) &&
- unrestrictedCalendar &&
- unrestrictedDayOfWeek
- ) {
+ const everyDayOfMonth = setContainsRange(rule.daysOfMonth, 1, 31)
+ const everyDayOfWeek = setContainsRange(rule.daysOfWeek, 0, 6)
+ // Labels describe the days the rule actually fires on, so they need coverage of the
+ // matched set, not the lexical restriction flags that pick OR over AND.
+ const matchesEitherDayField = rule.dayOfMonthRestricted && rule.dayOfWeekRestricted
+ const everyDay = matchesEitherDayField
+ ? everyDayOfMonth || everyDayOfWeek
+ : everyDayOfMonth && everyDayOfWeek
+ const unrestrictedCalendar = everyDayOfMonth && unrestrictedMonth
+ if (minute !== null && setContainsRange(rule.hours, 0, 23) && unrestrictedMonth && everyDay) {
return {
kind: 'hourly',
minute,
label: `Hourly at :${String(minute).padStart(2, '0')}`
}
}
- if (minute !== null && hour !== null && unrestrictedCalendar) {
+ if (minute !== null && hour !== null && unrestrictedMonth && everyDay) {
+ return { kind: 'daily', hour, minute, label: `Daily at ${formatTime(hour, minute)}` }
+ }
+ // Weekday/weekly names only read true under AND; under OR the day-of-month half fires too.
+ if (minute !== null && hour !== null && unrestrictedCalendar && !matchesEitherDayField) {
const time = formatTime(hour, minute)
- if (unrestrictedDayOfWeek) {
- return { kind: 'daily', hour, minute, label: `Daily at ${time}` }
- }
if (setContainsExactly(rule.daysOfWeek, [1, 2, 3, 4, 5])) {
return { kind: 'weekdays', hour, minute, label: `Weekdays at ${time}` }
}
From bfdec26352c0f0a36b35c7418f4bfa7f1d33bd25 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Tue, 15 Sep 2026 01:59:58 -0700
Subject: [PATCH 31/34] fix(lint): enable anti-slop/no-object-parameters
(#20781)
The rule rejects the broad `object` type on any function input (declarations,
expressions, arrows, methods, call/construct signatures, function types), plus
local aliases and unions that resolve to `object`. `object` accepts every
non-primitive while exposing no properties, so it documents nothing and pushes
callers into assertions at the boundary.
Fixes all 185 violations across src, config, tests and mobile, and flips the
rule from "off" to "error" in config/oxlint-anti-slop.json.
Approach: replace each `object` input with the type its owner already has.
Most sites took an existing domain type or a type-only import (36 added);
40 new aliases name shapes that had none. Where a value is genuinely only
compared by reference, it gets a named identity token instead of a shape --
`Record`, the built-in `WeakKey`, or a `unique symbol` brand,
matching the branding already used in src/shared. Same treatment for WeakMap
and Map key parameters. Two `as unknown as` casts became unnecessary once the
parameter carried a real type and were removed; no new casts were added.
Suppressions added: none. No `oxlint-disable` for this rule anywhere, and no
max-lines disable or per-file bump.
Three files sat exactly at their max-lines cap, so the added type imports were
made line-neutral rather than suppressed:
- src/main/ipc/browser.ts exports the existing guest-registration args type
(renamed BrowserGuestArgs) so browser.test.ts reuses it on one line.
- pane-scroll.ts takes TerminalScrollIntentTarget through the existing
pane-manager-types import via a type-only re-export.
- direct-rpc-client.ts drops the identity parameter entirely: the session
check moved into the sendProbe callback that owns the token.
Verified: anti-slop config reports zero violations over src config tests
mobile; run-typecheck-projects-in-parallel exits 0; 144 affected test files
pass (1749 tests); oxlint and oxfmt clean on all changed files. Mobile has no
runnable test/typecheck target in this worktree (expo is not installed), so
its 6 files were typechecked against a standalone config and diffed against
the base branch -- error sets are byte-identical, including test files.
---
config/oxlint-anti-slop.json | 2 +-
.../agent-status-hot-path-benchmark.test.ts | 9 +++-
.../happy-dom-mutation-observer-retention.ts | 8 ++--
.../connection-diagnostics-screen-data.ts | 7 +++-
.../mobile-terminal-viewport-resubscribe.ts | 11 +++--
mobile/src/transport/direct-rpc-client.ts | 6 +--
.../host-client-acquisition-registry.ts | 3 +-
mobile/src/transport/relay-dial-stage.ts | 23 +++++++---
.../rpc-session-liveness-watchdog.ts | 5 ++-
.../ai-vault-search/session-search-clock.ts | 6 +--
.../artifacts/artifact-cloud-recovery.test.ts | 12 ++++--
.../agent-browser-bridge-test-harness.ts | 19 +++++----
.../browser/browser-cookie-import-clear.ts | 17 ++++++--
.../browser-cookie-import-concurrency.test.ts | 5 ++-
...ser-cookie-import-google-exclusion.test.ts | 5 ++-
...r-cookie-import-partition-fidelity.test.ts | 5 ++-
.../browser-cookie-import-replacement.test.ts | 5 ++-
...kie-import-route-partition-staging.test.ts | 5 ++-
.../browser-cookie-import-scope.test.ts | 5 ++-
...rowser-cookie-import-undecryptable.test.ts | 5 ++-
.../browser/browser-cookie-import.test.ts | 5 ++-
.../browser/cdp-keyboard-us-layout.test.ts | 8 ++--
.../doc-preview-download-block-notice.test.ts | 5 ++-
.../managed-codex-auth-readiness.test.ts | 2 +-
.../codex-session-migration-scheduler.ts | 18 ++++++--
...aemon-pty-adapter-history-recovery.test.ts | 6 ++-
src/main/git/git-capability-state.test.ts | 8 +++-
src/main/git/git-capability-state.ts | 5 ++-
...browser-preview-tool-authorization.test.ts | 10 ++++-
src/main/ipc/browser.test.ts | 5 ++-
src/main/ipc/browser.ts | 8 ++--
src/main/ipc/filesystem-test-harness.ts | 8 +++-
.../ipc/runtime-watcher-process-pool.test.ts | 3 +-
src/main/ipc/settings.test.ts | 6 ++-
...thoritative-local-metadata-pruning.test.ts | 2 +-
.../ipc/worktrees-lineage-hydration.test.ts | 3 +-
src/main/ipc/worktrees-test-ipc-surface.ts | 3 +-
.../wsl-transcript-fs-process-dispatch.ts | 2 +-
.../network/electron-proxy-credentials.ts | 13 +++---
.../hook-plugin-fail-open-ownership.test.ts | 6 ++-
.../hook-plugin-lifecycle-delivery.test.ts | 8 +++-
.../loading-store/automation-persistence.ts | 2 +-
.../metadata-lineage-operations.ts | 2 +-
.../mobile-tab-selection-persistence.ts | 2 +-
.../loading-store/primary-state-writes.ts | 2 +-
.../loading-store/profile-preferences.ts | 5 ++-
.../project-collection-operations.ts | 2 +-
.../loading-store/pty-binding-persistence.ts | 2 +-
.../repo-lifecycle-operations.ts | 2 +-
.../retired-worktree-name-persistence.ts | 2 +-
.../loading-store/session-host-partitions.ts | 2 +-
.../session-snapshot-operations.ts | 2 +-
.../sparse-preset-persistence.ts | 2 +-
.../ssh-lease-recovery-operations.ts | 2 +-
.../loading-store/ssh-profile-operations.ts | 2 +-
.../loading-store/store-domain-composition.ts | 3 +-
.../loading-store/write-flush-barriers.ts | 2 +-
.../loading-store/write-scheduling.ts | 2 +-
...ng-local-worktree-metadata-pruning.test.ts | 3 +-
.../browser-client-download-transfer-store.ts | 8 +++-
...st-lease-download-transfer-cleanup.test.ts | 4 +-
.../relay/relay-control-client.test.ts | 5 ++-
.../runtime/relay/relay-control-client.ts | 2 +-
.../runtime/relay/relay-control-requests.ts | 42 +++++++++++--------
.../runtime/runtime-browser-page-registry.ts | 6 ++-
.../runtime/runtime-linear-command-surface.ts | 15 ++++---
...ructured-session-worktree-teardown.test.ts | 5 ++-
.../structured-worker-terminal-read.test.ts | 9 +++-
.../hosted-review-branch-cache.ts | 11 +++--
.../worktree-retirement-backfill-scan.test.ts | 2 +-
src/main/worktree-retirement-backfill-scan.ts | 6 ++-
...dispatcher-frame-guard-regressions.test.ts | 5 ++-
src/relay/dispatcher.test.ts | 11 ++---
.../relay-filesystem-watch-registry.test.ts | 3 +-
.../agent/AgentSettingsDialog.test.tsx | 9 +++-
.../useAgentBucketCounts.gate.test.ts | 3 +-
...fCommentDecorator.model-lifecycle.test.tsx | 11 ++++-
.../editor/markdown-preview-search.ts | 19 ++++++---
.../editor/rich-markdown-auto-focus.test.ts | 5 ++-
.../editor/rich-markdown-key-handler.test.ts | 6 +--
.../rich-markdown-list-continuation.test.ts | 4 +-
.../editor/rich-markdown-paragraph.test.ts | 8 ++--
.../rich-markdown-tab-key-handler.test.ts | 20 ++++-----
.../use-markdown-preview-source-foundation.ts | 3 +-
.../src/components/github-checks-tab-state.ts | 7 +++-
.../checks-tab-actions.ts | 9 ++--
.../inspect-pull-request/checks-tab.tsx | 5 ++-
.../pull-request-page/checks/rerun.ts | 15 +++++--
.../pull-request-page/checks/tab.tsx | 9 ++--
.../GeneralWorkspaceSettingsSection.test.tsx | 3 +-
...RepositoryWorktreeDefaultsSection.test.tsx | 2 +-
...worktree-agent-orchestration-index.test.ts | 5 ++-
.../task-page-github-work-item-quiet-state.ts | 9 ++--
.../hidden-output-restore-scheduler.ts | 12 ++++--
.../pty-renderer-delivery-claims.ts | 13 ++++--
.../terminal-captured-input-dispatch.ts | 4 +-
.../terminal-ime-xterm-adversarial.test.ts | 11 +++--
.../terminal-pane-lifecycle-primitives.ts | 9 ++--
.../use-task-page-github-quiet-refresh.ts | 3 +-
...vigationMetadata.capability-owner.test.tsx | 12 +++++-
...WindowsTerminalCapabilityOwnerKey.test.tsx | 2 +-
.../technical-literal-catalog-values.test.ts | 2 +-
.../src/lib/ime-composition-keyboard-event.ts | 16 +++----
.../pane-cursor-blink-suspension.test.ts | 5 ++-
.../lib/pane-manager/pane-lifecycle.test.ts | 4 +-
.../lib/pane-manager/pane-manager-types.ts | 2 +
.../pane-manager/pane-rendering-control.ts | 10 +++--
.../src/lib/pane-manager/pane-scroll.ts | 10 ++---
.../pane-terminal-output-ack-credit.ts | 5 ++-
.../terminal-parsed-dirty-rows.ts | 19 ++++++---
.../terminal-scroll-intent-rebuild.ts | 24 +++++++----
.../terminal-webgl-hidden-retention.test.ts | 5 ++-
.../terminal-webgl-hidden-retention.ts | 11 +++--
.../terminal-write-pipeline-health.ts | 33 ++++++++-------
...commit-cascade-store-write-samples.test.ts | 2 +-
...eact-commit-cascade-store-write-samples.ts | 26 ++++++------
.../src/lib/simulator-launch-coordination.ts | 5 ++-
.../src/lib/state-collection-byte-estimate.ts | 12 ++++--
.../react-commit-cascade-write-probe.test.ts | 3 +-
...lient-host-reconciliation-protocol.test.ts | 5 ++-
.../repro-7732-gitlab-job-id-dropped.test.ts | 2 +-
121 files changed, 593 insertions(+), 298 deletions(-)
diff --git a/config/oxlint-anti-slop.json b/config/oxlint-anti-slop.json
index bf41f269552..3a115684ba9 100644
--- a/config/oxlint-anti-slop.json
+++ b/config/oxlint-anti-slop.json
@@ -30,7 +30,7 @@
"anti-slop/no-conditional-empty-object-spread": "off",
"anti-slop/no-known-value-widening": "off",
"anti-slop/no-module-mocking": "error",
- "anti-slop/no-object-parameters": "off",
+ "anti-slop/no-object-parameters": "error",
"anti-slop/no-reduce-accumulator-copy": "error",
"anti-slop/no-reflect-apply": "error",
"anti-slop/no-reflect-get": "error",
diff --git a/config/scripts/agent-status-hot-path-benchmark.test.ts b/config/scripts/agent-status-hot-path-benchmark.test.ts
index 6c30b82ebf7..ece89b89d50 100644
--- a/config/scripts/agent-status-hot-path-benchmark.test.ts
+++ b/config/scripts/agent-status-hot-path-benchmark.test.ts
@@ -244,7 +244,11 @@ describe('agent-status hot path benchmark', () => {
let objectAssignCalls = 0
let objectAssignPropertyCopies = 0
let freshnessEntryVisits = 0
- Object.assign = ((target: object, ...sources: object[]) => {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `Object.assign` is an overload set no single arrow can satisfy; this wrapper only counts calls and forwards every argument to the captured native implementation.
+ Object.assign = ((
+ target: Record,
+ ...sources: readonly Record[]
+ ) => {
objectAssignCalls += 1
for (const source of sources) {
if (source && typeof source === 'object') {
@@ -253,7 +257,8 @@ describe('agent-status hot path benchmark', () => {
}
return nativeObjectAssign(target, ...sources)
}) as typeof Object.assign
- Object.values = ((value: object) => {
+ // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: same overload-set limit as the `Object.assign` wrapper above; this one counts visited entries and returns the native result unchanged.
+ Object.values = ((value: Record) => {
const result = nativeObjectValues(value)
freshnessEntryVisits += result.length
return result
diff --git a/config/scripts/happy-dom-mutation-observer-retention.ts b/config/scripts/happy-dom-mutation-observer-retention.ts
index a315b3d520c..c40a070bd58 100644
--- a/config/scripts/happy-dom-mutation-observer-retention.ts
+++ b/config/scripts/happy-dom-mutation-observer-retention.ts
@@ -48,12 +48,12 @@ export function installHappyDomMutationObserverRetention(): boolean {
const disconnect = prototype.disconnect
prototype.observe = function patchedObserve(
- this: object,
+ this: PatchableMutationObserver,
target: Node,
options?: MutationObserverInit
): void {
const existing = new Set(readMutationListeners(target))
- observe.call(this as unknown as PatchableMutationObserver, target, options)
+ observe.call(this, target, options)
const pinned = retainedCallbacks.get(this) ?? new Set()
for (const listener of readMutationListeners(target)) {
if (existing.has(listener)) {
@@ -69,8 +69,8 @@ export function installHappyDomMutationObserverRetention(): boolean {
}
}
- prototype.disconnect = function patchedDisconnect(this: object): void {
- disconnect.call(this as unknown as PatchableMutationObserver)
+ prototype.disconnect = function patchedDisconnect(this: PatchableMutationObserver): void {
+ disconnect.call(this)
retainedCallbacks.delete(this)
}
diff --git a/mobile/src/diagnostics/connection-diagnostics-screen-data.ts b/mobile/src/diagnostics/connection-diagnostics-screen-data.ts
index c8a2ed589c8..3ab974b46df 100644
--- a/mobile/src/diagnostics/connection-diagnostics-screen-data.ts
+++ b/mobile/src/diagnostics/connection-diagnostics-screen-data.ts
@@ -2,10 +2,13 @@ import type { ConnectionLogStore } from '../transport/connection-log-buffer'
import type { ConnectionLogEntry, HostProfile } from '../transport/types'
import type { RpcClientContextValue } from '../transport/rpc-client-context-contract'
+/** Route identity token: compared by reference to detect navigating away and back, never read. */
+export type DiagnosticsRouteKey = Record
+
export type DiagnosticsHostSelection = {
hostId: string
requestedHostId: string | undefined
- routeKey?: object
+ routeKey?: DiagnosticsRouteKey
}
export type DiagnosticsSubmissionState = 'sending' | 'sent' | 'failed'
@@ -29,7 +32,7 @@ export function resolveDiagnosticsHostId(
hosts: readonly HostProfile[],
requestedHostId: string | undefined,
manualSelection: DiagnosticsHostSelection | null,
- routeKey?: object
+ routeKey?: DiagnosticsRouteKey
): string | null {
const selected = manualSelection
if (selected && selected.requestedHostId === requestedHostId && selected.routeKey === routeKey) {
diff --git a/mobile/src/session/mobile-terminal-viewport-resubscribe.ts b/mobile/src/session/mobile-terminal-viewport-resubscribe.ts
index 8ad3c99c13e..175eb9d92b8 100644
--- a/mobile/src/session/mobile-terminal-viewport-resubscribe.ts
+++ b/mobile/src/session/mobile-terminal-viewport-resubscribe.ts
@@ -79,6 +79,9 @@ export function shouldResubscribeAfterViewportMeasure(args: {
return args.hostCols !== args.measured.cols || args.hostRows !== args.measured.rows
}
+/** Reference-identity token for a resubscribe attempt; carries no data, only compared by `===`. */
+type RetryGenerationToken = Readonly>
+
/** Per-handle resubscribe budget, mirroring the chat-side rearm bound: attempts
* refill only when the handle actually left terminal.list and came back. A
* still-listed non-converging handle re-funded on every list refresh would undo
@@ -87,7 +90,7 @@ export class TerminalViewportResubscribeBudget {
private readonly attemptsByHandle = new Map()
private readonly absentSinceExhaustion = new Set()
private readonly announcedExhaustion = new Set()
- private readonly retryGenerationByHandle = new Map()
+ private readonly retryGenerationByHandle = new Map()
attempts(handle: string): number {
return this.attemptsByHandle.get(handle) ?? 0
@@ -97,17 +100,17 @@ export class TerminalViewportResubscribeBudget {
this.attemptsByHandle.set(handle, this.attempts(handle) + 1)
}
- retryGeneration(handle: string): object {
+ retryGeneration(handle: string): RetryGenerationToken {
const existing = this.retryGenerationByHandle.get(handle)
if (existing) {
return existing
}
- const generation = {}
+ const generation: RetryGenerationToken = {}
this.retryGenerationByHandle.set(handle, generation)
return generation
}
- isRetryGenerationCurrent(handle: string, generation: object): boolean {
+ isRetryGenerationCurrent(handle: string, generation: RetryGenerationToken): boolean {
return this.retryGenerationByHandle.get(handle) === generation
}
diff --git a/mobile/src/transport/direct-rpc-client.ts b/mobile/src/transport/direct-rpc-client.ts
index 0031de90c6c..b72c49bd1a7 100644
--- a/mobile/src/transport/direct-rpc-client.ts
+++ b/mobile/src/transport/direct-rpc-client.ts
@@ -72,7 +72,7 @@ export class DirectRpcClient implements RpcClient {
})
this.liveness = new RpcSessionLivenessWatchdog({
transport: 'direct',
- sendProbe: (identity) => this.sendLivenessProbe(identity),
+ sendProbe: (identity) => identity === this.livenessSession && this.sendLivenessProbe(),
terminate: (identity) => {
if (identity === this.livenessSession && this.socketSession === this.livenessSession) {
this.socketClose.forceClose(this.livenessSession)
@@ -297,8 +297,8 @@ export class DirectRpcClient implements RpcClient {
return false
}
- private sendLivenessProbe(identity: object): boolean {
- if (identity !== this.livenessSession || this.getState() !== 'connected') {
+ private sendLivenessProbe(): boolean {
+ if (this.getState() !== 'connected') {
return false
}
return this.sendEncrypted({
diff --git a/mobile/src/transport/host-client-acquisition-registry.ts b/mobile/src/transport/host-client-acquisition-registry.ts
index 4e09ebdd9dc..618a4be3c0a 100644
--- a/mobile/src/transport/host-client-acquisition-registry.ts
+++ b/mobile/src/transport/host-client-acquisition-registry.ts
@@ -1,4 +1,5 @@
-export type HostClientAcquisition = object
+/** Holder identity token: the registry only compares references, never reads fields. */
+export type HostClientAcquisition = Record
export class HostClientAcquisitionRegistry {
private readonly acquisitions = new Map>()
diff --git a/mobile/src/transport/relay-dial-stage.ts b/mobile/src/transport/relay-dial-stage.ts
index c4a743f84f4..06c6e23477c 100644
--- a/mobile/src/transport/relay-dial-stage.ts
+++ b/mobile/src/transport/relay-dial-stage.ts
@@ -1,3 +1,5 @@
+import type { RpcClient } from './rpc-client'
+
// Where a relay dial is waiting, so a bound can tell "the cell never answered the
// upgrade" from "the cell took the dial and is slow" — the two look identical from
// ConnectionState, which stays 'connecting' until relay-hello arrives.
@@ -17,12 +19,21 @@ export type RelayDialStageSource = {
onDialStageChange(listener: (stage: RelayDialStage) => void): () => void
}
-export function relayDialStageSource(session: object): RelayDialStageSource | null {
- const candidate = session as Partial
- return typeof candidate.getDialStage === 'function' &&
- typeof candidate.onDialStageChange === 'function'
- ? (candidate as RelayDialStageSource)
- : null
+/** An RPC client that may also report relay dial stages; only relay sessions do. */
+export type MaybeRelayDialStageSource = RpcClient & Partial
+
+function reportsDialStages(
+ session: MaybeRelayDialStageSource
+): session is MaybeRelayDialStageSource & RelayDialStageSource {
+ return (
+ typeof session.getDialStage === 'function' && typeof session.onDialStageChange === 'function'
+ )
+}
+
+export function relayDialStageSource(
+ session: MaybeRelayDialStageSource
+): RelayDialStageSource | null {
+ return reportsDialStages(session) ? session : null
}
export class RelayDialStageTracker implements RelayDialStageSource {
diff --git a/mobile/src/transport/rpc-session-liveness-watchdog.ts b/mobile/src/transport/rpc-session-liveness-watchdog.ts
index 36525f60fb0..cbe891f810c 100644
--- a/mobile/src/transport/rpc-session-liveness-watchdog.ts
+++ b/mobile/src/transport/rpc-session-liveness-watchdog.ts
@@ -2,7 +2,10 @@ export const LIVENESS_IDLE_MS = 20_000
export const LIVENESS_PROBE_TIMEOUT_MS = 8_000
export const MISSED_PROBE_LIMIT = 3
-export type RpcSessionIdentity = object
+declare const rpcSessionIdentityBrand: unique symbol
+
+/** Opaque per-session token; only ever compared by reference. */
+export type RpcSessionIdentity = object & { readonly [rpcSessionIdentityBrand]?: never }
type WatchdogOptions = {
transport: 'direct' | 'relay'
diff --git a/src/main/ai-vault-search/session-search-clock.ts b/src/main/ai-vault-search/session-search-clock.ts
index c9eaf609a34..3c973b273e6 100644
--- a/src/main/ai-vault-search/session-search-clock.ts
+++ b/src/main/ai-vault-search/session-search-clock.ts
@@ -2,8 +2,8 @@
// makes is "within one reconcile interval", and a guarantee stated in wall time
// is only a claim until a test can advance the clock and watch it hold.
-/** Opaque to the indexer; a fake clock hands back whatever it likes. */
-export type SessionSearchTimerHandle = object | number
+/** Opaque to the indexer: the real clock hands back a timer, a fake clock an id. */
+export type SessionSearchTimerHandle = NodeJS.Timeout | number
export type SessionSearchClock = {
now(): number
@@ -20,5 +20,5 @@ export const systemSessionSearchClock: SessionSearchClock = {
timer.unref?.()
return timer
},
- clearTimeout: (handle) => clearTimeout(handle as NodeJS.Timeout)
+ clearTimeout: (handle) => clearTimeout(handle)
}
diff --git a/src/main/artifacts/artifact-cloud-recovery.test.ts b/src/main/artifacts/artifact-cloud-recovery.test.ts
index fea3b73bda0..04eebbf25a8 100644
--- a/src/main/artifacts/artifact-cloud-recovery.test.ts
+++ b/src/main/artifacts/artifact-cloud-recovery.test.ts
@@ -207,7 +207,10 @@ class ArtifactFaultServer {
rejectNextDeleteCode: string | null = null
rejectNextUpdateStatus: number | null = null
private readonly artifacts = new Map()
- private readonly createsByKey = new Map()
+ private readonly createsByKey = new Map<
+ string,
+ { body: string; response: ArtifactResponseBody }
+ >()
artifactSlugs(): string[] {
return [...this.artifacts.keys()].sort()
@@ -325,14 +328,17 @@ async function publishedLink(userDataPath: string): Promise {
return result.status === 'ok' ? (result.value?.shareUrl ?? null) : null
}
-function jsonResponse(body: object, status: number): Response {
+/** JSON payload the fake artifact API serialises for a response. */
+type ArtifactResponseBody = Record
+
+function jsonResponse(body: ArtifactResponseBody, status: number): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' }
})
}
-function createResponseBody(slug: string): object {
+function createResponseBody(slug: string): ArtifactResponseBody {
return {
artifact: {
version: 1,
diff --git a/src/main/browser/agent-browser-bridge-test-harness.ts b/src/main/browser/agent-browser-bridge-test-harness.ts
index 0e614600174..7aa38364a3c 100644
--- a/src/main/browser/agent-browser-bridge-test-harness.ts
+++ b/src/main/browser/agent-browser-bridge-test-harness.ts
@@ -1,4 +1,5 @@
import { vi, type Mock } from 'vitest'
+import type { AgentBrowserBridge } from './agent-browser-bridge'
import type { BrowserManager } from './browser-manager'
export type ExecFileCallback = (error: unknown, stdout?: string, stderr?: string) => void
@@ -95,15 +96,19 @@ export function mockWebContents(
// Why: the bridge resolves webContents via dynamic require('electron').webContents.fromId
// inside a try/catch. Override the private method to inject our mock.
export function overrideBridgeWebContentsLookup(
- bridgePrototype: object,
+ bridgePrototype: AgentBrowserBridge,
webContentsFromIdMock: Mock
): void {
- ;(bridgePrototype as { getWebContents: (id: number) => unknown }).getWebContents = function (
- id: number
- ) {
- const target = webContentsFromIdMock(id) as { isDestroyed: () => boolean } | null
- return target && !target.isDestroyed() ? target : null
- }
+ // Why defineProperty: getWebContents is protected, so a typed assignment is not expressible.
+ Object.defineProperty(bridgePrototype, 'getWebContents', {
+ configurable: true,
+ enumerable: true,
+ writable: true,
+ value: function (id: number) {
+ const target = webContentsFromIdMock(id) as { isDestroyed: () => boolean } | null
+ return target && !target.isDestroyed() ? target : null
+ }
+ })
}
export function createSucceedWith(execFileMock: Mock, stdinWrites: string[]) {
diff --git a/src/main/browser/browser-cookie-import-clear.ts b/src/main/browser/browser-cookie-import-clear.ts
index af79c4249ed..b1b04a98ff9 100644
--- a/src/main/browser/browser-cookie-import-clear.ts
+++ b/src/main/browser/browser-cookie-import-clear.ts
@@ -54,7 +54,13 @@ export type CookieClearSession = {
restoreClearIdentities: CookieClearStore['restoreClearIdentities']
}
-const mutationLocks = new WeakMap