From e8d5d50c3596c5ffc6c690d9f7c1097fedac335d Mon Sep 17 00:00:00 2001
From: jaeyoung
Date: Thu, 23 Jul 2026 16:27:06 +0900
Subject: [PATCH 01/32] fix(quick-open): guide rg install after fallback budget
errors (#9627)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(quick-open): guide rg install after fallback budget errors
* fix(quick-open): show local host wording for local install guidance
The install-rg guidance component hardcoded 'on the remote', so the new
local fallback path told local users to install ripgrep 'on the remote'
— wrong for the exact case #9627 targets. Parse the location out of the
message and render the matching wording; add the local locale string and
a render test that guards against the 'on the remote' regression. Also
harden the reason capture against a stray ')' in the error text.
Co-authored-by: Orca
---------
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca
---
.../filesystem-list-files-install-rg.test.ts | 71 +++++++++++++++
src/main/ipc/filesystem-list-files.ts | 23 +++--
src/relay/fs-handler-install-rg.ts | 88 ++-----------------
src/renderer/src/components/QuickOpen.tsx | 1 +
...k-open-install-rg-guidance.render.test.tsx | 33 +++++++
.../quick-open-install-rg-guidance.test.ts | 47 ++++++++++
.../quick-open-install-rg-guidance.tsx | 26 ++++--
src/renderer/src/i18n/locales/en.json | 3 +-
src/renderer/src/i18n/locales/es.json | 3 +-
src/renderer/src/i18n/locales/ja.json | 3 +-
src/renderer/src/i18n/locales/ko.json | 3 +-
src/renderer/src/i18n/locales/zh.json | 3 +-
src/shared/quick-open-install-rg.ts | 86 ++++++++++++++++++
13 files changed, 291 insertions(+), 99 deletions(-)
create mode 100644 src/main/ipc/filesystem-list-files-install-rg.test.ts
create mode 100644 src/renderer/src/components/quick-open-install-rg-guidance.render.test.tsx
create mode 100644 src/renderer/src/components/quick-open-install-rg-guidance.test.ts
create mode 100644 src/shared/quick-open-install-rg.ts
diff --git a/src/main/ipc/filesystem-list-files-install-rg.test.ts b/src/main/ipc/filesystem-list-files-install-rg.test.ts
new file mode 100644
index 00000000000..7f4c34f5eda
--- /dev/null
+++ b/src/main/ipc/filesystem-list-files-install-rg.test.ts
@@ -0,0 +1,71 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { Store } from '../persistence'
+
+const {
+ listFilesWithGitMock,
+ resolveAuthorizedPathMock,
+ checkRgAvailableMock,
+ getLocalGitOptionsForRegisteredWorktreeMock
+} = vi.hoisted(() => ({
+ listFilesWithGitMock: vi.fn(),
+ resolveAuthorizedPathMock: vi.fn(),
+ checkRgAvailableMock: vi.fn(),
+ getLocalGitOptionsForRegisteredWorktreeMock: vi.fn()
+}))
+
+vi.mock('./filesystem-list-files-git-fallback', () => ({
+ listFilesWithGit: listFilesWithGitMock
+}))
+
+vi.mock('./filesystem-auth', () => ({
+ resolveAuthorizedPath: resolveAuthorizedPathMock
+}))
+
+vi.mock('./rg-availability', () => ({
+ checkRgAvailable: checkRgAvailableMock
+}))
+
+vi.mock('./local-worktree-runtime-options', () => ({
+ getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock
+}))
+
+import { listQuickOpenFiles } from './filesystem-list-files'
+
+describe('filesystem-list-files ripgrep guidance', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resolveAuthorizedPathMock.mockImplementation(async (path) => path)
+ checkRgAvailableMock.mockResolvedValue(false)
+ getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({})
+ })
+
+ it('turns only a readdir budget failure into install guidance', async () => {
+ listFilesWithGitMock.mockRejectedValue(new Error('File listing exceeded 10000 files'))
+ const rejection = listQuickOpenFiles('/workspace', {} as Store)
+
+ await expect(rejection).rejects.toThrow(
+ 'Quick Open scan too large (File listing exceeded 10000 files).'
+ )
+ await rejection.catch((error: Error) =>
+ expect(error.message).toContain('Install ripgrep on the host running the Quick Open scan')
+ )
+ })
+
+ it('keeps cancellation and Git errors unchanged', async () => {
+ const cancellation = new Error('File listing cancelled')
+ listFilesWithGitMock.mockRejectedValueOnce(cancellation)
+ await expect(listQuickOpenFiles('/workspace', {} as Store)).rejects.toBe(cancellation)
+
+ const gitFailure = new Error('git ls-files exited with code 128')
+ listFilesWithGitMock.mockRejectedValueOnce(gitFailure)
+ await expect(listQuickOpenFiles('/workspace', {} as Store)).rejects.toBe(gitFailure)
+ })
+
+ it.skipIf(process.platform !== 'darwin')('shows the macOS install command', async () => {
+ listFilesWithGitMock.mockRejectedValue(new Error('File listing timed out'))
+
+ await expect(listQuickOpenFiles('/workspace', {} as Store)).rejects.toThrow(
+ 'brew install ripgrep'
+ )
+ })
+})
diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts
index 58b1c0c0cad..7edb688de72 100644
--- a/src/main/ipc/filesystem-list-files.ts
+++ b/src/main/ipc/filesystem-list-files.ts
@@ -14,6 +14,8 @@ import {
shouldExcludeQuickOpenRelPath,
shouldIncludeQuickOpenPath
} from '../../shared/quick-open-filter'
+import { isQuickOpenReaddirBudgetError } from '../../shared/quick-open-readdir-walk'
+import { buildInstallRgMessage } from '../../shared/quick-open-install-rg'
import { listFilesWithGit } from './filesystem-list-files-git-fallback'
export async function listQuickOpenFiles(
@@ -42,13 +44,20 @@ export async function listQuickOpenFiles(
// can run.
const rgAvailable = await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro)
if (!rgAvailable) {
- return listFilesWithGit(
- authorizedRootPath,
- excludePathPrefixes,
- localGitOptions,
- signal,
- maxResults
- )
+ try {
+ return await listFilesWithGit(
+ authorizedRootPath,
+ excludePathPrefixes,
+ localGitOptions,
+ signal,
+ maxResults
+ )
+ } catch (err) {
+ if (!isQuickOpenReaddirBudgetError(err)) {
+ throw err
+ }
+ throw new Error(await buildInstallRgMessage(err))
+ }
}
const files = new Set()
diff --git a/src/relay/fs-handler-install-rg.ts b/src/relay/fs-handler-install-rg.ts
index 9b0cd21b065..4f4be970aee 100644
--- a/src/relay/fs-handler-install-rg.ts
+++ b/src/relay/fs-handler-install-rg.ts
@@ -1,85 +1,11 @@
-import { readFile } from 'node:fs/promises'
import {
- getProcessOutputFields,
- iterateProcessOutputLines
-} from '../shared/process-output-field-scanner'
+ buildInstallRgMessage as buildSharedInstallRgMessage,
+ detectInstallCommand,
+ detectLinuxInstallCommandFromOsRelease
+} from '../shared/quick-open-install-rg'
-const GENERIC_LINUX_RIPGREP_INSTALL =
- 'install ripgrep via your package manager (e.g. apt/dnf/pacman)'
-const OS_RELEASE_ID_LIKE_MAX_FIELDS = 16
+export { detectInstallCommand, detectLinuxInstallCommandFromOsRelease }
-export async function detectInstallCommand(): Promise {
- if (process.platform === 'darwin') {
- return 'brew install ripgrep'
- }
- if (process.platform === 'linux') {
- try {
- const osRelease = await readFile('/etc/os-release', 'utf-8')
- return detectLinuxInstallCommandFromOsRelease(osRelease)
- } catch {
- /* fall through to generic guidance */
- }
- return GENERIC_LINUX_RIPGREP_INSTALL
- }
- return 'install ripgrep (https://github.com/BurntSushi/ripgrep#installation)'
-}
-
-export function detectLinuxInstallCommandFromOsRelease(osRelease: string): string {
- for (const id of getOsReleasePackageFamilyIds(osRelease)) {
- if (id === 'debian' || id === 'ubuntu') {
- return 'sudo apt install ripgrep'
- }
- if (id === 'fedora' || id === 'rhel' || id === 'centos') {
- return 'sudo dnf install ripgrep'
- }
- if (id === 'arch') {
- return 'sudo pacman -S ripgrep'
- }
- if (id === 'alpine') {
- return 'sudo apk add ripgrep'
- }
- }
-
- return GENERIC_LINUX_RIPGREP_INSTALL
-}
-
-function getOsReleasePackageFamilyIds(osRelease: string): string[] {
- const ids: string[] = []
-
- for (const line of iterateProcessOutputLines(osRelease)) {
- const separatorIndex = line.indexOf('=')
- if (separatorIndex <= 0) {
- continue
- }
-
- const key = line.slice(0, separatorIndex)
- const value = readOsReleaseValue(line.slice(separatorIndex + 1))
- if (key === 'ID') {
- const id = getProcessOutputFields(value, 1)[0]
- if (id) {
- ids.push(id)
- }
- } else if (key === 'ID_LIKE') {
- ids.push(...getProcessOutputFields(value, OS_RELEASE_ID_LIKE_MAX_FIELDS))
- }
- }
-
- return ids
-}
-
-function readOsReleaseValue(rawValue: string): string {
- const trimmed = rawValue.trim()
- const quote = trimmed[0]
- return (quote === '"' || quote === "'") && trimmed.at(-1) === quote
- ? trimmed.slice(1, -1)
- : trimmed
-}
-
-export async function buildInstallRgMessage(cause: unknown): Promise {
- const reason = cause instanceof Error ? cause.message : String(cause)
- const cmd = await detectInstallCommand()
- return (
- `Quick Open scan too large (${reason}). ` +
- `Install ripgrep on the remote to enable fast, gitignore-aware listing: ${cmd}`
- )
+export function buildInstallRgMessage(cause: unknown): Promise {
+ return buildSharedInstallRgMessage(cause, 'remote')
}
diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx
index 395628c0b3f..a2f2d48f3e9 100644
--- a/src/renderer/src/components/QuickOpen.tsx
+++ b/src/renderer/src/components/QuickOpen.tsx
@@ -130,6 +130,7 @@ export default function QuickOpen(): React.JSX.Element | null {
return guidance ? (
diff --git a/src/renderer/src/components/quick-open-install-rg-guidance.render.test.tsx b/src/renderer/src/components/quick-open-install-rg-guidance.render.test.tsx
new file mode 100644
index 00000000000..361f1457666
--- /dev/null
+++ b/src/renderer/src/components/quick-open-install-rg-guidance.render.test.tsx
@@ -0,0 +1,33 @@
+// @vitest-environment happy-dom
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+import { QuickOpenInstallRgGuidance } from './quick-open-install-rg-guidance'
+
+afterEach(cleanup)
+
+describe('QuickOpenInstallRgGuidance', () => {
+ it('says the local host, not the remote, for a local scan', () => {
+ render(
+
+ )
+ expect(screen.getByText(/on the host running the Quick Open scan/i)).toBeTruthy()
+ expect(screen.queryByText(/on the remote/i)).toBeNull()
+ })
+
+ it('says the remote for a relay scan', () => {
+ render(
+
+ )
+ expect(screen.getByText(/on the remote to enable fast/i)).toBeTruthy()
+ })
+})
diff --git a/src/renderer/src/components/quick-open-install-rg-guidance.test.ts b/src/renderer/src/components/quick-open-install-rg-guidance.test.ts
new file mode 100644
index 00000000000..0429563dad7
--- /dev/null
+++ b/src/renderer/src/components/quick-open-install-rg-guidance.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest'
+import { parseQuickOpenInstallRgGuidance } from './quick-open-install-rg-guidance'
+
+describe('parseQuickOpenInstallRgGuidance', () => {
+ it('parses the local message and reports the local location', () => {
+ expect(
+ parseQuickOpenInstallRgGuidance(
+ 'Quick Open scan too large (File listing timed out). Install ripgrep on the host running the Quick Open scan to enable fast, gitignore-aware listing: brew install ripgrep'
+ )
+ ).toEqual({
+ reason: 'File listing timed out',
+ location: 'local',
+ command: 'brew install ripgrep',
+ guidance: null
+ })
+ })
+
+ it('keeps parsing the legacy remote message and reports the remote location', () => {
+ expect(
+ parseQuickOpenInstallRgGuidance(
+ 'Quick Open scan too large (File listing exceeded 10000 files). Install ripgrep on the remote to enable fast, gitignore-aware listing: sudo apt install ripgrep'
+ )
+ ).toEqual({
+ reason: 'File listing exceeded 10000 files',
+ location: 'remote',
+ command: 'sudo apt install ripgrep',
+ guidance: null
+ })
+ })
+
+ it('renders generic install prose through the guidance path', () => {
+ expect(
+ parseQuickOpenInstallRgGuidance(
+ 'Quick Open scan too large (File listing timed out). Install ripgrep on the host running the Quick Open scan to enable fast, gitignore-aware listing: install ripgrep via your package manager (e.g. apt/dnf/pacman)'
+ )
+ ).toEqual({
+ reason: 'File listing timed out',
+ location: 'local',
+ command: null,
+ guidance: 'install ripgrep via your package manager (e.g. apt/dnf/pacman)'
+ })
+ })
+
+ it('returns null for regular errors', () => {
+ expect(parseQuickOpenInstallRgGuidance('git ls-files exited with code 128')).toBeNull()
+ })
+})
diff --git a/src/renderer/src/components/quick-open-install-rg-guidance.tsx b/src/renderer/src/components/quick-open-install-rg-guidance.tsx
index 1a05278aed8..a8f39679298 100644
--- a/src/renderer/src/components/quick-open-install-rg-guidance.tsx
+++ b/src/renderer/src/components/quick-open-install-rg-guidance.tsx
@@ -5,10 +5,16 @@ import { translate } from '@/i18n/i18n'
export type QuickOpenInstallRgGuidanceParts = {
reason: string
+ // Why: the fallback runs on whichever host performs the scan. A local scan
+ // must not tell the user to install ripgrep "on the remote"; the location
+ // phrase in the message is the only signal for which wording to render.
+ location: 'local' | 'remote'
command: string | null
guidance: string | null
}
+const REMOTE_LOCATION_PHRASE = 'on the remote'
+
/**
* Parses the install-ripgrep guidance message produced by the relay's
* buildInstallRgMessage(). Returns the parts needed to render as formatted
@@ -23,19 +29,21 @@ export function parseQuickOpenInstallRgGuidance(
message: string
): QuickOpenInstallRgGuidanceParts | null {
const match = message.match(
- /^Quick Open scan too large \(([^)]+)\)\. Install ripgrep on the remote to enable fast, gitignore-aware listing: (.+)$/
+ /^Quick Open scan too large \((.+?)\)\. Install ripgrep (on the remote|on the host running the Quick Open scan) to enable fast, gitignore-aware listing: (.+)$/
)
if (!match) {
return null
}
const reason = match[1]
- const tail = match[2].trim()
+ const location = match[2] === REMOTE_LOCATION_PHRASE ? 'remote' : 'local'
+ const tail = match[3].trim()
// Why: on unknown distros the relay emits prose like "install ripgrep via
// your package manager (e.g. apt/dnf/pacman)"; there is no single command
// to copy, so surface it as plain guidance without the code block.
const looksLikeCommand = /^(sudo\s+)?(brew|apt|dnf|pacman|apk)\s/.test(tail)
return {
reason,
+ location,
command: looksLikeCommand ? tail : null,
guidance: looksLikeCommand ? null : tail
}
@@ -43,6 +51,7 @@ export function parseQuickOpenInstallRgGuidance(
export function QuickOpenInstallRgGuidance({
reason,
+ location,
command,
guidance
}: QuickOpenInstallRgGuidanceParts): React.JSX.Element {
@@ -112,10 +121,15 @@ export function QuickOpenInstallRgGuidance({
{translate('auto.components.QuickOpen.5d80dc39bb', 'ripgrep')}
{' '}
- {translate(
- 'auto.components.QuickOpen.1cf8561ab4',
- 'on the remote to enable fast, gitignore-aware listing:'
- )}
+ {location === 'remote'
+ ? translate(
+ 'auto.components.QuickOpen.1cf8561ab4',
+ 'on the remote to enable fast, gitignore-aware listing:'
+ )
+ : translate(
+ 'auto.components.QuickOpen.344f8a48dd',
+ 'on the host running the Quick Open scan to enable fast, gitignore-aware listing:'
+ )}
{command ? (
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 3d38c3bce5e..08df452db97 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -1486,7 +1486,8 @@
"4725b0e931": "Quick Open scan too large (",
"b227d88520": "{{value0}} files found",
"995be8ea22": "Copy",
- "cf144856dc": "Copied"
+ "cf144856dc": "Copied",
+ "344f8a48dd": "on the host running the Quick Open scan to enable fast, gitignore-aware listing:"
},
"SelectedTextCopyMenu": {
"9b40d7b018": "Copy"
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 52d0aa8a94c..b5ab19f412f 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -1463,7 +1463,8 @@
"4725b0e931": "El escaneo de apertura rápida es demasiado grande (",
"b227d88520": "{{value0}} archivos encontrados",
"995be8ea22": "Copiar",
- "cf144856dc": "Copiado"
+ "cf144856dc": "Copiado",
+ "344f8a48dd": "on the host running the Quick Open scan to enable fast, gitignore-aware listing:"
},
"SelectedTextCopyMenu": {
"9b40d7b018": "Copiar"
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 3f15273505e..611063cc033 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -1463,7 +1463,8 @@
"4725b0e931": "クイック オープン スキャンが大きすぎます (",
"b227d88520": "{{value0}} ファイルが見つかりました",
"995be8ea22": "コピー",
- "cf144856dc": "コピーされました"
+ "cf144856dc": "コピーされました",
+ "344f8a48dd": "on the host running the Quick Open scan to enable fast, gitignore-aware listing:"
},
"SelectedTextCopyMenu": {
"9b40d7b018": "コピー"
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index a45c819cce9..e0e9d3c85d7 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -1463,7 +1463,8 @@
"4725b0e931": "Quick Open 스캔이 너무 큼(",
"b227d88520": "{{value0}} 파일을 찾았습니다.",
"995be8ea22": "복사",
- "cf144856dc": "복사됨"
+ "cf144856dc": "복사됨",
+ "344f8a48dd": "on the host running the Quick Open scan to enable fast, gitignore-aware listing:"
},
"SelectedTextCopyMenu": {
"9b40d7b018": "복사"
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index b4a27eb0fd7..f0a69c15604 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -1463,7 +1463,8 @@
"4725b0e931": "快速打开扫描太大(",
"b227d88520": "找到 {{value0}} 文件",
"995be8ea22": "复制",
- "cf144856dc": "已复制"
+ "cf144856dc": "已复制",
+ "344f8a48dd": "on the host running the Quick Open scan to enable fast, gitignore-aware listing:"
},
"SelectedTextCopyMenu": {
"9b40d7b018": "复制"
diff --git a/src/shared/quick-open-install-rg.ts b/src/shared/quick-open-install-rg.ts
new file mode 100644
index 00000000000..5ba70bc8f0c
--- /dev/null
+++ b/src/shared/quick-open-install-rg.ts
@@ -0,0 +1,86 @@
+import { readFile } from 'node:fs/promises'
+import { getProcessOutputFields, iterateProcessOutputLines } from './process-output-field-scanner'
+
+const GENERIC_LINUX_RIPGREP_INSTALL =
+ 'install ripgrep via your package manager (e.g. apt/dnf/pacman)'
+const OS_RELEASE_ID_LIKE_MAX_FIELDS = 16
+
+export async function detectInstallCommand(): Promise
{
+ if (process.platform === 'darwin') {
+ return 'brew install ripgrep'
+ }
+ if (process.platform === 'linux') {
+ try {
+ const osRelease = await readFile('/etc/os-release', 'utf-8')
+ return detectLinuxInstallCommandFromOsRelease(osRelease)
+ } catch {
+ /* fall through to generic guidance */
+ }
+ return GENERIC_LINUX_RIPGREP_INSTALL
+ }
+ return 'install ripgrep (https://github.com/BurntSushi/ripgrep#installation)'
+}
+
+export function detectLinuxInstallCommandFromOsRelease(osRelease: string): string {
+ for (const id of getOsReleasePackageFamilyIds(osRelease)) {
+ if (id === 'debian' || id === 'ubuntu') {
+ return 'sudo apt install ripgrep'
+ }
+ if (id === 'fedora' || id === 'rhel' || id === 'centos') {
+ return 'sudo dnf install ripgrep'
+ }
+ if (id === 'arch') {
+ return 'sudo pacman -S ripgrep'
+ }
+ if (id === 'alpine') {
+ return 'sudo apk add ripgrep'
+ }
+ }
+
+ return GENERIC_LINUX_RIPGREP_INSTALL
+}
+
+function getOsReleasePackageFamilyIds(osRelease: string): string[] {
+ const ids: string[] = []
+
+ for (const line of iterateProcessOutputLines(osRelease)) {
+ const separatorIndex = line.indexOf('=')
+ if (separatorIndex <= 0) {
+ continue
+ }
+
+ const key = line.slice(0, separatorIndex)
+ const value = readOsReleaseValue(line.slice(separatorIndex + 1))
+ if (key === 'ID') {
+ const id = getProcessOutputFields(value, 1)[0]
+ if (id) {
+ ids.push(id)
+ }
+ } else if (key === 'ID_LIKE') {
+ ids.push(...getProcessOutputFields(value, OS_RELEASE_ID_LIKE_MAX_FIELDS))
+ }
+ }
+
+ return ids
+}
+
+function readOsReleaseValue(rawValue: string): string {
+ const trimmed = rawValue.trim()
+ const quote = trimmed[0]
+ return (quote === '"' || quote === "'") && trimmed.at(-1) === quote
+ ? trimmed.slice(1, -1)
+ : trimmed
+}
+
+export async function buildInstallRgMessage(
+ cause: unknown,
+ host: 'local' | 'remote' = 'local'
+): Promise {
+ const reason = cause instanceof Error ? cause.message : String(cause)
+ const cmd = await detectInstallCommand()
+ const location = host === 'local' ? 'on the host running the Quick Open scan' : 'on the remote'
+ return (
+ `Quick Open scan too large (${reason}). ` +
+ `Install ripgrep ${location} to enable fast, gitignore-aware listing: ${cmd}`
+ )
+}
From 1648251fb82fed419bb52231177de91a2b45a185 Mon Sep 17 00:00:00 2001
From: Wooseong Kim <2222333+innocarpe@users.noreply.github.com>
Date: Thu, 23 Jul 2026 17:04:45 +0900
Subject: [PATCH 02/32] fix(terminal): restore a dark-background contrast floor
(#10108)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(terminal): restore a dark-background contrast floor
Fully disabling xterm minimumContrastRatio on dark backgrounds (#9599)
left near-background body text unreadable — Antigravity paints #262b30
on #1e242a (~1.1:1). Keep light backgrounds at WCAG-AA 4.5 and use a
milder dark floor (3) so dark-on-dark body text is lifted without the
full light-bg correction strength.
Fixes #10104
* fix(terminal): extend dark-bg contrast floor to preview + mobile terminals
The dark-background minimumContrastRatio floor (#10104) is applied per
`new Terminal()` construction site. Beyond the live pane, agent output also
renders in the dashboard popout preview and the mobile WebView, which were
still at the floor-1 default, so Antigravity output stayed unreadable there.
- AgentTerminalPreview: gate via resolveTerminalMinimumContrastRatio
- mobile WebView: port the gate as resolveTerminalContrastFloor (Chrome-74 JS)
- tests: builtin-catalog guard + mobile vm-harness coverage
Co-authored-by: Orca
---------
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca
---
mobile/src/terminal/terminal-webview-html.ts | 2 +
.../terminal-webview-theme-injected.test.ts | 79 +++++++++++++++
.../terminal-webview-theme-injected.ts | 84 +++++++++++++++-
.../dashboard-popout/AgentTerminalPreview.tsx | 14 ++-
.../terminal-pane/terminal-appearance.test.ts | 12 ++-
.../lib/terminal-contrast-correction.test.ts | 96 +++++++++++++++++++
.../src/lib/terminal-contrast-correction.ts | 13 ++-
7 files changed, 286 insertions(+), 14 deletions(-)
create mode 100644 mobile/src/terminal/terminal-webview-theme-injected.test.ts
create mode 100644 src/renderer/src/lib/terminal-contrast-correction.test.ts
diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts
index a4d9c350c47..0b7eaa9149d 100644
--- a/mobile/src/terminal/terminal-webview-html.ts
+++ b/mobile/src/terminal/terminal-webview-html.ts
@@ -290,6 +290,7 @@ window.onerror = function(msg) {
var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_THEME)};
var terminalThemeInput = null;
var terminalTheme = defaultTheme;
+ var terminalMinimumContrastRatio = 3;
var webglAddon = null;
var webglRecoveryTimer = null;
var activeAltScreenSnapshot = false;
@@ -714,6 +715,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
cols: cols || 80,
rows: rows || 24,
theme: terminalTheme,
+ minimumContrastRatio: terminalMinimumContrastRatio,
fontFamily: terminalFontFamily,
fontSize: fontPxForScale(currentTextScale),
fontWeight: '300',
diff --git a/mobile/src/terminal/terminal-webview-theme-injected.test.ts b/mobile/src/terminal/terminal-webview-theme-injected.test.ts
new file mode 100644
index 00000000000..92e4127b2fc
--- /dev/null
+++ b/mobile/src/terminal/terminal-webview-theme-injected.test.ts
@@ -0,0 +1,79 @@
+import { Script } from 'node:vm'
+import { parse } from 'acorn'
+import { describe, expect, it } from 'vitest'
+import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected'
+
+const DARK_FLOOR = 3
+const LIGHT_FLOOR = 4.5
+
+// Eval the injected theme JS in a bare context so the declared helpers become
+// callable properties on it (mirrors terminal-webview-engine.test.ts).
+function loadThemeInjected(extra: Record = {}): Record {
+ const context: Record = {
+ defaultTheme: { background: '#1a1b26', foreground: '#c0caf5' },
+ ...extra
+ }
+ new Script(TERMINAL_WEBVIEW_THEME_JS).runInNewContext(context)
+ return context
+}
+
+describe('mobile terminal-webview contrast floor gate', () => {
+ it('parses at the Chrome 74 syntax floor', () => {
+ expect(() => parse(TERMINAL_WEBVIEW_THEME_JS, { ecmaVersion: 2019 })).not.toThrow()
+ })
+
+ it('picks the dark floor for dark composed backgrounds', () => {
+ const { resolveTerminalContrastFloor } = loadThemeInjected() as {
+ resolveTerminalContrastFloor: (bg: unknown) => number
+ }
+ for (const bg of ['#1a1b26', '#1e242a', '#282828', '#000000', 'black']) {
+ expect(resolveTerminalContrastFloor(bg)).toBe(DARK_FLOOR)
+ }
+ })
+
+ it('picks the light floor for light composed backgrounds', () => {
+ const { resolveTerminalContrastFloor } = loadThemeInjected() as {
+ resolveTerminalContrastFloor: (bg: unknown) => number
+ }
+ for (const bg of ['#ffffff', '#fbf1c7', 'white', 'rgb(240 240 240)']) {
+ expect(resolveTerminalContrastFloor(bg)).toBe(LIGHT_FLOOR)
+ }
+ })
+
+ it('composites transparency over the dark app surface before deciding', () => {
+ const { resolveTerminalContrastFloor } = loadThemeInjected() as {
+ resolveTerminalContrastFloor: (bg: unknown) => number
+ }
+ // Fully transparent → app surface (dark) → dark floor.
+ expect(resolveTerminalContrastFloor('transparent')).toBe(DARK_FLOOR)
+ // Faint white over the dark surface stays dark; opaque-enough white flips light.
+ expect(resolveTerminalContrastFloor('rgba(255,255,255,0.15)')).toBe(DARK_FLOOR)
+ expect(resolveTerminalContrastFloor('rgba(255,255,255,0.9)')).toBe(LIGHT_FLOOR)
+ })
+
+ it('defaults unparseable backgrounds to the dark floor so output never stays invisible', () => {
+ const { resolveTerminalContrastFloor } = loadThemeInjected() as {
+ resolveTerminalContrastFloor: (bg: unknown) => number
+ }
+ for (const bg of [undefined, null, '', 'not-a-color', '#12', 42]) {
+ expect(resolveTerminalContrastFloor(bg)).toBe(DARK_FLOOR)
+ }
+ })
+
+ it('writes the resolved floor onto a live terminal when the theme changes', () => {
+ const term = { options: { theme: undefined as unknown, minimumContrastRatio: 1 } }
+ const context = loadThemeInjected({
+ term,
+ document: {
+ documentElement: { style: { background: '' } },
+ body: { style: { background: '' } }
+ }
+ }) as Record & { applyTerminalTheme: (input: unknown) => void }
+
+ context.applyTerminalTheme({ theme: { background: '#ffffff' } })
+ expect(term.options.minimumContrastRatio).toBe(LIGHT_FLOOR)
+
+ context.applyTerminalTheme({ theme: { background: '#1e242a' } })
+ expect(term.options.minimumContrastRatio).toBe(DARK_FLOOR)
+ })
+})
diff --git a/mobile/src/terminal/terminal-webview-theme-injected.ts b/mobile/src/terminal/terminal-webview-theme-injected.ts
index 1f798fe337e..c2d9beb4786 100644
--- a/mobile/src/terminal/terminal-webview-theme-injected.ts
+++ b/mobile/src/terminal/terminal-webview-theme-injected.ts
@@ -1,7 +1,85 @@
import { colors } from '../theme/mobile-theme'
// Theme normalization and page-surface painting injected into the WebView IIFE.
+// Mirrors the desktop minimumContrastRatio gate (src/renderer/src/lib/terminal-contrast-correction.ts,
+// #7934/#10104): a dark composed background gets a mild floor of 3 to rescue near-background body text
+// (e.g. Antigravity's #262b30 on #1e242a) without over-brightening vibrant ANSI colors; a light
+// background keeps the WCAG-AA 4.5 floor. Gate on the composed background luminance, not app mode,
+// because either theme slot can hold either kind of theme.
export const TERMINAL_WEBVIEW_THEME_JS = `
+ var DARK_BG_MIN_CONTRAST = 3;
+ var LIGHT_BG_MIN_CONTRAST = 4.5;
+ // Dark app surface a transparent terminal background composites over (matches desktop APP_SURFACE_COLORS.dark).
+ var CONTRAST_APP_SURFACE = { r: 10, g: 10, b: 10 };
+
+ function parseTerminalBackgroundRgba(value) {
+ if (typeof value !== 'string') return null;
+ var v = value.trim().toLowerCase();
+ if (!v) return null;
+ if (v === 'black') return { r: 0, g: 0, b: 0, a: 1 };
+ if (v === 'white') return { r: 255, g: 255, b: 255, a: 1 };
+ if (v === 'transparent') return { r: 0, g: 0, b: 0, a: 0 };
+ var hex = v.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
+ if (hex) {
+ var h = hex[1];
+ var ch;
+ if (h.length === 3 || h.length === 4) {
+ ch = h.split('').map(function (p) { return parseInt(p + p, 16); });
+ } else {
+ ch = [];
+ for (var i = 0; i < h.length; i += 2) ch.push(parseInt(h.slice(i, i + 2), 16));
+ }
+ return { r: ch[0], g: ch[1], b: ch[2], a: ch[3] === undefined ? 1 : ch[3] / 255 };
+ }
+ var rgb = v.match(/^rgba?\\(([^)]+)\\)$/);
+ if (!rgb) return null;
+ var parts = rgb[1].indexOf(',') >= 0 ? rgb[1].split(',') : rgb[1].split(/[\\s/]+/);
+ parts = parts.map(function (p) { return p.trim(); }).filter(function (p) { return p.length > 0; });
+ if (parts.length < 3) return null;
+ var channel = function (p) {
+ var n = p.charAt(p.length - 1) === '%' ? (parseFloat(p) / 100) * 255 : parseFloat(p);
+ return isFinite(n) ? Math.min(255, Math.max(0, Math.round(n))) : null;
+ };
+ var r = channel(parts[0]), g = channel(parts[1]), b = channel(parts[2]);
+ if (r === null || g === null || b === null) return null;
+ var a = 1;
+ if (parts[3] !== undefined) {
+ var raw = parts[3].charAt(parts[3].length - 1) === '%' ? parseFloat(parts[3]) / 100 : parseFloat(parts[3]);
+ a = isFinite(raw) ? Math.min(1, Math.max(0, raw)) : 1;
+ }
+ return { r: r, g: g, b: b, a: a };
+ }
+
+ function terminalRelativeLuminance(rgb) {
+ var lin = function (c) {
+ var n = c / 255;
+ return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4);
+ };
+ return 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b);
+ }
+
+ function terminalContrastRatio(a, b) {
+ var la = terminalRelativeLuminance(a), lb = terminalRelativeLuminance(b);
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
+ }
+
+ // Pick the xterm minimumContrastRatio floor from the composed terminal background.
+ // Unparseable input defaults to the dark floor so agent output never stays invisible.
+ function resolveTerminalContrastFloor(background) {
+ var color = parseTerminalBackgroundRgba(background);
+ if (!color) return DARK_BG_MIN_CONTRAST;
+ var composited = color.a < 1
+ ? {
+ r: Math.round(color.r * color.a + CONTRAST_APP_SURFACE.r * (1 - color.a)),
+ g: Math.round(color.g * color.a + CONTRAST_APP_SURFACE.g * (1 - color.a)),
+ b: Math.round(color.b * color.a + CONTRAST_APP_SURFACE.b * (1 - color.a))
+ }
+ : color;
+ var isLight = terminalContrastRatio({ r: 0, g: 0, b: 0 }, composited) >=
+ terminalContrastRatio({ r: 255, g: 255, b: 255 }, composited);
+ return isLight ? LIGHT_BG_MIN_CONTRAST : DARK_BG_MIN_CONTRAST;
+ }
+
function normalizeTerminalTheme(input) {
var source = input && typeof input === 'object' && input.theme && typeof input.theme === 'object'
? input.theme
@@ -22,6 +100,10 @@ export const TERMINAL_WEBVIEW_THEME_JS = `
var background = terminalTheme.background || '${colors.terminalBg}';
document.documentElement.style.background = background;
document.body.style.background = background;
- if (term) term.options.theme = terminalTheme;
+ terminalMinimumContrastRatio = resolveTerminalContrastFloor(background);
+ if (term) {
+ term.options.theme = terminalTheme;
+ term.options.minimumContrastRatio = terminalMinimumContrastRatio;
+ }
}
`
diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx
index b6b82f0d133..4d01ce480c8 100644
--- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx
+++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx
@@ -20,6 +20,7 @@ import {
} from '@/components/terminal-pane/terminal-ime-native-text-forwarder'
import { getMacNativeTextInputSourceTracker } from '@/components/terminal-pane/terminal-ime-input-source'
import { composeActiveTerminalTheme } from '@/components/terminal-pane/terminal-appearance'
+import { resolveTerminalMinimumContrastRatio } from '@/lib/terminal-contrast-correction'
import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark'
import { translate } from '@/i18n/i18n'
import { getBuiltinTheme, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme'
@@ -53,15 +54,16 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
const containerRef = useRef(null)
const settings = useAppStore((state) => state.settings)
const systemPrefersDark = useSystemPrefersDark()
- const terminalTheme = useMemo(() => {
+ const { terminalTheme, terminalMode } = useMemo(() => {
if (!settings) {
- return null
+ return { terminalTheme: null, terminalMode: 'dark' as const }
}
const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
- return composeActiveTerminalTheme(
+ const theme = composeActiveTerminalTheme(
appearance.theme ?? getBuiltinTheme(appearance.themeName),
settings
)
+ return { terminalTheme: theme, terminalMode: appearance.mode }
}, [settings, systemPrefersDark])
// A null snapshot means no serializer knows this pty (it died or was never
// spawned this session) — say so instead of painting a silent blank terminal.
@@ -271,6 +273,10 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
cols: clamp(snap.cols ?? FALLBACK_COLS, 2, 500),
rows: clamp(snap.rows ?? FALLBACK_ROWS, 2, 200),
theme: terminalTheme ?? undefined,
+ minimumContrastRatio: resolveTerminalMinimumContrastRatio(
+ terminalTheme?.background,
+ terminalMode
+ ),
scrollback: 1000
})
try {
@@ -399,7 +405,7 @@ export function AgentTerminalPreview({ ptyId }: { ptyId: string }): React.JSX.El
void window.api.terminalPreview.unsubscribe(ptyId)
terminal?.dispose()
}
- }, [ptyId, terminalTheme])
+ }, [ptyId, terminalTheme, terminalMode])
return (
// Why: a size FIXED by the viewport (not shrink-to-fit) + overflow-hidden
diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts
index fdf3da8e271..8a0e8842a32 100644
--- a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts
+++ b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts
@@ -417,13 +417,15 @@ describe('applyTerminalAppearance theme assignment', () => {
expect(pane.terminal.options.minimumContrastRatio).toBe(4.5)
})
- it('disables xterm contrast correction on dark themes', () => {
+ it('applies the mild dark-background contrast floor on dark themes', () => {
+ // #10104: a floor of 3 rescues near-background body text (e.g. Antigravity's #262b30 on #1e242a)
+ // without the 4.5-floor over-brightening of vibrant ANSI colors that #7934 fixed.
const pane = makePane(1)
const settings = getDefaultSettings('/tmp')
apply(pane, { ...settings, theme: 'dark' })
- expect(pane.terminal.options.minimumContrastRatio).toBe(1)
+ expect(pane.terminal.options.minimumContrastRatio).toBe(3)
})
it('re-gates contrast correction when the theme flips live', () => {
@@ -434,17 +436,17 @@ describe('applyTerminalAppearance theme assignment', () => {
expect(pane.terminal.options.minimumContrastRatio).toBe(4.5)
apply(pane, { ...settings, theme: 'dark' })
- expect(pane.terminal.options.minimumContrastRatio).toBe(1)
+ expect(pane.terminal.options.minimumContrastRatio).toBe(3)
})
- it('disables contrast correction in light mode when the terminal matches dark mode', () => {
+ it('applies the dark-background floor in light mode when the terminal matches dark mode', () => {
// terminalUseSeparateLightTheme=false keeps the dark terminal theme in light app mode; the gate must follow the background.
const pane = makePane(1)
const settings = getDefaultSettings('/tmp')
apply(pane, { ...settings, theme: 'light', terminalUseSeparateLightTheme: false })
- expect(pane.terminal.options.minimumContrastRatio).toBe(1)
+ expect(pane.terminal.options.minimumContrastRatio).toBe(3)
})
it('keeps contrast correction in dark mode when a light theme fills the dark slot', () => {
diff --git a/src/renderer/src/lib/terminal-contrast-correction.test.ts b/src/renderer/src/lib/terminal-contrast-correction.test.ts
new file mode 100644
index 00000000000..7f5cd76cbfe
--- /dev/null
+++ b/src/renderer/src/lib/terminal-contrast-correction.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from 'vitest'
+import {
+ DARK_BG_MIN_CONTRAST,
+ LIGHT_BG_MIN_CONTRAST,
+ resolveTerminalMinimumContrastRatio
+} from './terminal-contrast-correction'
+import { TERMINAL_THEME_CATALOG } from './terminal-themes'
+
+// WCAG relative-luminance contrast ratio, matching xterm's minimumContrastRatio gate.
+function contrastRatio(a: string, b: string): number {
+ const lum = (hex: string): number => {
+ const n = Number.parseInt(hex.replace('#', ''), 16)
+ const toLinear = (channel: number): number => {
+ const c = channel / 255
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
+ }
+ const r = toLinear((n >> 16) & 0xff)
+ const g = toLinear((n >> 8) & 0xff)
+ const bl = toLinear(n & 0xff)
+ return 0.2126 * r + 0.7152 * g + 0.0722 * bl
+ }
+ const la = lum(a)
+ const lb = lum(b)
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05)
+}
+
+describe('resolveTerminalMinimumContrastRatio', () => {
+ it('returns the light-background floor for a light terminal background', () => {
+ expect(resolveTerminalMinimumContrastRatio('#ffffff', 'light')).toBe(LIGHT_BG_MIN_CONTRAST)
+ })
+
+ it('returns the dark-background floor for a dark terminal background', () => {
+ expect(resolveTerminalMinimumContrastRatio('#1e242a', 'dark')).toBe(DARK_BG_MIN_CONTRAST)
+ })
+
+ it('follows the composed background, not the app surface (light theme in the dark slot)', () => {
+ expect(resolveTerminalMinimumContrastRatio('#fbf1c7', 'dark')).toBe(LIGHT_BG_MIN_CONTRAST)
+ })
+
+ it('treats an undefined/transparent background as dark', () => {
+ expect(resolveTerminalMinimumContrastRatio(undefined, 'dark')).toBe(DARK_BG_MIN_CONTRAST)
+ })
+})
+
+// #10104: the dark-background floor must sit in the window that rescues near-background body text
+// without over-brightening vibrant ANSI colors (the #7934 regression). Guarding both edges keeps a
+// future tweak from silently sliding out of that window.
+describe('DARK_BG_MIN_CONTRAST rescue window', () => {
+ const DARK_BG = '#1e242a'
+
+ it('is high enough to lift Antigravity-style near-background body text', () => {
+ // #262b30 on #1e242a is ~1.1:1 — invisible at floor 1. The floor must exceed it so xterm corrects it.
+ expect(contrastRatio(DARK_BG, '#262b30')).toBeLessThan(DARK_BG_MIN_CONTRAST)
+ })
+
+ it('stays below the contrast that saturated ANSI colors naturally reach on a dark background', () => {
+ // Normal red/blue/magenta sit at ~3.0-3.4:1 here; the floor must not exceed them or xterm would
+ // wash them toward white — exactly the over-brightening #7934 disabled the 4.5 floor to avoid.
+ for (const ansi of ['#cd3131', '#2472c8', '#bc3fbc']) {
+ expect(contrastRatio(DARK_BG, ansi)).toBeGreaterThanOrEqual(DARK_BG_MIN_CONTRAST)
+ }
+ })
+})
+
+// #10104: pin which real builtin dark themes have normal ANSI colors below the floor, so a new theme
+// or floor tweak forces an explicit decision instead of a silent #7934-style regression.
+describe('DARK_BG_MIN_CONTRAST vs the builtin theme catalog', () => {
+ // Normal (non-bright) chromatic ANSI channels — the vibrant body-text colors #7934 protects.
+ // Bright variants are excluded: several themes (e.g. Solarized) repurpose them as achromatic grays.
+ const CHROMATIC_ANSI = ['red', 'green', 'yellow', 'blue', 'magenta', 'cyan'] as const
+
+ // Accepted below-floor cases: near-illegible primaries on very dark backgrounds where the mild
+ // lift helps rather than harms. Keep in sync with the comment in terminal-contrast-correction.ts.
+ const ACCEPTED_BELOW_FLOOR = ['Gruvbox Dark:red', 'Homebrew:blue', 'Homebrew:red']
+
+ it('leaves every dark-theme chromatic ANSI color at/above the floor, except the pinned exceptions', () => {
+ const belowFloor: string[] = []
+ for (const [name, theme] of Object.entries(TERMINAL_THEME_CATALOG)) {
+ const background = theme.background
+ // Only dark-slot themes get the dark floor; the resolver picks it exactly for those.
+ if (
+ !background ||
+ resolveTerminalMinimumContrastRatio(background, 'dark') !== DARK_BG_MIN_CONTRAST
+ ) {
+ continue
+ }
+ for (const channel of CHROMATIC_ANSI) {
+ const color = theme[channel]
+ if (color && contrastRatio(background, color) < DARK_BG_MIN_CONTRAST) {
+ belowFloor.push(`${name}:${channel}`)
+ }
+ }
+ }
+ expect(belowFloor.sort()).toEqual(ACCEPTED_BELOW_FLOOR)
+ })
+})
diff --git a/src/renderer/src/lib/terminal-contrast-correction.ts b/src/renderer/src/lib/terminal-contrast-correction.ts
index 4cd147af812..4a4e9ac3cb6 100644
--- a/src/renderer/src/lib/terminal-contrast-correction.ts
+++ b/src/renderer/src/lib/terminal-contrast-correction.ts
@@ -1,10 +1,15 @@
import { isTerminalBackgroundLight } from '@/lib/terminal-title-contrast'
-// xterm minimumContrastRatio tuning (#7934). Light backgrounds keep WCAG-AA correction so invisible
-// white/bright-white ANSI body text stays readable; dark backgrounds disable it (ratio 1) because
-// correction over-brightens vibrant ANSI colors.
+// xterm minimumContrastRatio tuning (#7934, #9599, #10104). Light backgrounds keep WCAG-AA correction so
+// invisible white/bright-white ANSI body text stays readable. Dark backgrounds use a mild floor of 3
+// (WCAG-AA large-text): high enough to rescue near-background body text — e.g. Antigravity's #262b30
+// on #1e242a (~1.1:1) — while staying far milder than the light-background 4.5 that badly
+// over-brightened vibrant colors (#7934). On most dark themes saturated ANSI colors already clear 3:1
+// and are untouched; a few (e.g. Homebrew red/blue on pure black, Gruvbox Dark red) sit below 3:1 and
+// get mildly lifted — accepted because those were already near-illegible, so the nudge helps rather
+// than harms (see the builtin-catalog exceptions pinned in terminal-contrast-correction.test.ts).
export const LIGHT_BG_MIN_CONTRAST = 4.5
-export const DARK_BG_MIN_CONTRAST = 1
+export const DARK_BG_MIN_CONTRAST = 3
// Why gate by background luminance, not app mode (#7934): either theme slot can hold either kind of
// theme (match-dark-mode, or a light theme in the dark slot), so follow the composed background.
From 7ab601487c2e4c05b7067a5157e395300a0bdc0c Mon Sep 17 00:00:00 2001
From: Mark Xian
Date: Thu, 23 Jul 2026 16:45:24 +0800
Subject: [PATCH 03/32] fix(remote): don't classify a stale/gone remote handle
as agent completion (#9263)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
---
config/reliability-gates.jsonc | 115 ++++++++--
...n-foreground-confirmation-protocol.test.ts | 3 +-
.../daemon-foreground-process-protocol.ts | 4 +
.../daemon/daemon-protocol-version.test.ts | 8 +-
src/main/daemon/daemon-protocol-version.ts | 5 +-
src/main/daemon/daemon-pty-adapter.ts | 13 ++
src/main/daemon/daemon-pty-router.test.ts | 10 +
src/main/daemon/daemon-pty-router.ts | 17 ++
src/main/daemon/daemon-server.ts | 3 +
.../degraded-daemon-pty-provider.test.ts | 10 +
.../daemon/degraded-daemon-pty-provider.ts | 19 +-
src/main/daemon/terminal-host.test.ts | 3 +
src/main/daemon/terminal-host.ts | 12 ++
src/main/daemon/types.ts | 8 +-
src/main/ipc/pty.ts | 7 +
.../providers/pty-process-inspection.test.ts | 41 ++++
src/main/providers/pty-process-inspection.ts | 26 +++
src/main/providers/ssh-pty-provider.ts | 8 +
src/main/runtime/orca-runtime.test.ts | 23 ++
src/main/runtime/orca-runtime.ts | 13 +-
src/preload/api-types.ts | 3 +
src/preload/index.ts | 4 +
src/relay/pty-handler.test.ts | 7 +
src/relay/pty-handler.ts | 20 ++
.../agent-completion-coordinator.ts | 9 +
.../terminal-pane/pty-connection.test.ts | 7 +
.../src/lib/codex-session-restart.test.ts | 71 ++++--
src/renderer/src/lib/codex-session-restart.ts | 4 +-
.../runtime-terminal-inspection.test.ts | 41 ++--
.../runtime/runtime-terminal-inspection.ts | 11 +-
src/renderer/src/web/web-preload-api.ts | 1 +
...te-agent-completion-authority.unit.test.ts | 202 ++++++++++++++++++
32 files changed, 658 insertions(+), 70 deletions(-)
create mode 100644 src/main/providers/pty-process-inspection.test.ts
create mode 100644 src/main/providers/pty-process-inspection.ts
create mode 100644 tests/e2e/remote-agent-completion-authority.unit.test.ts
diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc
index fad227b81fd..7ec61073b60 100644
--- a/config/reliability-gates.jsonc
+++ b/config/reliability-gates.jsonc
@@ -1390,6 +1390,7 @@
"headed desktop remote-server pairing",
"headless remote-server parity",
"daemon and relay reconnect",
+ "remote completion classification across disconnect and reconnect",
"terminal exit retirement and restart restore",
"mixed-version fallback"
],
@@ -1415,21 +1416,36 @@
"wsl",
"remote-runtime"
],
- "coverageNotes": "Deterministic macOS tests cover controller claims, daemon and SSH/relay operation replay, mixed-version selection, runtime ownership, exact provisional handoff, durable terminal retirement, two independent viewer mirrors, and guarded adoption of legacy live PTYs. The adoption harness models v1.4.150 agent/setup/shell tabs, current-generation restart and reconnect, exact handle/incarnation/worktree/host checks, topology CAS, competing clients, split-pane/group restoration, WSL ownership, and SSH owner rejection. The secondary parity repro runs independent clients against one headless remote Orca runtime over encrypted pairing and a real daemon-backed PTY. A headed Orca desktop server paired to a separate client is the primary live user topology and remains uncollected for this issue. SSH coverage is provider/relay contract and fault-injection coverage only; it does not substitute for paired-server coverage. Live Windows, WSL, and SSH hosts remain gaps.",
+ "coverageNotes": "Deterministic macOS tests cover controller claims, daemon and SSH/relay operation replay, mixed-version selection, runtime ownership, exact provisional handoff, durable terminal retirement, two independent viewer mirrors, guarded adoption of legacy live PTYs, and completion classification when either the outer remote transport or authoritative host/provider process inspection becomes unreachable. The adoption harness models v1.4.150 agent/setup/shell tabs, current-generation restart and reconnect, exact handle/incarnation/worktree/host checks, topology CAS, competing clients, split-pane/group restoration, WSL ownership, and SSH owner rejection. The secondary parity repro runs independent clients against one headless remote Orca runtime over encrypted pairing and a real daemon-backed PTY. The primary headed topology was exercised with an isolated macOS Orca desktop server, a separate paired Edge client, a real Codex process, injected WebSocket loss, reconnect, and explicit stop; host inspection and visible client state agreed throughout. SSH coverage is provider/relay contract and fault-injection coverage only; it does not substitute for paired-server coverage. Live Windows, Linux, WSL, and SSH hosts remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/8878",
+ "https://github.com/stablyai/orca/issues/9151",
"https://github.com/stablyai/orca/issues/9352",
"https://github.com/stablyai/orca/pull/9687"
],
- "invariant": "For every claim-capable execution route, one provider-session identity has at most one live PTY owner and one canonical host surface across concurrent clients, retries, reconnects, and stale publications. A live orphan may be adopted only when the controller proves its exact handle and incarnation, its worktree and host owner match, no competing visual owner exists, and a host topology CAS wins. A physical exit retires that exact incarnation durably so stale client state and host restart cannot recreate it. Mixed-version routes select the unchanged legacy request before any authority side effect or execution-owner-local filesystem access.",
- "oracle": "Race independent clients and repeated operation IDs, then assert one physical spawn and one canonical PTY/surface; inject exit-before-reply, provider disconnect, conflicting claim scope, old daemon/relay capabilities, reused handles, stale incarnations, owner mismatch, and topology revision conflict; assert safe adoption or explicit failure without a second spawn or wrong-process attachment. Restore legacy split panes and groups beside a newer host-owned tab, preserving output, input, resize, titles, tab/leaf identity, active group, and multi-client convergence. After exact exit, assert terminal and tab listings omit the surface, a stale publication cannot restore it, restart cannot resurrect it, and an exact provisional handoff is consumed even when exit wins before the next snapshot.",
+ "invariant": "For every claim-capable execution route, one provider-session identity has at most one live PTY owner and one canonical host surface across concurrent clients, retries, reconnects, and stale publications. A live orphan may be adopted only when the controller proves its exact handle and incarnation, its worktree and host owner match, no competing visual owner exists, and a host topology CAS wins. A viewer may classify completion only from successful host/provider inspection or explicit lifecycle evidence; transport, handle, or provider unavailability remains unknown and breaks any consecutive-idle proof. A physical exit retires that exact incarnation durably so stale client state and host restart cannot recreate it. Mixed-version routes select the unchanged legacy request before any authority side effect or execution-owner-local filesystem access.",
+ "oracle": "Race independent clients and repeated operation IDs, then assert one physical spawn and one canonical PTY/surface; inject exit-before-reply, provider disconnect, conflicting claim scope, old daemon/relay capabilities, reused handles, stale incarnations, owner mismatch, and topology revision conflict; assert safe adoption or explicit failure without a second spawn or wrong-process attachment. Restore legacy split panes and groups beside a newer host-owned tab, preserving output, input, resize, titles, tab/leaf identity, active group, and multi-client convergence. For completion, drive a known running agent through outer transport loss, authoritative provider rejection, reconnect, explicit stop, real exit status, and successful hook completion; assert unavailable evidence never dispatches completion and two fresh authoritative idle samples are required after the gap. After exact exit, assert terminal and tab listings omit the surface, a stale publication cannot restore it, restart cannot resurrect it, and an exact provisional handoff is consumed even when exit wins before the next snapshot.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/shared/claimed-agent-pty-owner.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/runtime/orca-runtime-agent-session-operation.test.ts src/main/runtime/remote-agent-session-host-authority.integration.test.ts src/main/runtime/orca-runtime-terminal-retirement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-terminal-tab-retirement.unit.test.ts",
"pnpm test:repro:remote-agent-session",
- "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-orphan-owner.test.ts src/main/runtime/terminal-orphan-topology.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --maxWorkers=1"
+ "Manual headed paired-server journey: isolated Orca desktop host + separate paired web client + real Codex process + 20-second WebSocket fault + reconnect + explicit stop",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-orphan-owner.test.ts src/main/runtime/terminal-orphan-topology.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --maxWorkers=1",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/runtime/orca-runtime.test.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/lib/codex-session-restart.test.ts"
],
"testFiles": [
+ "src/main/providers/pty-process-inspection.test.ts",
+ "src/main/daemon/terminal-host.test.ts",
+ "src/main/daemon/daemon-pty-router.test.ts",
+ "src/main/daemon/degraded-daemon-pty-provider.test.ts",
+ "src/relay/pty-handler.test.ts",
+ "src/main/runtime/orca-runtime.test.ts",
+ "tests/e2e/remote-agent-completion-authority.unit.test.ts",
+ "src/renderer/src/runtime/runtime-terminal-inspection.test.ts",
+ "src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts",
+ "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
+ "src/renderer/src/lib/codex-session-restart.test.ts",
"src/shared/claimed-agent-pty-owner.test.ts",
"src/main/daemon/daemon-pty-adapter.test.ts",
"src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts",
@@ -1448,6 +1464,62 @@
"src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts"
],
"assertionRefs": [
+ {
+ "file": "src/main/runtime/orca-runtime.test.ts",
+ "assertions": [
+ "completion-sensitive process inspection preserves authoritative host/provider failures"
+ ]
+ },
+ {
+ "file": "src/main/providers/pty-process-inspection.test.ts",
+ "assertions": [
+ "dedicated provider inspection preserves failures and rejects missing PTYs instead of returning idle evidence"
+ ]
+ },
+ {
+ "file": "src/main/daemon/daemon-pty-router.test.ts",
+ "assertions": [
+ "completion inspection rejects an unmapped session instead of borrowing the current daemon"
+ ]
+ },
+ {
+ "file": "src/main/daemon/degraded-daemon-pty-provider.test.ts",
+ "assertions": [
+ "completion inspection rejects an unmapped session instead of borrowing the local fallback"
+ ]
+ },
+ {
+ "file": "src/relay/pty-handler.test.ts",
+ "assertions": [
+ "strict relay inspection rejects a missing PTY"
+ ]
+ },
+ {
+ "file": "tests/e2e/remote-agent-completion-authority.unit.test.ts",
+ "assertions": [
+ "transport loss remains unknown through reconnect and cannot dispatch completion",
+ "returned unavailability or a thrown transport failure interrupts consecutive-idle proof and requires two fresh authoritative idle samples",
+ "explicit stop, real exit status, and genuine successful completion remain distinct"
+ ]
+ },
+ {
+ "file": "src/renderer/src/runtime/runtime-terminal-inspection.test.ts",
+ "assertions": [
+ "direct SSH terminals use strict main-process inspection rather than lax split IPC evidence"
+ ]
+ },
+ {
+ "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
+ "assertions": [
+ "completion polling uses the atomic process-inspection boundary without regressing established lifecycle behavior"
+ ]
+ },
+ {
+ "file": "src/renderer/src/lib/codex-session-restart.test.ts",
+ "assertions": [
+ "one unreachable pane cannot suppress restart notices for another authoritatively confirmed Codex pane"
+ ]
+ },
{
"file": "src/shared/claimed-agent-pty-owner.test.ts",
"assertions": [
@@ -1532,6 +1604,24 @@
}
],
"evidenceRuns": [
+ {
+ "date": "2026-07-22",
+ "runner": "manual",
+ "platform": "macos",
+ "command": "Manual headed paired-server journey: isolated Orca desktop host + separate paired web client + real Codex process + 20-second WebSocket fault + reconnect + explicit stop",
+ "result": "passed",
+ "durationSeconds": 549,
+ "summary": "The primary user topology used an isolated headed Orca desktop as the owning server and a separate paired Edge client. Host inspection reported Codex alive before, during, and after a page-scoped WebSocket fault; the client showed no completion toast, reconnected to the same live Codex TUI, and explicit stop restored the shell prompt with no child process."
+ },
+ {
+ "date": "2026-07-23",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/lib/codex-session-restart.test.ts",
+ "result": "passed",
+ "durationSeconds": 11.96,
+ "summary": "Eleven focused files and 870 tests passed on the current-main candidate. The cross-boundary harness fails with the implementation reverted by dispatching process-exit from unavailable remote evidence. Direct SSH uses strict main-process inspection, daemon and relay inspection reject missing or unmapped sessions, the terminal lifecycle suite uses the atomic inspection boundary, and one stale pane cannot suppress restart notices for a separately confirmed Codex pane."
+ },
{
"date": "2026-07-21",
"runner": "local",
@@ -1542,13 +1632,13 @@
"summary": "Ten focused files and 325 tests passed after the final review fixes, covering claim scope, mixed-version Pi/SSH fallback ordering, operation replay, terminal retirement, causal inventory fencing, exact concurrent handoff confirmation, daemon-generation integration, transport behavior, and remote host integration."
},
{
- "date": "2026-07-21",
+ "date": "2026-07-22",
"runner": "local",
"platform": "macos",
"command": "pnpm test:repro:remote-agent-session",
"result": "passed",
- "durationSeconds": 48.47,
- "summary": "The secondary build-backed headless parity harness passed over encrypted WebSocket pairing with independent clients, proving one spawn, retry adoption, durable exit retirement, stale-publication rejection, and no restart resurrection."
+ "durationSeconds": 48.63,
+ "summary": "The secondary build-backed headless parity harness passed post-rebase on main@72a2d7bc7 over encrypted WebSocket pairing with independent clients, proving one spawn, retry adoption, durable exit retirement, stale-publication rejection, and no restart resurrection."
},
{
"date": "2026-07-22",
@@ -1588,22 +1678,23 @@
},
"redGreenEvidence": {
"status": "partial",
- "evidence": "The exact cross-boundary retirement oracle is red on pre-#9687 commit 2a32c5c9a and PR #9053 head d3a1d3047 because the pinned persisted surface remains in the host publication, and green on main@4fce2de49. Duplicate-resume red evidence remains encoded in lower-layer tests; saved CI artifacts are still needed."
+ "evidence": "Issue #9151 has local red/green evidence: current main dispatches process-exit from a stale remote handle, while the fix preserves unknown liveness and requires fresh consecutive idle evidence after reconnect. The exact retirement oracle is red on pre-#9687 commit 2a32c5c9a and PR #9053 head d3a1d3047 because the pinned persisted surface remains in the host publication, and green on main@4fce2de49. Duplicate-resume red evidence remains encoded in lower-layer tests; saved CI artifacts are still needed."
},
"performanceBudget": {
"required": true,
- "evidence": "Agent-session reconciliation runs only at explicit claim admission, dedupes concurrent provider listing, and adds no polling or renderer output work. Create-operation ledgers are capped globally and per client, expire after 24 hours, and reject rather than evict live replay fences. Capability caches are bounded or connection-scoped, and exact handoffs are consumed by the next authoritative snapshot."
+ "evidence": "Agent-session reconciliation runs only at explicit claim admission, dedupes concurrent provider listing, and adds no polling or renderer output work. Completion inspection reuses the coordinator's per-pane in-flight guard, global concurrency/rate queue, and existing error backoff; the strict daemon path reduces two foreground RPCs to one. Create-operation ledgers are capped globally and per client, expire after 24 hours, and reject rather than evict live replay fences. Capability caches are bounded or connection-scoped, and exact handoffs are consumed by the next authoritative snapshot."
},
"promotionCriteria": [
"Run the focused gate and remote-server repro for at least 100 consecutive passes or 14 days across required CI platforms.",
"Attach saved red/green evidence for duplicate remote resume and exit-before-snapshot retirement.",
- "Add the primary live journey with a headed Orca desktop server and a separate paired client; use a physical host when OS, ConPTY, update, sleep, firewall, or window lifecycle is causal.",
+ "Automate the headed Orca desktop-server journey with a separate paired client; add a physical host only when OS, ConPTY, update, sleep, firewall, or window lifecycle is causal.",
"Add live SSH/WSL provider evidence before claiming full provider coverage; Docker SSH proves only the SSH provider/relay path."
],
"knownGaps": [
- "The primary live topology—a headed Orca desktop server with an isolated profile paired to a separate persistent client—has not been run for this issue. The causal boundary is host membership rather than OS/window/ConPTY/update/sleep behavior, so a physical headed host was not required for the current disposition.",
+ "The primary headed macOS desktop-server journey is collected but not automated in CI; Windows and Linux window, ConPTY, update, sleep/wake, and firewall behavior remain uncollected.",
+ "Mixed-version pairings remain conservative only when the completion-aware client and strict-inspection host changes are both present; older peers retain their legacy classification behavior.",
"The secondary headless parity harness runs on macOS with a local daemon-backed execution owner and independent short-lived encrypted RPC clients; two persistent viewer-store mirrors and reconnect ordering are joined deterministically in the cross-boundary unit test rather than mounted live.",
- "SSH and relay failure ordering is deterministic provider-contract coverage, not a live SSH-host journey or paired-Orca-server proof; WSL has no provider-specific run.",
+ "SSH and relay failure ordering is deterministic provider-contract coverage, not a live SSH-host journey or paired-Orca-server proof; WSL has no provider-specific run, and Linux and Windows runs remain uncollected.",
"Fresh-launch operation replay is memory-backed and intentionally does not survive runtime restart; a durable operation journal is a documented future extension.",
"Automatic sleep checkpoints, verified nested-SSH execution namespaces, and multi-process profile coordination remain outside v1."
],
diff --git a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts
index 5e5e63e1104..f50a2d4e0ac 100644
--- a/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts
+++ b/src/main/daemon/daemon-foreground-confirmation-protocol.test.ts
@@ -3,11 +3,12 @@ import { PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION } from './types'
describe('foreground-confirmation daemon protocol', () => {
it('rejects daemons from before the fresh-confirmation RPC', () => {
- expect(PROTOCOL_VERSION).toBe(26)
+ expect(PROTOCOL_VERSION).toBe(27)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(19)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(22)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(23)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(24)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(25)
+ expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toContain(26)
})
})
diff --git a/src/main/daemon/daemon-foreground-process-protocol.ts b/src/main/daemon/daemon-foreground-process-protocol.ts
index 01b050c58d3..c0d672ec159 100644
--- a/src/main/daemon/daemon-foreground-process-protocol.ts
+++ b/src/main/daemon/daemon-foreground-process-protocol.ts
@@ -9,3 +9,7 @@ export type GetForegroundProcessRequest = {
export type ConfirmForegroundProcessRequest = Omit & {
type: 'confirmForegroundProcess'
}
+
+export type InspectProcessRequest = Omit & {
+ type: 'inspectProcess'
+}
diff --git a/src/main/daemon/daemon-protocol-version.test.ts b/src/main/daemon/daemon-protocol-version.test.ts
index 9fb4784b687..e557263697d 100644
--- a/src/main/daemon/daemon-protocol-version.test.ts
+++ b/src/main/daemon/daemon-protocol-version.test.ts
@@ -2,17 +2,19 @@ import { describe, expect, it } from 'vitest'
import {
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
+ COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
PROTOCOL_VERSION
} from './daemon-protocol-version'
describe('daemon protocol version', () => {
- it('ships claim and incarnation authority after startup-ingress generations', () => {
- expect(PROTOCOL_VERSION).toBe(26)
+ it('ships strict completion inspection after claim authority', () => {
+ expect(PROTOCOL_VERSION).toBe(27)
+ expect(COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION).toBe(27)
expect(AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION).toBe(26)
expect(PREVIOUS_DAEMON_PROTOCOL_VERSIONS).toEqual(
- Array.from({ length: 25 }, (_, index) => index + 1)
+ Array.from({ length: 26 }, (_, index) => index + 1)
)
})
})
diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts
index e8c88774f53..8afcfdf3e22 100644
--- a/src/main/daemon/daemon-protocol-version.ts
+++ b/src/main/daemon/daemon-protocol-version.ts
@@ -1,12 +1,13 @@
// Why: daemons survive app updates, so wire behavior must be version-gated.
-export const PROTOCOL_VERSION = 26
+export const PROTOCOL_VERSION = 27
+export const COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION = 27
export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 25
export const AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION = 26
export const AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION = 26
export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22
export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
- 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25
+ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26
] as const
export function supportsPtyStartupIngress(protocolVersion: number): boolean {
diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts
index b31c9c6c505..a9754a1c4b6 100644
--- a/src/main/daemon/daemon-pty-adapter.ts
+++ b/src/main/daemon/daemon-pty-adapter.ts
@@ -10,6 +10,7 @@ import { supportsPtyStartupBarrier } from './shell-ready'
import { CODEX_SHELL_READY_TIMEOUT_MS } from './session'
import {
CLEAN_DISCONNECT_PROTOCOL_VERSION,
+ COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
@@ -822,6 +823,18 @@ export class DaemonPtyAdapter implements IPtyProvider {
return foregroundProcess !== null && !isShellProcess(foregroundProcess)
}
+ async inspectProcess(
+ id: string
+ ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> {
+ if (this.protocolVersion < COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION) {
+ throw new Error('terminal_liveness_unavailable')
+ }
+ return this.client.request<{
+ foregroundProcess: string | null
+ hasChildProcesses: boolean
+ }>('inspectProcess', { sessionId: id })
+ }
+
async getForegroundProcess(id: string): Promise {
try {
const result = await this.client.request<{ foregroundProcess: string | null }>(
diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts
index e003cbede2c..0c66cf38d47 100644
--- a/src/main/daemon/daemon-pty-router.test.ts
+++ b/src/main/daemon/daemon-pty-router.test.ts
@@ -80,6 +80,7 @@ function createAdapter(
acknowledgeDataEvent: vi.fn(),
hasChildProcesses: vi.fn(async () => false),
getForegroundProcess: vi.fn(async () => null),
+ inspectProcess: vi.fn(async () => ({ foregroundProcess: null, hasChildProcesses: false })),
confirmForegroundProcess: vi.fn(async () => `${label}-confirmed`),
serialize: vi.fn(async () => '{}'),
revive: vi.fn(async () => {}),
@@ -140,6 +141,15 @@ function createAdapter(
} as unknown as AdapterMock
}
+it('rejects completion inspection when no daemon owns the session', async () => {
+ const router = new DaemonPtyRouter({
+ current: createAdapter('current'),
+ legacy: [createAdapter('legacy')]
+ })
+
+ await expect(router.inspectProcess('unmapped-session')).rejects.toThrow('terminal_gone')
+})
+
describe('DaemonPtyRouter', () => {
it('reports separate conservative resume and fresh-create boundaries', () => {
const current = createAdapter(
diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts
index 4d1e3d723de..e30021a6f0f 100644
--- a/src/main/daemon/daemon-pty-router.ts
+++ b/src/main/daemon/daemon-pty-router.ts
@@ -189,6 +189,12 @@ export class DaemonPtyRouter implements IPtyProvider {
return this.adapterFor(id).getForegroundProcess(id)
}
+ async inspectProcess(
+ id: string
+ ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> {
+ return this.adapterForInspection(id).inspectProcess(id)
+ }
+
async confirmForegroundProcess(id: string): Promise {
return this.adapterFor(id).confirmForegroundProcess(id)
}
@@ -348,6 +354,17 @@ export class DaemonPtyRouter implements IPtyProvider {
return this.sessionAdapters.get(sessionId) ?? this.current
}
+ private adapterForInspection(sessionId: string): DaemonPtyAdapter {
+ const adapter =
+ this.sessionAdapters.get(sessionId) ??
+ this.allAdapters().find((candidate) => candidate.hasPty(sessionId))
+ if (!adapter) {
+ throw new Error('terminal_gone')
+ }
+ this.sessionAdapters.set(sessionId, adapter)
+ return adapter
+ }
+
private allAdapters(): DaemonPtyAdapter[] {
return [this.current, ...this.legacy]
}
diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts
index 814a27b269e..95fb09715f0 100644
--- a/src/main/daemon/daemon-server.ts
+++ b/src/main/daemon/daemon-server.ts
@@ -909,6 +909,9 @@ export class DaemonServer {
case 'getForegroundProcess':
return { foregroundProcess: this.host.getForegroundProcess(request.payload.sessionId) }
+ case 'inspectProcess':
+ return this.host.inspectProcess(request.payload.sessionId)
+
case 'confirmForegroundProcess':
return {
foregroundProcess: await this.host.confirmForegroundProcess(request.payload.sessionId)
diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts
index 87dc721db0f..a8211b3615c 100644
--- a/src/main/daemon/degraded-daemon-pty-provider.test.ts
+++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts
@@ -113,6 +113,16 @@ function createDaemonAdapter(
} as unknown as DaemonPtyAdapter & ProviderMock
}
+it('rejects completion inspection instead of borrowing the fallback provider', async () => {
+ const provider = new DegradedDaemonPtyProvider({
+ current: createDaemonAdapter('daemon'),
+ legacy: [],
+ fallback: createProvider('fallback')
+ })
+
+ await expect(provider.inspectProcess('unmapped-session')).rejects.toThrow('terminal_gone')
+})
+
describe('DegradedDaemonPtyProvider', () => {
it('only delegates owner-listing authority to the provider that owns the id', async () => {
const current = createDaemonAdapter('daemon', ['daemon-session'])
diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts
index 0dd1577a614..e3bd38af6fd 100644
--- a/src/main/daemon/degraded-daemon-pty-provider.ts
+++ b/src/main/daemon/degraded-daemon-pty-provider.ts
@@ -1,14 +1,9 @@
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
import { shutdownDegradedFallbackSessions } from './degraded-daemon-fallback-shutdown'
-import type {
- IPtyProvider,
- PtyBackgroundStreamEvent,
- PtyDataEvent,
- PtyProviderBufferSnapshot,
- PtyProcessInfo,
- PtySpawnOptions,
- PtySpawnResult
-} from '../providers/types'
+import { inspectPtyProviderProcess } from '../providers/pty-process-inspection'
+import type { IPtyProvider, PtyBackgroundStreamEvent } from '../providers/types'
+import type { PtyDataEvent, PtyProviderBufferSnapshot } from '../providers/types'
+import type { PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from '../providers/types'
export class DegradedDaemonPtyProvider implements IPtyProvider {
readonly routesFreshSpawnsToLocalProvider = true
@@ -158,7 +153,11 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
async getForegroundProcess(id: string): Promise {
return this.providerFor(id).getForegroundProcess(id)
}
-
+ inspectProcess(id: string) {
+ return this.hasPty(id)
+ ? inspectPtyProviderProcess(this.providerFor(id), id)
+ : Promise.reject(new Error('terminal_gone'))
+ }
async confirmForegroundProcess(id: string): Promise {
return this.providerFor(id).confirmForegroundProcess?.(id) ?? null
}
diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts
index e7e83e31a31..c1e9f09ebc1 100644
--- a/src/main/daemon/terminal-host.test.ts
+++ b/src/main/daemon/terminal-host.test.ts
@@ -84,6 +84,9 @@ describe('TerminalHost', () => {
await host.dispose()
})
+ it('rejects missing strict inspection', () =>
+ expect(() => host.inspectProcess('missing-session')).toThrow('not found'))
+
describe('createOrAttach', () => {
it('creates a new session when none exists', async () => {
const result = await host.createOrAttach({
diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts
index 6be96d27d8e..ae348a3854e 100644
--- a/src/main/daemon/terminal-host.ts
+++ b/src/main/daemon/terminal-host.ts
@@ -16,6 +16,7 @@ import { resolveTerminalHostSessionCwd } from './terminal-host-session-cwd'
import { TerminalHostTombstones } from './terminal-host-tombstones'
import { listLiveTerminalHostSessions } from './terminal-host-session-listing'
import { createOrAttachTerminalSession } from './terminal-host-session-create'
+import { isShellProcess } from '../../shared/agent-detection'
export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract'
export type { TerminalHostOptions } from './terminal-host-options'
@@ -146,6 +147,17 @@ export class TerminalHost {
return session.getForegroundProcess()
}
+ inspectProcess(sessionId: string): {
+ foregroundProcess: string | null
+ hasChildProcesses: boolean
+ } {
+ const foregroundProcess = this.getAliveSession(sessionId).getForegroundProcess()
+ return {
+ foregroundProcess,
+ hasChildProcesses: foregroundProcess !== null && !isShellProcess(foregroundProcess)
+ }
+ }
+
async confirmForegroundProcess(sessionId: string): Promise {
const session = this.sessions.get(sessionId)
if (!session || !session.isAlive) {
diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts
index 082ae1e64b9..2ee1ebd9535 100644
--- a/src/main/daemon/types.ts
+++ b/src/main/daemon/types.ts
@@ -1,11 +1,13 @@
import type {
ConfirmForegroundProcessRequest,
- GetForegroundProcessRequest
+ GetForegroundProcessRequest,
+ InspectProcessRequest
} from './daemon-foreground-process-protocol'
export type {
ConfirmForegroundProcessRequest,
- GetForegroundProcessRequest
+ GetForegroundProcessRequest,
+ InspectProcessRequest
} from './daemon-foreground-process-protocol'
// ─── Protocol Version ────────────────────────────────────────────────
@@ -24,6 +26,7 @@ export {
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION,
CLEAN_DISCONNECT_PROTOCOL_VERSION,
+ COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION,
GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION,
PREVIOUS_DAEMON_PROTOCOL_VERSIONS,
PROTOCOL_VERSION,
@@ -302,6 +305,7 @@ export type DaemonRequest =
| DetachRequest
| GetCwdRequest
| GetForegroundProcessRequest
+ | InspectProcessRequest
| ConfirmForegroundProcessRequest
| ClearScrollbackRequest
| ShutdownRequest
diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts
index 931cf8f271f..88000e69914 100644
--- a/src/main/ipc/pty.ts
+++ b/src/main/ipc/pty.ts
@@ -58,6 +58,7 @@ import { detectPiAgentKindFromCommand, type PiAgentKind } from '../../shared/pi-
import { isPwshAvailable } from '../pwsh'
import { LocalPtyProvider } from '../providers/local-pty-provider'
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
+import { inspectPtyProviderProcess } from '../providers/pty-process-inspection'
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
import {
SSH_SESSION_EXPIRED_ERROR,
@@ -1586,6 +1587,7 @@ export function registerPtyHandlers(
ipcMain.removeHandler('pty:hasPty')
ipcMain.removeHandler('pty:hasChildProcesses')
ipcMain.removeHandler('pty:getForegroundProcess')
+ ipcMain.removeHandler('pty:inspectProcess')
ipcMain.removeHandler('pty:confirmForegroundProcess')
ipcMain.removeHandler('pty:getCwd')
ipcMain.removeHandler('pty:getSize')
@@ -3752,6 +3754,7 @@ export function registerPtyHandlers(
return null
}
},
+ inspectProcess: async (ptyId) => inspectPtyProviderProcess(getProviderForPty(ptyId), ptyId),
confirmForegroundProcess: async (ptyId) => {
try {
const provider = getProviderForPty(ptyId)
@@ -5337,6 +5340,10 @@ export function registerPtyHandlers(
}
)
+ ipcMain.handle('pty:inspectProcess', async (_event, args: { id: string }) =>
+ inspectPtyProviderProcess(getProviderForPty(args.id), args.id)
+ )
+
ipcMain.handle(
'pty:confirmForegroundProcess',
async (_event, args: { id: string }): Promise => {
diff --git a/src/main/providers/pty-process-inspection.test.ts b/src/main/providers/pty-process-inspection.test.ts
new file mode 100644
index 00000000000..60ce53bc568
--- /dev/null
+++ b/src/main/providers/pty-process-inspection.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { IPtyProvider } from './types'
+import { inspectPtyProviderProcess } from './pty-process-inspection'
+
+describe('PTY provider process inspection', () => {
+ it('rejects a missing provider PTY instead of returning idle evidence', async () => {
+ const provider = {
+ hasPty: vi.fn(() => false),
+ getForegroundProcess: vi.fn().mockResolvedValue(null),
+ hasChildProcesses: vi.fn().mockResolvedValue(false)
+ } as unknown as IPtyProvider
+
+ await expect(inspectPtyProviderProcess(provider, 'pty-missing')).rejects.toThrow(
+ 'terminal_gone'
+ )
+ expect(provider.getForegroundProcess).not.toHaveBeenCalled()
+ })
+
+ it('preserves a completion-sensitive provider failure', async () => {
+ const failure = new Error('daemon unavailable')
+ const inspectProcess = vi.fn().mockRejectedValue(failure)
+ const provider = { inspectProcess } as unknown as IPtyProvider
+
+ await expect(inspectPtyProviderProcess(provider, 'pty-1')).rejects.toBe(failure)
+ expect(inspectProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
+ })
+
+ it('falls back to the existing provider process APIs', async () => {
+ const getForegroundProcess = vi.fn().mockResolvedValue('codex')
+ const hasChildProcesses = vi.fn().mockResolvedValue(true)
+ const provider = {
+ getForegroundProcess,
+ hasChildProcesses
+ } as Pick as IPtyProvider
+
+ await expect(inspectPtyProviderProcess(provider, 'pty-1')).resolves.toEqual({
+ foregroundProcess: 'codex',
+ hasChildProcesses: true
+ })
+ })
+})
diff --git a/src/main/providers/pty-process-inspection.ts b/src/main/providers/pty-process-inspection.ts
new file mode 100644
index 00000000000..68e174ff886
--- /dev/null
+++ b/src/main/providers/pty-process-inspection.ts
@@ -0,0 +1,26 @@
+import type { IPtyProvider } from './types'
+
+export type PtyProcessInspection = {
+ foregroundProcess: string | null
+ hasChildProcesses: boolean
+}
+
+type CompletionSensitivePtyProvider = IPtyProvider & {
+ inspectProcess?: (id: string) => Promise
+}
+
+export async function inspectPtyProviderProcess(
+ provider: IPtyProvider,
+ ptyId: string
+): Promise {
+ if (provider.hasPty?.(ptyId) === false) {
+ throw new Error('terminal_gone')
+ }
+ const inspectProcess = (provider as CompletionSensitivePtyProvider).inspectProcess
+ if (inspectProcess) {
+ return inspectProcess.call(provider, ptyId)
+ }
+ const foregroundProcess = await provider.getForegroundProcess(ptyId)
+ const hasChildProcesses = await provider.hasChildProcesses(ptyId)
+ return { foregroundProcess, hasChildProcesses }
+}
diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts
index 02611e79d15..6dc0cb39a1d 100644
--- a/src/main/providers/ssh-pty-provider.ts
+++ b/src/main/providers/ssh-pty-provider.ts
@@ -281,6 +281,14 @@ export class SshPtyProvider implements IPtyProvider {
return result as string | null
}
+ async inspectProcess(
+ id: string
+ ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> {
+ return (await this.mux.request('pty.inspectProcess', {
+ id: this.toRelayPtyId(id)
+ })) as { foregroundProcess: string | null; hasChildProcesses: boolean }
+ }
+
async serialize(ids: string[]): Promise {
const result = await this.mux.request('pty.serialize', {
ids: ids.map((id) => this.toRelayPtyId(id))
diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts
index e9f6698e408..af1344d62a8 100644
--- a/src/main/runtime/orca-runtime.test.ts
+++ b/src/main/runtime/orca-runtime.test.ts
@@ -86,6 +86,8 @@ import {
unregisterSshFilesystemProvider
} from '../providers/ssh-filesystem-dispatch'
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
+import { inspectPtyProviderProcess } from '../providers/pty-process-inspection'
+import type { IPtyProvider } from '../providers/types'
import * as worktreePathComparison from '../ipc/worktree-path-comparison'
import * as localWorktreeFilesystem from '../local-worktree-filesystem'
import {
@@ -1279,6 +1281,9 @@ function createRuntimeWithSshLease(
async function createExplicitAgentStatusHarness(options: {
getForegroundProcess: (ptyId: string) => Promise
+ inspectProcess?: (
+ ptyId: string
+ ) => Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }>
confirmForegroundProcess?: (ptyId: string) => Promise
title?: string
}): Promise<{
@@ -1308,6 +1313,7 @@ async function createExplicitAgentStatusHarness(options: {
write: () => true,
kill: () => true,
getForegroundProcess: options.getForegroundProcess,
+ inspectProcess: options.inspectProcess,
confirmForegroundProcess: options.confirmForegroundProcess
})
runtime.attachWindow(1)
@@ -10368,6 +10374,23 @@ describe('OrcaRuntimeService', () => {
expect(confirmForegroundProcess).toHaveBeenCalledWith('pty-1')
})
+ it('preserves provider failure during completion-sensitive process inspection', async () => {
+ const failure = new Error('daemon unavailable')
+ const providerInspectProcess = vi.fn().mockRejectedValue(failure)
+ const provider = { inspectProcess: providerInspectProcess } as unknown as IPtyProvider
+ const inspectProcess = vi.fn((ptyId: string) => inspectPtyProviderProcess(provider, ptyId))
+ const getForegroundProcess = vi.fn(async () => null)
+ const { runtime, handle } = await createExplicitAgentStatusHarness({
+ getForegroundProcess,
+ inspectProcess
+ })
+
+ await expect(runtime.inspectTerminalProcess(handle)).rejects.toBe(failure)
+ expect(inspectProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
+ expect(providerInspectProcess).toHaveBeenCalledExactlyOnceWith('pty-1')
+ expect(getForegroundProcess).not.toHaveBeenCalled()
+ })
+
it('calls foreground confirmation with its controller receiver', async () => {
const getForegroundProcess = vi.fn(async () => 'powershell.exe')
const confirmForegroundProcess = vi.fn(
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index 1a214ef445c..05cc5770d16 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -1427,6 +1427,9 @@ type RuntimePtyController = {
markReversibleStops?(ptyIds: readonly string[]): () => void
getCwd?(ptyId: string): Promise
getForegroundProcess(ptyId: string): Promise
+ inspectProcess?(
+ ptyId: string
+ ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }>
confirmForegroundProcess?(ptyId: string): Promise
hasChildProcesses?(ptyId: string): Promise
clearBuffer?(ptyId: string): Promise
@@ -15723,13 +15726,15 @@ export class OrcaRuntimeService {
async inspectTerminalProcess(
terminalSelector: string
): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> {
- const leaf = this.resolveLeafForHandle(terminalSelector)
+ const leaf = this.resolveLiveLeafForHandle(terminalSelector)
if (!leaf?.ptyId || !this.ptyController) {
- return { foregroundProcess: null, hasChildProcesses: false }
+ throw new Error('terminal_gone')
+ }
+ if (this.ptyController.inspectProcess) {
+ return this.ptyController.inspectProcess(leaf.ptyId)
}
const foregroundProcess = await this.ptyController.getForegroundProcess(leaf.ptyId)
- const hasChildProcesses =
- (await this.ptyController.hasChildProcesses?.(leaf.ptyId).catch(() => false)) ?? false
+ const hasChildProcesses = (await this.ptyController.hasChildProcesses?.(leaf.ptyId)) ?? false
return { foregroundProcess, hasChildProcesses }
}
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index b02f8474911..be713ab04f0 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -1373,6 +1373,9 @@ export type PreloadApi = {
publishTerminalViewAttributes: (attributes: TerminalViewAttributes) => void
hasChildProcesses: (id: string) => Promise
getForegroundProcess: (id: string) => Promise
+ inspectProcess: (
+ id: string
+ ) => Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }>
confirmForegroundProcess: (id: string) => Promise
getCwd: (id: string) => Promise
getSize: (id: string) => Promise<{ cols: number; rows: number } | null>
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 41d850a8827..01e6c18798f 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -981,6 +981,10 @@ const api = {
/** Return the PTY foreground process basename when available (e.g. "codex"). */
getForegroundProcess: (id: string): Promise =>
ipcRenderer.invoke('pty:getForegroundProcess', { id }),
+ inspectProcess: (
+ id: string
+ ): Promise<{ foregroundProcess: string | null; hasChildProcesses: boolean }> =>
+ ipcRenderer.invoke('pty:inspectProcess', { id }),
confirmForegroundProcess: (id: string): Promise =>
ipcRenderer.invoke('pty:confirmForegroundProcess', { id }),
diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts
index 60f944456d4..3e3bbebd9fd 100644
--- a/src/relay/pty-handler.test.ts
+++ b/src/relay/pty-handler.test.ts
@@ -151,6 +151,7 @@ describe('PtyHandler', () => {
expect(methods).toContain('pty.clearBuffer')
expect(methods).toContain('pty.hasChildProcesses')
expect(methods).toContain('pty.getForegroundProcess')
+ expect(methods).toContain('pty.inspectProcess')
expect(methods).toContain('pty.listProcesses')
expect(methods).toContain('pty.getDefaultShell')
@@ -160,6 +161,12 @@ describe('PtyHandler', () => {
expect(notifMethods).toContain('pty.ackData')
})
+ it('rejects strict process inspection for a missing relay PTY', async () => {
+ await expect(dispatcher.callRequest('pty.inspectProcess', { id: 'missing' })).rejects.toThrow(
+ 'terminal_gone'
+ )
+ })
+
it('allows callers to shorten a grace timer for empty startup relays', () => {
const onExpire = vi.fn()
handler.startGraceTimer(onExpire, 100)
diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts
index 2bc9ead9cfe..ce2059e30aa 100644
--- a/src/relay/pty-handler.ts
+++ b/src/relay/pty-handler.ts
@@ -621,6 +621,7 @@ export class PtyHandler {
this.dispatcher.onRequest('pty.clearBuffer', (p) => this.clearBuffer(p))
this.dispatcher.onRequest('pty.hasChildProcesses', (p) => this.hasChildProcesses(p))
this.dispatcher.onRequest('pty.getForegroundProcess', (p) => this.getForegroundProcess(p))
+ this.dispatcher.onRequest('pty.inspectProcess', (p) => this.inspectProcess(p))
this.dispatcher.onRequest('pty.getCapabilities', async () => ({
startupIngressVersion: PTY_STARTUP_INGRESS_VERSION,
agentSessionClaimVersion: AGENT_SESSION_EXECUTION_OWNER_PROTOCOL_VERSION,
@@ -1386,6 +1387,25 @@ export class PtyHandler {
return await getForegroundProcessName(managed.pty.pid, managed.pty.process || null)
}
+ private async inspectProcess(params: Record): Promise<{
+ foregroundProcess: string | null
+ hasChildProcesses: boolean
+ }> {
+ const id = params.id as string
+ const managed = this.ptys.get(id)
+ if (!managed || managed.disposed) {
+ throw new Error('terminal_gone')
+ }
+ const foregroundProcess = await getForegroundProcessName(
+ managed.pty.pid,
+ managed.pty.process || null
+ )
+ return {
+ foregroundProcess,
+ hasChildProcesses: await processHasChildren(managed.pty.pid)
+ }
+ }
+
private async listProcesses(): Promise {
const results: PtyProcessSummary[] = []
for (const [id, managed] of this.ptys) {
diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts
index a57f4bb1fdd..b948cf43da6 100644
--- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts
+++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts
@@ -471,6 +471,13 @@ export function createAgentCompletionCoordinator(
}
function handleProcessInspectionResult(result: RuntimeTerminalProcessInspection): boolean {
+ if (result.unavailable === true) {
+ // Why: unknown liveness breaks the consecutive-idle proof without erasing known agent ownership.
+ pendingProcessExitAgent = null
+ consecutiveInspectionErrors += 1
+ scheduleNextPoll()
+ return false
+ }
consecutiveInspectionErrors = 0
const recognized = recognizeAgentProcess(result.foregroundProcess)
if (recognized) {
@@ -551,6 +558,8 @@ export function createAgentCompletionCoordinator(
inspectionSucceeded = true
}
} catch {
+ // Why: a failed inspection breaks the consecutive-idle proof just like an unavailable result.
+ pendingProcessExitAgent = null
consecutiveInspectionErrors += 1
} finally {
inspectionInFlight = false
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
index 3020dab3794..634f5f5b49d 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
@@ -874,6 +874,7 @@ describe('connectPanePty', () => {
reportGeometry: vi.fn(),
getMainBufferSnapshot: vi.fn().mockResolvedValue(null),
getForegroundProcess: vi.fn().mockResolvedValue(null),
+ inspectProcess: vi.fn(),
confirmForegroundProcess: vi.fn().mockResolvedValue(null),
hasChildProcesses: vi.fn().mockResolvedValue(false),
write: vi.fn(),
@@ -910,6 +911,11 @@ describe('connectPanePty', () => {
vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation((id) =>
window.api.pty.getForegroundProcess(id)
)
+ vi.mocked(window.api.pty.inspectProcess).mockImplementation(async (id) => {
+ const foregroundProcess = await window.api.pty.getForegroundProcess(id)
+ const hasChildProcesses = await window.api.pty.hasChildProcesses(id)
+ return { foregroundProcess, hasChildProcesses }
+ })
globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
callback(0)
return 1
@@ -16508,6 +16514,7 @@ describe('connectPanePty', () => {
terminalTitle: 'experimental-agent-observability',
paneKey: makePaneKey('tab-1', LEAF_1)
})
+ expect(window.api.pty.inspectProcess).toHaveBeenCalledWith('pty-codex')
})
it('does not dispatch generic spinner completions when process inspection finds no agent', async () => {
diff --git a/src/renderer/src/lib/codex-session-restart.test.ts b/src/renderer/src/lib/codex-session-restart.test.ts
index d7074e8edd9..b75a961c50d 100644
--- a/src/renderer/src/lib/codex-session-restart.test.ts
+++ b/src/renderer/src/lib/codex-session-restart.test.ts
@@ -53,7 +53,8 @@ describe('markLiveCodexSessionsForRestart', () => {
pty: {
...originalWindow?.api?.pty,
getForegroundProcess: vi.fn(),
- hasChildProcesses: vi.fn().mockResolvedValue(false)
+ hasChildProcesses: vi.fn().mockResolvedValue(false),
+ inspectProcess: vi.fn()
},
runtimeEnvironments: {
...originalWindow?.api?.runtimeEnvironments,
@@ -72,14 +73,17 @@ describe('markLiveCodexSessionsForRestart', () => {
})
it('marks a live Codex PTY for restart', async () => {
- vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('codex')
+ vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({
+ foregroundProcess: 'codex',
+ hasChildProcesses: false
+ })
await markLiveCodexSessionsForRestart({
previousAccountLabel: ACCOUNT_A,
nextAccountLabel: ACCOUNT_B
})
- expect(window.api.pty.getForegroundProcess).toHaveBeenCalledWith('pty-1')
+ expect(window.api.pty.inspectProcess).toHaveBeenCalledWith('pty-1')
expect(useAppStore.getState().codexRestartNoticeByPtyId['pty-1']).toEqual({
previousAccountLabel: ACCOUNT_A,
nextAccountLabel: ACCOUNT_B
@@ -117,14 +121,10 @@ describe('markLiveCodexSessionsForRestart', () => {
'tab-2': ['pty-3']
}
})
- vi.mocked(window.api.pty.getForegroundProcess).mockImplementation((ptyId) => {
- if (ptyId === 'pty-1') {
- return Promise.resolve('codex')
- }
- if (ptyId === 'pty-3') {
- return Promise.resolve('codex-aarch64-ap')
- }
- return Promise.resolve('zsh')
+ vi.mocked(window.api.pty.inspectProcess).mockImplementation(async (ptyId) => {
+ const foregroundProcess =
+ ptyId === 'pty-1' ? 'codex' : ptyId === 'pty-3' ? 'codex-aarch64-ap' : 'zsh'
+ return { foregroundProcess, hasChildProcesses: false }
})
await markLiveCodexSessionsForRestart({
@@ -145,7 +145,10 @@ describe('markLiveCodexSessionsForRestart', () => {
})
it('does not mark non-codex foreground processes', async () => {
- vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('zsh')
+ vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({
+ foregroundProcess: 'zsh',
+ hasChildProcesses: false
+ })
await markLiveCodexSessionsForRestart({
previousAccountLabel: ACCOUNT_A,
@@ -155,8 +158,33 @@ describe('markLiveCodexSessionsForRestart', () => {
expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({})
})
+ it('still marks a confirmed Codex pane when another pane is unreachable', async () => {
+ useAppStore.setState({ ptyIdsByTabId: { 'tab-1': ['pty-1', 'pty-stale'] } })
+ vi.mocked(window.api.pty.inspectProcess).mockImplementation(async (ptyId) => {
+ if (ptyId === 'pty-stale') {
+ throw new Error('terminal_gone')
+ }
+ return { foregroundProcess: 'codex', hasChildProcesses: true }
+ })
+
+ await markLiveCodexSessionsForRestart({
+ previousAccountLabel: ACCOUNT_A,
+ nextAccountLabel: ACCOUNT_B
+ })
+
+ expect(useAppStore.getState().codexRestartNoticeByPtyId).toEqual({
+ 'pty-1': {
+ previousAccountLabel: ACCOUNT_A,
+ nextAccountLabel: ACCOUNT_B
+ }
+ })
+ })
+
it('treats codex.exe as codex for Windows PTYs', async () => {
- vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('codex.exe')
+ vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({
+ foregroundProcess: 'codex.exe',
+ hasChildProcesses: false
+ })
await markLiveCodexSessionsForRestart({
previousAccountLabel: ACCOUNT_A,
@@ -170,7 +198,10 @@ describe('markLiveCodexSessionsForRestart', () => {
})
it('treats codex-prefixed packaged binaries as codex', async () => {
- vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('codex-aarch64-ap')
+ vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({
+ foregroundProcess: 'codex-aarch64-ap',
+ hasChildProcesses: false
+ })
await markLiveCodexSessionsForRestart({
previousAccountLabel: ACCOUNT_A,
@@ -184,7 +215,10 @@ describe('markLiveCodexSessionsForRestart', () => {
})
it('clears stale restart notices when the selected account switches back to the live pane account', async () => {
- vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('codex')
+ vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({
+ foregroundProcess: 'codex',
+ hasChildProcesses: false
+ })
await markLiveCodexSessionsForRestart({
previousAccountLabel: ACCOUNT_A,
@@ -202,7 +236,10 @@ describe('markLiveCodexSessionsForRestart', () => {
})
it('preserves the pane original account across repeated switches until restart', async () => {
- vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('codex')
+ vi.mocked(window.api.pty.inspectProcess).mockResolvedValue({
+ foregroundProcess: 'codex',
+ hasChildProcesses: false
+ })
await markLiveCodexSessionsForRestart({
previousAccountLabel: ACCOUNT_A,
@@ -255,7 +292,7 @@ describe('markLiveCodexSessionsForRestart', () => {
nextAccountLabel: ACCOUNT_B
})
- expect(window.api.pty.getForegroundProcess).not.toHaveBeenCalled()
+ expect(window.api.pty.inspectProcess).not.toHaveBeenCalled()
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'terminal.inspectProcess',
diff --git a/src/renderer/src/lib/codex-session-restart.ts b/src/renderer/src/lib/codex-session-restart.ts
index 6e7a46bb156..36778b7beed 100644
--- a/src/renderer/src/lib/codex-session-restart.ts
+++ b/src/renderer/src/lib/codex-session-restart.ts
@@ -37,7 +37,9 @@ async function getLiveCodexSessionPtyIds(state: AppState): Promise {
const foregroundProcesses = await Promise.all(
ptyIds.map((ptyId) =>
inspectRuntimeTerminalProcess(state.settings, ptyId).then(
- (inspection) => inspection.foregroundProcess
+ (inspection) => inspection.foregroundProcess,
+ // Why: one stale remote pane must not hide restart notices for other confirmed Codex panes.
+ () => null
)
)
)
diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.test.ts b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts
index 2782cb00455..8202f5aa9f7 100644
--- a/src/renderer/src/runtime/runtime-terminal-inspection.test.ts
+++ b/src/renderer/src/runtime/runtime-terminal-inspection.test.ts
@@ -28,6 +28,7 @@ describe('runtime terminal owner routing', () => {
const localWriteAccepted = vi.fn()
const localForeground = vi.fn()
const localHasChildren = vi.fn()
+ const localInspect = vi.fn()
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
@@ -47,7 +48,8 @@ describe('runtime terminal owner routing', () => {
write: localWrite,
writeAccepted: localWriteAccepted,
getForegroundProcess: localForeground,
- hasChildProcesses: localHasChildren
+ hasChildProcesses: localHasChildren,
+ inspectProcess: localInspect
}
}
})
@@ -114,20 +116,35 @@ describe('runtime terminal owner routing', () => {
expect(localHasChildren).not.toHaveBeenCalled()
})
- it('treats stale remote terminal handles as gone during process inspection', async () => {
- runtimeCall.mockResolvedValue({
- ok: false,
- error: { code: 'terminal_handle_stale', message: 'terminal_handle_stale' }
- })
+ it('uses strict main-process inspection for a direct SSH PTY', async () => {
+ localInspect.mockResolvedValue({ foregroundProcess: 'codex', hasChildProcesses: true })
- await expect(
- inspectRuntimeTerminalProcess(
- { activeRuntimeEnvironmentId: 'env-2' },
- 'remote:env-1@@terminal-stale'
- )
- ).resolves.toEqual({ foregroundProcess: null, hasChildProcesses: false })
+ await expect(inspectRuntimeTerminalProcess(null, 'ssh:host@@pty-1')).resolves.toEqual({
+ foregroundProcess: 'codex',
+ hasChildProcesses: true
+ })
+ expect(localInspect).toHaveBeenCalledExactlyOnceWith('ssh:host@@pty-1')
+ expect(localForeground).not.toHaveBeenCalled()
+ expect(localHasChildren).not.toHaveBeenCalled()
})
+ it.each(['no_connected_pty', 'terminal_handle_stale', 'terminal_gone'])(
+ 'reports %s remote process inspection as unavailable',
+ async (code) => {
+ runtimeCall.mockResolvedValue({
+ ok: false,
+ error: { code, message: code }
+ })
+
+ await expect(
+ inspectRuntimeTerminalProcess(
+ { activeRuntimeEnvironmentId: 'env-2' },
+ 'remote:env-1@@terminal-stale'
+ )
+ ).resolves.toEqual({ foregroundProcess: null, hasChildProcesses: false, unavailable: true })
+ }
+ )
+
it('records accepted fire-and-forget runtime input against the owning pane key', async () => {
runtimeCall.mockResolvedValue({
ok: true,
diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.ts b/src/renderer/src/runtime/runtime-terminal-inspection.ts
index b9691f62283..45bb77dd32c 100644
--- a/src/renderer/src/runtime/runtime-terminal-inspection.ts
+++ b/src/renderer/src/runtime/runtime-terminal-inspection.ts
@@ -12,6 +12,8 @@ import {
export type RuntimeTerminalProcessInspection = {
foregroundProcess: string | null
hasChildProcesses: boolean
+ // Why: callers must not treat a stale remote handle as authoritative idle evidence.
+ unavailable?: true
}
const REMOTE_PTY_ID_PREFIX = 'remote:'
@@ -34,6 +36,7 @@ function isTerminalGoneError(error: unknown): boolean {
? String((error as { code?: unknown }).code)
: ''
return (
+ code === 'no_connected_pty' ||
code === 'terminal_handle_stale' ||
code === 'terminal_exited' ||
code === 'terminal_gone' ||
@@ -74,11 +77,7 @@ export async function inspectRuntimeTerminalProcess(
: getActiveRuntimeTarget(settings)
const terminal = getRemoteRuntimeTerminalHandle(ptyId)
if (target.kind !== 'environment' || !terminal) {
- const [foregroundProcess, hasChildProcesses] = await Promise.all([
- window.api.pty.getForegroundProcess(ptyId),
- window.api.pty.hasChildProcesses(ptyId)
- ])
- return { foregroundProcess, hasChildProcesses }
+ return window.api.pty.inspectProcess(ptyId)
}
try {
@@ -91,7 +90,7 @@ export async function inspectRuntimeTerminalProcess(
return result.process
} catch (error) {
if (isTerminalGoneError(error)) {
- return { foregroundProcess: null, hasChildProcesses: false }
+ return { foregroundProcess: null, hasChildProcesses: false, unavailable: true }
}
throw error
}
diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts
index 2e18333d89a..d745b82ed44 100644
--- a/src/renderer/src/web/web-preload-api.ts
+++ b/src/renderer/src/web/web-preload-api.ts
@@ -2934,6 +2934,7 @@ function createPtyApi(): NonNullable['pty']> {
publishTerminalViewAttributes: () => {},
hasChildProcesses: () => Promise.resolve(false),
getForegroundProcess: () => Promise.resolve(null),
+ inspectProcess: () => Promise.reject(new Error('terminal_liveness_unavailable')),
// Why: paired web panes cannot provide a local post-boundary process scan.
confirmForegroundProcess: () => Promise.resolve(null),
getCwd: () => Promise.resolve('~'),
diff --git a/tests/e2e/remote-agent-completion-authority.unit.test.ts b/tests/e2e/remote-agent-completion-authority.unit.test.ts
new file mode 100644
index 00000000000..72e73ec0d8d
--- /dev/null
+++ b/tests/e2e/remote-agent-completion-authority.unit.test.ts
@@ -0,0 +1,202 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { createTerminalTitleTracker } from '../../src/shared/terminal-output-side-effects'
+import {
+ createAgentCompletionCoordinator,
+ resetAgentCompletionCoordinatorIdentitiesForTest
+} from '../../src/renderer/src/components/terminal-pane/agent-completion-coordinator'
+import type { AgentCompletionDispatchMeta } from '../../src/renderer/src/components/terminal-pane/agent-completion-coordinator-types'
+import { inspectRuntimeTerminalProcess } from '../../src/renderer/src/runtime/runtime-terminal-inspection'
+import { clearRuntimeCompatibilityCacheForTests } from '../../src/renderer/src/runtime/runtime-rpc-client'
+import {
+ createCompatibleRuntimeStatusResponseIfNeeded,
+ type RuntimeEnvironmentCallRequest
+} from '../../src/renderer/src/runtime/runtime-compatibility-test-fixture'
+
+const REMOTE_PTY_ID = 'remote:remote-host@@term_remote_agent'
+
+describe('remote agent completion authority', () => {
+ const runtimeCall = vi.fn()
+ const runtimeTransportCall = vi.fn((args: RuntimeEnvironmentCallRequest) => {
+ return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeCall(args)
+ })
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.spyOn(Math, 'random').mockReturnValue(0.5)
+ clearRuntimeCompatibilityCacheForTests()
+ vi.stubGlobal('window', {
+ api: {
+ runtimeEnvironments: { call: runtimeTransportCall },
+ pty: {
+ getForegroundProcess: vi.fn(),
+ hasChildProcesses: vi.fn()
+ }
+ }
+ })
+ })
+
+ afterEach(() => {
+ resetAgentCompletionCoordinatorIdentitiesForTest()
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+ vi.clearAllMocks()
+ vi.restoreAllMocks()
+ })
+
+ it('keeps transport loss unknown through reconnect and completes only after authoritative idle samples', async () => {
+ const dispatchCompletion = vi.fn()
+ const coordinator = createAgentCompletionCoordinator({
+ paneKey: 'tab-remote:leaf-remote',
+ getPtyId: () => REMOTE_PTY_ID,
+ getSettings: () => ({ activeRuntimeEnvironmentId: 'remote-host' }),
+ inspectProcess: inspectRuntimeTerminalProcess,
+ dispatchCompletion,
+ isLive: () => true
+ })
+
+ runtimeCall.mockResolvedValue(remoteInspection('codex'))
+ coordinator.startProcessTracking()
+ await vi.advanceTimersByTimeAsync(2_000)
+ expect(runtimeCall).toHaveBeenCalledTimes(1)
+
+ runtimeCall.mockResolvedValue({
+ ok: false,
+ error: { code: 'terminal_handle_stale', message: 'remote transport is reconnecting' }
+ })
+ await vi.advanceTimersByTimeAsync(20_000)
+ expect(runtimeCall.mock.calls.length).toBeGreaterThan(2)
+ expect(dispatchCompletion).not.toHaveBeenCalled()
+
+ runtimeCall.mockResolvedValue(remoteInspection('codex'))
+ await vi.advanceTimersByTimeAsync(20_000)
+ expect(dispatchCompletion).not.toHaveBeenCalled()
+
+ runtimeCall.mockResolvedValue(remoteInspection(null, false))
+ await vi.advanceTimersByTimeAsync(20_000)
+ expect(dispatchCompletion).toHaveBeenCalledExactlyOnceWith('codex', {
+ source: 'process-exit',
+ quietedHookDone: false,
+ terminalIdleConfirmed: true
+ })
+
+ coordinator.dispose()
+ })
+
+ it.each([
+ {
+ failure: {
+ ok: false,
+ error: { code: 'no_connected_pty', message: 'remote transport is unavailable' }
+ },
+ kind: 'an unavailable response'
+ },
+ {
+ failure: new Error('Runtime request timed out before terminal.inspectProcess completed'),
+ kind: 'a thrown transport failure'
+ }
+ ])(
+ 'requires two new idle samples when $kind interrupts exit confirmation',
+ async ({ failure }) => {
+ const dispatchCompletion = vi.fn()
+ const coordinator = createAgentCompletionCoordinator({
+ paneKey: 'tab-remote:leaf-partitioned-exit',
+ getPtyId: () => REMOTE_PTY_ID,
+ getSettings: () => ({ activeRuntimeEnvironmentId: 'remote-host' }),
+ inspectProcess: inspectRuntimeTerminalProcess,
+ dispatchCompletion,
+ isLive: () => true
+ })
+
+ runtimeCall.mockResolvedValue(remoteInspection('codex'))
+ coordinator.startProcessTracking()
+ await vi.advanceTimersByTimeAsync(2_000)
+
+ runtimeCall.mockResolvedValue(remoteInspection(null, false))
+ await vi.advanceTimersByTimeAsync(750)
+ expect(runtimeCall).toHaveBeenCalledTimes(2)
+ expect(dispatchCompletion).not.toHaveBeenCalled()
+
+ if (failure instanceof Error) {
+ runtimeCall.mockRejectedValue(failure)
+ } else {
+ runtimeCall.mockResolvedValue(failure)
+ }
+ await vi.advanceTimersByTimeAsync(750)
+ expect(runtimeCall).toHaveBeenCalledTimes(3)
+ expect(dispatchCompletion).not.toHaveBeenCalled()
+
+ runtimeCall.mockResolvedValue(remoteInspection(null, false))
+ await vi.advanceTimersByTimeAsync(1_500)
+ expect(runtimeCall).toHaveBeenCalledTimes(4)
+ expect(dispatchCompletion).not.toHaveBeenCalled()
+
+ await vi.advanceTimersByTimeAsync(750)
+ expect(dispatchCompletion).toHaveBeenCalledExactlyOnceWith('codex', {
+ source: 'process-exit',
+ quietedHookDone: false,
+ terminalIdleConfirmed: true
+ })
+
+ coordinator.dispose()
+ }
+ )
+
+ it('preserves distinct stopped, exited, and successful completion evidence', async () => {
+ const outcomes: (
+ | { kind: 'hook'; interrupted: boolean }
+ | { kind: 'process-exit'; exitCode: number | null }
+ )[] = []
+ const createHookCoordinator = (paneKey: string) =>
+ createAgentCompletionCoordinator({
+ paneKey,
+ getPtyId: () => REMOTE_PTY_ID,
+ getSettings: () => ({ activeRuntimeEnvironmentId: 'remote-host' }),
+ inspectProcess: inspectRuntimeTerminalProcess,
+ dispatchCompletion: (_title: string, meta?: AgentCompletionDispatchMeta) => {
+ outcomes.push({
+ kind: 'hook',
+ interrupted: meta?.agentStatus?.interrupted === true
+ })
+ },
+ isLive: () => true
+ })
+
+ const stopped = createHookCoordinator('tab-remote:leaf-stopped')
+ stopped.observeHookStatus({ state: 'working', prompt: 'stop me', agentType: 'codex' })
+ stopped.observeHookStatus({
+ state: 'done',
+ prompt: 'stop me',
+ agentType: 'codex',
+ interrupted: true
+ })
+ await vi.advanceTimersByTimeAsync(1_500)
+
+ const tracker = createTerminalTitleTracker({
+ onCommandFinished: (exitCode) => outcomes.push({ kind: 'process-exit', exitCode })
+ })
+ tracker.handleChunk('\u001b]133;D;130\u0007')
+
+ const succeeded = createHookCoordinator('tab-remote:leaf-succeeded')
+ succeeded.observeHookStatus({ state: 'working', prompt: 'finish me', agentType: 'codex' })
+ succeeded.observeHookStatus({ state: 'done', prompt: 'finish me', agentType: 'codex' })
+ await vi.advanceTimersByTimeAsync(1_500)
+
+ expect(outcomes).toEqual([
+ { kind: 'hook', interrupted: true },
+ { kind: 'process-exit', exitCode: 130 },
+ { kind: 'hook', interrupted: false }
+ ])
+
+ stopped.dispose()
+ succeeded.dispose()
+ tracker.dispose()
+ })
+})
+
+function remoteInspection(foregroundProcess: string | null, hasChildProcesses = true) {
+ return {
+ ok: true,
+ result: { process: { foregroundProcess, hasChildProcesses } },
+ _meta: { runtimeId: 'remote-host' }
+ }
+}
From 8a23618310968bd3ab7ad37db89929414910d690 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Thu, 23 Jul 2026 02:33:27 -0700
Subject: [PATCH 04/32] fix(terminal): route host-agnostic setup and floating
terminals instead of failing them closed (#10151)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Inline setup/onboarding terminals (skill installs, feature tips) and the floating terminal use host-agnostic synthetic worktree ids with no worktree/repo row. Since #9994, connectPanePty gated their transport on resolveWorktreeOperationRouteResult, which fails closed ('missing') for unknown ids — so the Orca CLI skill Update button, every other inline setup terminal, and the floating terminal showed "Workspace identity is ambiguous across hosts" instead of running.
Route them through the shared resolveTerminalWorktreeRoute (which already exempts the floating terminal and folder workspaces), and add the missing ephemeral-setup exemption so setup terminals follow the single active runtime (remote skill installs land there) or run locally when none is focused. Genuinely unknown/stale repo-backed worktrees still fail closed, preserving #9994.
Adds terminal-worktree-route unit tests and connectPanePty regression tests (proven to fail without the fix).
Co-authored-by: Orca
---
.../terminal-pane/pty-connection.test.ts | 84 +++++++++++++++++++
.../terminal-pane/pty-connection.ts | 16 ++--
.../src/lib/terminal-worktree-route.test.ts | 72 ++++++++++++++++
.../src/lib/terminal-worktree-route.ts | 8 ++
4 files changed, 173 insertions(+), 7 deletions(-)
create mode 100644 src/renderer/src/lib/terminal-worktree-route.test.ts
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
index 634f5f5b49d..7374e99e551 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts
@@ -15122,6 +15122,90 @@ describe('connectPanePty', () => {
expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('legacy-hub', expect.any(Object))
})
+ it('runs an inline setup terminal locally instead of failing its host closed', async () => {
+ // Regression (#9994 fallout): the branded ephemeral-setup id resolves to no worktree/repo, so
+ // the strict owner resolver reported it unresolved and gave the pane the "Workspace identity is
+ // ambiguous across hosts" error transport instead of a real local PTY.
+ const { connectPanePty } = await import('./pty-connection')
+ const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
+ const { createIpcPtyTransport } = await import('./pty-transport')
+ const transport = createMockTransport()
+ transportFactoryQueue.push(transport)
+ const setupWorktreeId =
+ 'ephemeral-setup-terminal:settings-mobile-emulator-orca-cli-skill-terminal'
+ mockStoreState = {
+ ...mockStoreState,
+ tabsByWorktree: { [setupWorktreeId]: [{ id: 'tab-1', ptyId: null }] },
+ worktreesByRepo: {
+ repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/tmp/wt-1', hostId: 'local' }]
+ },
+ repos: [{ id: 'repo1', connectionId: null, executionHostId: 'local' }]
+ } as StoreState
+
+ connectPanePty(
+ createPane(1) as never,
+ createManager(1) as never,
+ createDeps({ worktreeId: setupWorktreeId }) as never
+ )
+
+ expect(createIpcPtyTransport).toHaveBeenCalled()
+ expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled()
+ })
+
+ it('runs an inline setup terminal on the single active runtime for remote skill installs', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+ const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
+ const transport = createMockTransport()
+ transportFactoryQueue.push(transport)
+ const setupWorktreeId =
+ 'ephemeral-setup-terminal:settings-mobile-emulator-orca-cli-skill-terminal'
+ mockStoreState = {
+ ...mockStoreState,
+ tabsByWorktree: { [setupWorktreeId]: [{ id: 'tab-1', ptyId: null }] },
+ worktreesByRepo: {
+ repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/tmp/wt-1', hostId: 'local' }]
+ },
+ repos: [{ id: 'repo1', connectionId: null, executionHostId: 'local' }],
+ runtimeEnvironments: [{ id: 'hub-a' }],
+ settings: { ...mockStoreState.settings, activeRuntimeEnvironmentId: 'hub-a' }
+ } as StoreState
+
+ connectPanePty(
+ createPane(1) as never,
+ createManager(1) as never,
+ createDeps({ worktreeId: setupWorktreeId }) as never
+ )
+
+ expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('hub-a', expect.any(Object))
+ })
+
+ it('keeps the floating terminal local even while a runtime is active', async () => {
+ const { connectPanePty } = await import('./pty-connection')
+ const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
+ const { createIpcPtyTransport } = await import('./pty-transport')
+ const transport = createMockTransport()
+ transportFactoryQueue.push(transport)
+ mockStoreState = {
+ ...mockStoreState,
+ tabsByWorktree: { 'global-floating-terminal': [{ id: 'tab-1', ptyId: null }] },
+ worktreesByRepo: {
+ repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/tmp/wt-1', hostId: 'local' }]
+ },
+ repos: [{ id: 'repo1', connectionId: null, executionHostId: 'local' }],
+ runtimeEnvironments: [{ id: 'hub-a' }],
+ settings: { ...mockStoreState.settings, activeRuntimeEnvironmentId: 'hub-a' }
+ } as StoreState
+
+ connectPanePty(
+ createPane(1) as never,
+ createManager(1) as never,
+ createDeps({ worktreeId: 'global-floating-terminal' }) as never
+ )
+
+ expect(createIpcPtyTransport).toHaveBeenCalled()
+ expect(createRemoteRuntimePtyTransport).not.toHaveBeenCalled()
+ })
+
it('routes a HUB-owned SSH PTY wake hint through the HUB without direct SSH', async () => {
const { connectPanePty } = await import('./pty-connection')
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts
index 8a410c4e06a..40785b1cca7 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.ts
@@ -35,7 +35,7 @@ import { createIpcPtyTransport } from './pty-transport'
import { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport'
import { toAgentLaunchPreferences } from '@/runtime/agent-session-create-operation'
import { createUnresolvedOwnerPtyTransport } from './unresolved-owner-pty-transport'
-import { resolveWorktreeOperationRouteResult } from '@/lib/worktree-operation-route'
+import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route'
import { getConnectionId } from '@/lib/connection-context'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import {
@@ -3205,11 +3205,13 @@ export function connectPanePty(
deps.restoredLeafId && deps.restoredPtyIdByLeafId
? (deps.restoredPtyIdByLeafId[deps.restoredLeafId] ?? null)
: null
- const operationRouteResolution = resolveWorktreeOperationRouteResult(state, deps.worktreeId)
- const explicitRuntimeEnvironmentId =
- operationRouteResolution.kind === 'resolved'
- ? operationRouteResolution.route.runtimeEnvironmentId
- : null
+ // Why: the floating terminal and inline setup/onboarding terminals are host-agnostic synthetic
+ // ids with no worktree/repo row, so the strict owner resolver reports them as unresolved. The
+ // shared terminal router scopes them to their floating owner (local for the floating terminal,
+ // the active runtime for setup terminals so remote skill installs land there) and returns null
+ // only for a genuinely unknown/stale worktree that must fail closed (#9994).
+ const terminalWorktreeRoute = resolveTerminalWorktreeRoute(state, deps.worktreeId)
+ const explicitRuntimeEnvironmentId = terminalWorktreeRoute?.runtimeEnvironmentId ?? null
// Why: paired-web worktrees retain HUB execution identity; their runtime-scoped mirrored pane is the session-level transport owner.
const mirroredRuntimeOwners = new Set(
isWebTerminalSurfaceTabId(deps.tabId)
@@ -3221,7 +3223,7 @@ export function connectPanePty(
const mirroredRuntimeEnvironmentId = mirroredRuntimeOwners.values().next().value ?? null
const terminalOwnerUnresolved =
mirroredRuntimeOwners.size > 1 ||
- (operationRouteResolution.kind !== 'resolved' && !mirroredRuntimeEnvironmentId)
+ (terminalWorktreeRoute === null && !mirroredRuntimeEnvironmentId)
const runtimeEnvironmentId = explicitRuntimeEnvironmentId
? explicitRuntimeEnvironmentId
: mirroredRuntimeEnvironmentId
diff --git a/src/renderer/src/lib/terminal-worktree-route.test.ts b/src/renderer/src/lib/terminal-worktree-route.test.ts
new file mode 100644
index 00000000000..396c3df9896
--- /dev/null
+++ b/src/renderer/src/lib/terminal-worktree-route.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from 'vitest'
+import type { AppState } from '@/store/types'
+import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
+import { brandEphemeralSetupTerminalWorktreeId } from '../../../shared/ephemeral-setup-terminal-worktree-id'
+import { folderWorkspaceKey } from '../../../shared/workspace-scope'
+import { resolveTerminalWorktreeRoute } from './terminal-worktree-route'
+
+const EPHEMERAL_ID = brandEphemeralSetupTerminalWorktreeId(
+ 'settings-mobile-emulator-orca-cli-skill-terminal'
+)
+
+// A realistic local-only store: one real repo/worktree, hydrated empty runtime catalog.
+function localState(overrides: Partial = {}): AppState {
+ return {
+ repos: [{ id: 'repo-1', connectionId: null, executionHostId: 'local' }],
+ worktreesByRepo: { 'repo-1': [{ id: 'repo-1::/w', repoId: 'repo-1', hostId: 'local' }] },
+ runtimeEnvironments: [],
+ runtimeEnvironmentCatalogHydrated: true,
+ removedRuntimeEnvironmentIds: new Set(),
+ ...overrides
+ } as unknown as AppState
+}
+
+describe('resolveTerminalWorktreeRoute', () => {
+ it('keeps the floating terminal local', () => {
+ expect(resolveTerminalWorktreeRoute(localState(), FLOATING_TERMINAL_WORKTREE_ID)).toEqual({
+ runtimeEnvironmentId: null
+ })
+ })
+
+ it('routes an ephemeral setup terminal locally when no runtime is active', () => {
+ // Regression: previously returned null (unroutable), producing the
+ // "Workspace identity is ambiguous across hosts" error transport (#9994 fallout).
+ expect(resolveTerminalWorktreeRoute(localState(), EPHEMERAL_ID)).toEqual({
+ runtimeEnvironmentId: null
+ })
+ })
+
+ it('scopes an ephemeral setup terminal to the single active runtime for remote skill installs', () => {
+ const state = localState({
+ settings: { activeRuntimeEnvironmentId: 'hub-a' },
+ runtimeEnvironments: [{ id: 'hub-a' }]
+ } as unknown as Partial)
+ expect(resolveTerminalWorktreeRoute(state, EPHEMERAL_ID)).toEqual({
+ runtimeEnvironmentId: 'hub-a'
+ })
+ })
+
+ it('does not guess a runtime for an ephemeral setup terminal when the focus is ambiguous', () => {
+ const state = localState({
+ settings: { activeRuntimeEnvironmentId: 'hub-a' },
+ runtimeEnvironments: [{ id: 'hub-a' }, { id: 'hub-b' }]
+ } as unknown as Partial)
+ expect(resolveTerminalWorktreeRoute(state, EPHEMERAL_ID)).toEqual({
+ runtimeEnvironmentId: null
+ })
+ })
+
+ it('resolves a known local worktree', () => {
+ expect(resolveTerminalWorktreeRoute(localState(), 'repo-1::/w')).toEqual({
+ runtimeEnvironmentId: null
+ })
+ })
+
+ it('still fails a genuinely unknown/stale worktree closed', () => {
+ expect(resolveTerminalWorktreeRoute(localState(), 'repo-9::/stale')).toBeNull()
+ })
+
+ it('does not treat a folder workspace as an unresolved worktree', () => {
+ expect(resolveTerminalWorktreeRoute(localState(), folderWorkspaceKey('abc-123'))).not.toBeNull()
+ })
+})
diff --git a/src/renderer/src/lib/terminal-worktree-route.ts b/src/renderer/src/lib/terminal-worktree-route.ts
index 58e900e91bd..fc001125d5b 100644
--- a/src/renderer/src/lib/terminal-worktree-route.ts
+++ b/src/renderer/src/lib/terminal-worktree-route.ts
@@ -1,4 +1,5 @@
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
+import { isEphemeralSetupTerminalWorktreeId } from '../../../shared/ephemeral-setup-terminal-worktree-id'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import type { AppState } from '@/store/types'
import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner'
@@ -22,6 +23,13 @@ export function resolveTerminalWorktreeRoute(
) {
return { runtimeEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, worktreeId) }
}
+ // Why: inline setup/onboarding terminals (skill installs, feature tips) have no worktree row,
+ // so the strict owner resolver reports them as an unresolved cross-host worktree. Scope them to
+ // the active runtime — so a remote skill install lands on that runtime — falling back to local
+ // when none is focused, instead of failing them closed.
+ if (isEphemeralSetupTerminalWorktreeId(worktreeId)) {
+ return { runtimeEnvironmentId: getSingleFocusedRuntimeEnvironmentId(state) }
+ }
const resolution = resolveWorktreeOperationRouteResult(state, worktreeId)
if (resolution.kind === 'resolved') {
return { runtimeEnvironmentId: resolution.route.runtimeEnvironmentId }
From 434f43328717a63515b6c01551bbe3037ad427d3 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Thu, 23 Jul 2026 12:48:59 +0000
Subject: [PATCH 05/32] 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 1f82e4c1b53..aa7a1305c9f 100644
--- a/docs/assets/readme-downloads.svg
+++ b/docs/assets/readme-downloads.svg
@@ -1,5 +1,5 @@
-