fix(terminal): make DECSET 2031 subscriptions silent (#13904)

fish arms `CSI ?2031h` before painting each prompt and withdraws it when it
hands the tty to a child — a ~1ms window. Orca answered that subscribe with
`CSI ?997;Nn` across a 1-3ms renderer hop, so the reply landed after the
withdrawal and was read as stdin by the next child, corrupting `brew`/`npx`
`[y/N]` prompts.

The reply is not stale by Orca's own view when written (measured
staleReplies: 0), so no suppress-the-stale-reply scheme can close this — the
information needed to suppress does not exist yet. Nothing asked for the reply
either. The Contour spec says a terminal "should only send out the DSR when the
palette has been updated"; Ghostty (Termio.zig:729 — force=true reachable only
from the ?996n DSR), iTerm2 (VT100Terminal.m:995 — flag only) and xterm.js
(InputHandler.ts:2035 — flag only) all emit nothing on the DECSET. So stop
entering the race: record the subscription, answer nothing.

Of 17 real programs measured under a pty, only fish, tmux, claude and opencode
subscribe; none block on a reply, and answering produces one redundant palette
re-query and zero rendering difference. tmux is the only one that sends `?996n`,
which Orca still answers.

- Subscribes are record-only at all four emitters (live scan, hidden-gate fact,
  parked byte watcher, parked responder — the last is deleted, it only replied).
- `?996n` answers, the subscription registry, and the theme-flip push are
  unchanged. `paneLastThemeMode` is still seeded at subscribe so the next
  appearance re-apply is not read as a flip.
- Replay grammar carries `?2031l` alongside `?2031h`, so a late-attaching remote
  client no longer registers a subscription the TUI already retired.

Also closes fish-integration gaps found alongside: `unset` (which fish lacks)
becomes `set -e` on paths parsed by the client's login shell, `config.fish` is
parsed for agent-home detection, and bracketed-paste startup delivery is made
consistent across local/daemon/relay.

Regression test drives real fish 4.7.1 under node-pty and asserts on what the
child process reads; it fails against pre-fix code with the exact payload from
the issue. CI installs fish 4 and fails loudly rather than skipping.

Closes #9993

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-11 21:16:36 -07:00
committed by GitHub
co-authored by Orca
parent 137e724119
commit 5ea7df1a5b
51 changed files with 2199 additions and 494 deletions
+47 -1
View File
@@ -176,6 +176,12 @@ jobs:
shell_contracts:
name: shell contracts
runs-on: ubuntu-latest
env:
# Why: the suites below gate their live fish tests on the binary, which is
# right on a developer machine and wrong here — this job is a required check
# and its fish lane is the only end-to-end guard for #9993, so a skip would
# report green with nothing exercised. Turns those skips into failures.
ORCA_REQUIRE_FISH: '1'
steps:
- name: Checkout
@@ -186,8 +192,46 @@ jobs:
# Why fish: shell-ready.test.ts gates its live fish test on the binary being
# present, so without this the fish barrier is only covered by config-shape
# assertions and never actually exercised.
# Why release-4: DECSET 2031 arming lives in the fish 4.0 Rust tty_handoff, and
# fish-color-scheme-child-stdin.node-pty.test.ts (#9993) needs it. Noble ships
# 3.7, so the PPA is what makes that lane real.
- name: Install zsh and fish
run: sudo apt-get update && sudo apt-get install -y zsh fish
run: |
# Why the update/PPA/fish steps are tolerant: a repo the runner image already
# ships can lack a Release file for this suite, and a failed add-apt-repository
# still leaves its list entry behind — either makes `apt-get update` exit
# non-zero and would red this required check over something unrelated to the
# PR. Every fish outcome is judged by the version gate below instead, so only
# the zsh install (which has no such gate) stays fatal here.
sudo apt-get update || true
# Why retry only here: adding the PPA is the network-flaky step, and the
# version gate below is fatal, so a transient Launchpad blip would
# otherwise red a required check on PRs unrelated to shells.
for attempt in 1 2 3; do
sudo add-apt-repository -y ppa:fish-shell/release-4 && break
echo "add-apt-repository attempt ${attempt} failed; retrying" >&2
sudo add-apt-repository -y -r ppa:fish-shell/release-4 || true
sleep 5
done
sudo apt-get update || true
# Why both shells on one line: pr-workflow-parallelism.test.mjs parses only the
# first install command in this step to prove the lane really installs them.
sudo apt-get install -y zsh fish
# Separate from the install so the failure names the contract, not an apt error.
# ORCA_REQUIRE_FISH re-checks this at test time; this step just fails in seconds
# instead of after a full dependency install.
- name: Require fish 4+
run: |
version="$(fish --version 2>/dev/null || true)"
major="${version##*version }"
major="${major%%.*}"
case "$major" in '' | *[!0-9]*) major=0 ;; esac
echo "${version:-<fish not installed>}"
if [ "$major" -lt 4 ]; then
echo "::error::shell contracts needs fish 4+ (DECSET 2031 arming, #9993) but got '${version:-none}'. Fix the ppa:fish-shell/release-4 install rather than letting the fish lane skip." >&2
exit 1
fi
- uses: ./.github/actions/install-node-dependencies
with:
@@ -201,6 +245,7 @@ jobs:
src/main/providers/local-pty-shell-ready.test.ts \
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
src/shared/posix-command-path-lookup.test.ts
test:
@@ -235,6 +280,7 @@ jobs:
--exclude=src/main/providers/local-pty-shell-ready.test.ts \
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
--exclude=tests/e2e/cross-version-wire/** \
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
@@ -111,6 +111,7 @@ function isShellStartupHomeOverride(value: unknown): value is CodexShellStartupH
typeof context.home === 'string' &&
context.home.length > 0 &&
(context.shell === undefined || typeof context.shell === 'string') &&
(context.configHome === undefined || typeof context.configHome === 'string') &&
typeof context.codexHome === 'string' &&
context.codexHome.length > 0
)
@@ -207,6 +208,7 @@ function shellStartupHomeOverridesEqual(
return (
left?.home === right?.home &&
left?.shell === right?.shell &&
left?.configHome === right?.configHome &&
left?.codexHome === right?.codexHome
)
}
+56 -1
View File
@@ -1,4 +1,4 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, sep } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -11,9 +11,15 @@ import {
import { __resetShellStartupEnvCache } from '../pty/shell-startup-env'
const temporaryHomes: string[] = []
const savedConfigHome = process.env.XDG_CONFIG_HOME
afterEach(() => {
__resetShellStartupEnvCache()
if (savedConfigHome === undefined) {
delete process.env.XDG_CONFIG_HOME
} else {
process.env.XDG_CONFIG_HOME = savedConfigHome
}
for (const path of temporaryHomes.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
@@ -56,6 +62,9 @@ describe('hasCustomCodexHomeOverride', () => {
temporaryHomes.push(paneHome)
writeFileSync(join(paneHome, '.zshrc'), 'export CODEX_HOME="$HOME/custom-codex-home"\n')
// Why cleared: the context records XDG_CONFIG_HOME, so a developer machine
// that sets one would otherwise change the recorded shape.
delete process.env.XDG_CONFIG_HOME
expect(hasCustomCodexHomeOverrideForLaunch({ HOME: paneHome, SHELL: '/bin/zsh' })).toBe(true)
const override = getCustomCodexHomeOverrideForLaunch({
HOME: paneHome,
@@ -75,4 +84,50 @@ describe('hasCustomCodexHomeOverride', () => {
).toBe(true)
}
)
// Why: a fish user who exports XDG_CONFIG_HOME from config.fish never passes it to
// a Dock-launched Orca, so the launch env is the only place it appears. Reading the
// main process env instead scans ~/.config and misses the override entirely.
it.skipIf(process.platform === 'win32')(
'resolves a fish override under the launch env XDG_CONFIG_HOME, not the process one',
() => {
const paneHome = mkdtempSync(join(tmpdir(), 'orca-codex-fish-home-'))
temporaryHomes.push(paneHome)
const configHome = join(paneHome, 'xdg')
mkdirSync(join(configHome, 'fish'), { recursive: true })
writeFileSync(
join(configHome, 'fish', 'config.fish'),
'set -gx CODEX_HOME "$HOME/custom-codex-home"\n'
)
// The decoy fish reads only if XDG_CONFIG_HOME is ignored.
mkdirSync(join(paneHome, '.config', 'fish'), { recursive: true })
writeFileSync(
join(paneHome, '.config', 'fish', 'config.fish'),
'set -gx CODEX_HOME /wrong-default-config-home\n'
)
delete process.env.XDG_CONFIG_HOME
const launchEnv = {
HOME: paneHome,
SHELL: '/opt/homebrew/bin/fish',
XDG_CONFIG_HOME: configHome
}
const override = getCustomCodexHomeOverrideForLaunch(launchEnv)
expect(override).toEqual({
source: 'shell-startup',
context: {
home: paneHome,
shell: '/opt/homebrew/bin/fish',
configHome,
codexHome: join(paneHome, 'custom-codex-home')
}
})
// And the recorded configHome is what a later re-check resolves against.
expect(
override?.source === 'shell-startup' &&
shellStartupCodexHomeOverrideMatches(override.context)
).toBe(true)
}
)
})
+11 -3
View File
@@ -5,6 +5,8 @@ import { readShellStartupEnvVar } from '../pty/shell-startup-env'
export type CodexShellStartupHomeOverride = {
home: string
shell?: string
/** Why recorded: fish reads config under it, so re-reads must use the same root. */
configHome?: string
codexHome: string
}
@@ -54,7 +56,10 @@ export function getCustomCodexHomeOverrideForLaunch(
}
const home = launchEnv ? getLaunchEnvValue(launchEnv, 'HOME') : process.env.HOME
const shell = launchEnv ? getLaunchEnvValue(launchEnv, 'SHELL') : process.env.SHELL
const shellCodexHome = readShellStartupEnvVar('CODEX_HOME', home, shell)
const configHome = launchEnv
? getLaunchEnvValue(launchEnv, 'XDG_CONFIG_HOME')
: process.env.XDG_CONFIG_HOME
const shellCodexHome = readShellStartupEnvVar('CODEX_HOME', home, shell, configHome)
if (!home || !shellCodexHome || !hasCustomCodexHomeOverride({ CODEX_HOME: shellCodexHome })) {
return null
}
@@ -63,6 +68,7 @@ export function getCustomCodexHomeOverrideForLaunch(
context: {
home,
...(shell ? { shell } : {}),
...(configHome ? { configHome } : {}),
codexHome: shellCodexHome
}
}
@@ -85,7 +91,8 @@ export function shellStartupCodexHomeOverrideMatches(
const currentCodexHome = readShellStartupEnvVar(
'CODEX_HOME',
currentContext.home,
currentContext.shell
currentContext.shell,
currentContext.configHome
)
return Boolean(
currentCodexHome &&
@@ -101,13 +108,14 @@ export function shellStartupCodexHomeOverrideContextsEqual(
return (
normalizePathForComparison(left.home) === normalizePathForComparison(right.home) &&
left.shell === right.shell &&
left.configHome === right.configHome &&
normalizePathForComparison(left.codexHome) === normalizePathForComparison(right.codexHome)
)
}
function getLaunchEnvValue(
launchEnv: NodeJS.ProcessEnv,
key: 'CODEX_HOME' | 'ORCA_CODEX_HOME' | 'HOME' | 'SHELL'
key: 'CODEX_HOME' | 'ORCA_CODEX_HOME' | 'HOME' | 'SHELL' | 'XDG_CONFIG_HOME'
): string | undefined {
return Object.hasOwn(launchEnv, key) ? launchEnv[key] : process.env[key]
}
+8 -2
View File
@@ -5,6 +5,7 @@ import { join } from 'node:path'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import type * as ShellReadyModule from './shell-ready'
import { getZshShellReadyMarkerRegistrationBlock } from '../shell-templates'
import { fishRequirementViolation, resolveFishBinary } from '../../shared/fish-binary-requirement'
async function importFreshShellReady(): Promise<typeof ShellReadyModule> {
vi.resetModules()
@@ -16,8 +17,8 @@ const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version'])
const itWithBash = hasBash ? it : it.skip
const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0
const itWithZsh = hasZsh ? it : it.skip
const hasFish = process.platform !== 'win32' && spawnSync('fish', ['--version']).status === 0
const itWithFish = hasFish ? it : it.skip
const FISH = resolveFishBinary()
const itWithFish = FISH.available ? it : it.skip
const SHELL_READY_MARKER_OUTPUT = '\x1b]777;orca-shell-ready\x07'
@@ -166,6 +167,11 @@ function expectFinalZdotdirRestoreContext(content: string) {
}
describePosix('daemon shell-ready launch config', () => {
// Always runs, so the CI lane cannot report green with every live fish test skipped.
it('has the fish the live tests need when CI requires one', () => {
expect(fishRequirementViolation(FISH)).toBeNull()
})
let previousUserDataPath: string | undefined
let previousOrcaOrigZdotdir: string | undefined
let userDataPath: string
+1 -2
View File
@@ -18,8 +18,7 @@ export type HiddenPtyDeliveryGateSettings = Pick<
const hiddenRendererPtys = new Set<string>()
// Why: sidecar consumers (paste-draft pacing, background agent launches,
// automation observers, and the kill-switch-off parked 2031 responder) need
// live bytes even while no visible view exists. Any
// automation observers) need live bytes even while no visible view exists. Any
// registered interest suppresses the gate for that PTY.
const deliveryInterestRendererPtys = new Set<string>()
// Why: reveal must restore from the model only when bytes were actually
+4 -19
View File
@@ -132,7 +132,7 @@ import {
import { isRemoteAgentHooksEnabled } from '../../shared/agent-hook-relay'
import { createTerminalSessionStateSaveFailureMessage } from '../../shared/terminal-session-state-save-failure'
import { RendererTerminalSerializerReadiness } from './renderer-terminal-serializer-readiness'
import { readShellStartupEnvVar } from '../pty/shell-startup-env'
import { readSessionShellStartupEnvVar } from '../pty/shell-startup-env'
import {
isTerminalLeafId,
makePaneKey,
@@ -1507,11 +1507,7 @@ function resolvePiAgentSourceDir(
if (kind === 'prime-agent') {
return (
readEnvWithProcessFallback(baseEnv, primaryKey) ??
readShellStartupEnvVar(
primaryKey,
baseEnv.HOME ?? process.env.HOME,
baseEnv.SHELL ?? process.env.SHELL
)
readSessionShellStartupEnvVar(primaryKey, baseEnv)
)
}
@@ -1526,11 +1522,7 @@ function resolvePiAgentSourceDir(
return publicDir
}
return readShellStartupEnvVar(
primaryKey,
baseEnv.HOME ?? process.env.HOME,
baseEnv.SHELL ?? process.env.SHELL
)
return readSessionShellStartupEnvVar(primaryKey, baseEnv)
}
function resolveScopedPiAgentSourceDir(
@@ -1684,14 +1676,7 @@ function resolveOpenCodeSourceConfigDir(baseEnv: Record<string, string>): string
return undefined
}
return (
configDir ??
readShellStartupEnvVar(
'OPENCODE_CONFIG_DIR',
baseEnv.HOME ?? process.env.HOME,
baseEnv.SHELL ?? process.env.SHELL
)
)
return configDir ?? readSessionShellStartupEnvVar('OPENCODE_CONFIG_DIR', baseEnv)
}
/**
+7 -2
View File
@@ -13,6 +13,7 @@ import { existsSync } from 'node:fs'
import * as pty from 'node-pty'
import { getDefaultWslDistro, parseWslPath, isWslAvailableAsync } from '../wsl'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission'
import {
injectHistoryEnv,
updateHistFileForFallback,
@@ -1048,10 +1049,14 @@ export class LocalPtyProvider implements IPtyProvider {
ptyDisposables.set(id, disposables)
if (args.command && !startupCommandDeliveredInShellArgs) {
// Why: only POSIX bash/zsh have bracketed-paste armed so multiline startup prompts paste literally; others use raw submit.
// Why: shells with bracketed paste armed take a multiline startup prompt literally; others use raw submit.
const spawnedShellName = getSpawnedShellName(shellPath).toLowerCase()
const bracketedPasteSafe =
process.platform !== 'win32' && (spawnedShellName === 'bash' || spawnedShellName === 'zsh')
process.platform !== 'win32' &&
isBracketedPasteSafeShell({
shellName: spawnedShellName,
waitsForShellReady: shellReadyLaunch?.supportsReadyMarker === true
})
writeStartupCommandWhenShellReady(
shellReadyPromise,
proc,
+1 -1
View File
@@ -433,7 +433,7 @@ export function writeStartupCommandWhenShellReady(
proc: pty.IPty,
startupCommand: string,
onExit: (cleanup: () => void) => void,
// Why: only Orca-wrapped bash/zsh have bracketed-paste active; other shells use the raw path to avoid echoing the ESC[200~ markers.
// Why: only shells with bracketed-paste active (see isBracketedPasteSafeShell) accept the wrapper; others use the raw path so ESC[200~ isn't echoed.
options: { bracketedPasteSafe?: boolean } = {}
): void {
let sent = false
+235 -3
View File
@@ -1,28 +1,41 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { existsSyncMock, readFileSyncMock } = vi.hoisted(() => ({
const { existsSyncMock, readFileSyncMock, readdirSyncMock } = vi.hoisted(() => ({
existsSyncMock: vi.fn(),
readFileSyncMock: vi.fn()
readFileSyncMock: vi.fn(),
readdirSyncMock: vi.fn()
}))
vi.mock('fs', () => ({
existsSync: existsSyncMock,
readFileSync: readFileSyncMock
readFileSync: readFileSyncMock,
readdirSync: readdirSyncMock
}))
import {
__resetShellStartupEnvCache,
isShellStartupEnvProbeSupported,
readSessionShellStartupEnvVar,
readShellStartupEnvVar
} from './shell-startup-env'
describe('readShellStartupEnvVar', () => {
const originalPlatform = process.platform
const originalShell = process.env.SHELL
// Why pinned: the fish branch defaults configHome to process.env.XDG_CONFIG_HOME, which CI
// runners set and dev machines usually do not — leaving it ambient makes these mocked paths
// resolve differently per host.
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME
beforeEach(() => {
delete process.env.XDG_CONFIG_HOME
existsSyncMock.mockReset()
readFileSyncMock.mockReset()
readdirSyncMock.mockReset()
// Why: only the fish branch lists a directory; every other case must see ENOENT.
readdirSyncMock.mockImplementation(() => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
})
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
process.env.SHELL = '/bin/zsh'
__resetShellStartupEnvCache()
@@ -35,6 +48,11 @@ describe('readShellStartupEnvVar', () => {
} else {
process.env.SHELL = originalShell
}
if (originalXdgConfigHome === undefined) {
delete process.env.XDG_CONFIG_HOME
} else {
process.env.XDG_CONFIG_HOME = originalXdgConfigHome
}
})
function mockStartupFiles(files: Record<string, string>) {
@@ -326,4 +344,218 @@ describe('readShellStartupEnvVar', () => {
mockStartupFiles({ '.zshrc': '# export OPENCODE_CONFIG_DIR=/from-comment\n' })
expect(readShellStartupEnvVar('OPENCODE_CONFIG_DIR', '/home/alice')).toBeUndefined()
})
describe('fish', () => {
const FISH = '/opt/homebrew/bin/fish'
function mockFishFiles(files: Record<string, string>, snippets: string[] = []) {
readdirSyncMock.mockImplementation((dir: string) => {
if (dir === '/home/alice/.config/fish/conf.d' || dir === '/cfg/fish/conf.d') {
return snippets
}
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' })
})
mockStartupFiles(files)
}
it('reads an exported set from config.fish', () => {
mockFishFiles({
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /home/alice/.codex\n'
})
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/home/alice/.codex')
})
it('lets config.fish win over a conf.d snippet, matching fish source order', () => {
mockFishFiles(
{
'/home/alice/.config/fish/conf.d/10-agents.fish': 'set -gx CODEX_HOME /from/confd\n',
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /from/config\n'
},
['10-agents.fish']
)
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/from/config')
})
it('sources conf.d snippets in filename order', () => {
mockFishFiles(
{
'/home/alice/.config/fish/conf.d/aaa.fish': 'set -gx CODEX_HOME /from/aaa\n',
'/home/alice/.config/fish/conf.d/zzz.fish': 'set -gx CODEX_HOME /from/zzz\n'
},
['zzz.fish', 'aaa.fish', 'notes.txt']
)
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/from/zzz')
})
it('honors XDG_CONFIG_HOME', () => {
mockFishFiles({ '/cfg/fish/config.fish': 'set -gx CODEX_HOME /from/xdg\n' })
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH, '/cfg')).toBe('/from/xdg')
})
// Why also via the env: the argument defaults to process.env.XDG_CONFIG_HOME, so without
// this every other fish case here silently depends on the host not exporting it.
it('defaults configHome to XDG_CONFIG_HOME from the environment', () => {
process.env.XDG_CONFIG_HOME = '/cfg'
__resetShellStartupEnvCache()
mockFishFiles({ '/cfg/fish/config.fish': 'set -gx CODEX_HOME /from/env-xdg\n' })
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/from/env-xdg')
})
it('ignores sets that are not exported', () => {
mockFishFiles({
'/home/alice/.config/fish/config.fish':
'set -g CODEX_HOME /global\nset -l CODEX_HOME /local\nset CODEX_HOME /plain\n'
})
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBeUndefined()
})
it('accepts every fish export spelling', () => {
for (const line of [
'set -x CODEX_HOME /a',
'set -xg CODEX_HOME /a',
'set -gx CODEX_HOME /a',
'set -Ux CODEX_HOME /a',
'set --export --global CODEX_HOME /a'
]) {
__resetShellStartupEnvCache()
mockFishFiles({ '/home/alice/.config/fish/config.fish': `${line}\n` })
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/a')
}
})
it('expands $HOME in double quotes and keeps single quotes literal', () => {
mockFishFiles({
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME "$HOME/.codex" # note\n'
})
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('/home/alice/.codex')
__resetShellStartupEnvCache()
mockFishFiles({
'/home/alice/.config/fish/config.fish': "set -gx CODEX_HOME '$HOME/.codex'\n"
})
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBe('$HOME/.codex')
})
it('does not read zsh or bash startup files for a fish user', () => {
mockFishFiles({
'/home/alice/.zshrc': 'export CODEX_HOME=/from/zsh\n',
'/home/alice/.bash_profile': 'export CODEX_HOME=/from/bash\n'
})
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBeUndefined()
})
it('ignores a commented assignment', () => {
mockFishFiles({
'/home/alice/.config/fish/config.fish': '# set -gx CODEX_HOME /from-comment\n'
})
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBeUndefined()
})
it('does not match a different variable with the same prefix', () => {
mockFishFiles({
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME_BACKUP /backup\n'
})
expect(readShellStartupEnvVar('CODEX_HOME', '/home/alice', FISH)).toBeUndefined()
})
})
// Why these matter: a caller that plumbs HOME/SHELL but forgets XDG_CONFIG_HOME
// reads a different fish config than the shell will, so the same user gets one
// answer locally and another over relay.
describe('readSessionShellStartupEnvVar', () => {
const FISH = '/opt/homebrew/bin/fish'
const savedConfigHome = process.env.XDG_CONFIG_HOME
const savedHome = process.env.HOME
afterEach(() => {
restoreEnv('XDG_CONFIG_HOME', savedConfigHome)
restoreEnv('HOME', savedHome)
})
function restoreEnv(key: string, value: string | undefined) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
it("prefers the session env's XDG_CONFIG_HOME over the main process's", () => {
process.env.XDG_CONFIG_HOME = '/main-process-cfg'
mockStartupFiles({
'/session-cfg/fish/config.fish': 'set -gx CODEX_HOME /from/session\n',
'/main-process-cfg/fish/config.fish': 'set -gx CODEX_HOME /from/main-process\n'
})
expect(
readSessionShellStartupEnvVar('CODEX_HOME', {
HOME: '/home/alice',
SHELL: FISH,
XDG_CONFIG_HOME: '/session-cfg'
})
).toBe('/from/session')
})
it('falls back to the main process XDG_CONFIG_HOME when the session env lacks one', () => {
process.env.XDG_CONFIG_HOME = '/main-process-cfg'
mockStartupFiles({
'/main-process-cfg/fish/config.fish': 'set -gx CODEX_HOME /from/main-process\n'
})
expect(
readSessionShellStartupEnvVar('CODEX_HOME', { HOME: '/home/alice', SHELL: FISH })
).toBe('/from/main-process')
})
it("falls back to fish's own ~/.config default when neither env has one", () => {
delete process.env.XDG_CONFIG_HOME
mockStartupFiles({
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /from/default\n'
})
expect(
readSessionShellStartupEnvVar('CODEX_HOME', { HOME: '/home/alice', SHELL: FISH })
).toBe('/from/default')
})
it('resolves against the session HOME, not the main process HOME', () => {
delete process.env.XDG_CONFIG_HOME
process.env.HOME = '/home/root-user'
mockStartupFiles({
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME "$HOME/.codex"\n',
'/home/root-user/.config/fish/config.fish': 'set -gx CODEX_HOME /from/wrong-home\n'
})
expect(
readSessionShellStartupEnvVar('CODEX_HOME', { HOME: '/home/alice', SHELL: FISH })
).toBe('/home/alice/.codex')
})
it('lets an explicit shell override beat the session SHELL', () => {
delete process.env.XDG_CONFIG_HOME
mockStartupFiles({
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /from/fish\n',
'/home/alice/.zshrc': 'export CODEX_HOME=/from/zsh\n'
})
expect(
readSessionShellStartupEnvVar(
'CODEX_HOME',
{ HOME: '/home/alice', SHELL: '/bin/zsh' },
FISH
)
).toBe('/from/fish')
})
it('falls back to the main process HOME and SHELL with no session env at all', () => {
delete process.env.XDG_CONFIG_HOME
process.env.HOME = '/home/alice'
process.env.SHELL = FISH
mockStartupFiles({
'/home/alice/.config/fish/config.fish': 'set -gx CODEX_HOME /from/process-env\n'
})
expect(readSessionShellStartupEnvVar('CODEX_HOME', undefined)).toBe('/from/process-env')
})
})
})
+123 -23
View File
@@ -1,4 +1,4 @@
import { existsSync, readFileSync } from 'node:fs'
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { posix } from 'node:path'
// Why: only files the user's actual shell would source. Mixing zsh and bash
@@ -11,24 +11,45 @@ const ZSH_AFTER_ENV_FILES = ['.zprofile', '.zshrc', '.zlogin']
// .bash_profile / .bash_login / .profile but intentionally does NOT force
// .bashrc. Scanning .bashrc would mirror values the live Orca bash never sees.
const BASH_LOGIN_FILES = ['.bash_profile', '.bash_login', '.profile']
// Why: fish sources conf.d/*.fish (sorted by name) before config.fish, for
// login and non-login shells alike — verified against fish 4.7.
const FISH_SNIPPET_DIR = 'conf.d'
const FISH_SNIPPET_SUFFIX = '.fish'
const FISH_CONFIG_FILE = 'config.fish'
/** Assignment grammar of a startup file: `export NAME=value` vs fish `set -gx NAME value`. */
type StartupFileSyntax = 'export' | 'fish-set'
type ShellStartupFiles = {
paths: readonly string[]
syntax: StartupFileSyntax
}
export function isShellStartupEnvProbeSupported(): boolean {
return process.platform !== 'win32'
}
function parseExportedValue(content: string, name: string, home: string): string | undefined {
const assignment = new RegExp(`^export\\s+${name}=(.+)$`)
function parseAssignedValue(
content: string,
name: string,
home: string,
syntax: StartupFileSyntax
): string | undefined {
const assignment =
syntax === 'fish-set'
? new RegExp(`^set\\s+((?:-{1,2}[A-Za-z][\\w-]*\\s+)+)${name}\\s+(.+)$`)
: new RegExp(`^export\\s+${name}=()(.+)$`)
let lastMatch: string | undefined
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim()
const match = assignment.exec(line)
if (!match?.[1]) {
if (!match?.[2] || (syntax === 'fish-set' && !fishFlagsExport(match[1] ?? ''))) {
continue
}
// Why: strip trailing unquoted `# comment` first so quoted values like
// `"$HOME/.opencode" # note` survive intact for unquoteShellValue.
const decommented = stripTrailingComment(match[1])
const decommented = stripTrailingComment(match[2])
const { text, quoted } = unquoteShellValue(decommented)
// Why: $HOME / ${HOME} / ~ expansion mimics what the live shell would
// do for double-quoted and unquoted values; single-quoted is literal.
@@ -41,6 +62,21 @@ function parseExportedValue(content: string, name: string, home: string): string
return lastMatch
}
function parseExportedValue(content: string, name: string, home: string): string | undefined {
return parseAssignedValue(content, name, home, 'export')
}
// Why: only `set -x` / `--export` reaches child processes; `set -g`, `set -l`
// and function-local sets never appear in the PTY's environment.
function fishFlagsExport(flags: string): boolean {
return flags
.trim()
.split(/\s+/)
.some((flag) =>
flag.startsWith('--') ? flag === '--export' : /^-[A-Za-z]*x[A-Za-z]*$/.test(flag)
)
}
function readStartupFile(path: string): string | null {
if (!existsSync(path)) {
return null
@@ -52,23 +88,55 @@ function readStartupFile(path: string): string | null {
}
}
function shellStartupFilePaths(home: string, shell: string | undefined): readonly string[] {
function shellStartupFiles(
home: string,
shell: string | undefined,
configHome: string | undefined
): ShellStartupFiles {
if (!shell) {
// Why: Orca's POSIX default shell is /bin/zsh when $SHELL is unset.
return zshStartupFilePaths(home)
return { paths: zshStartupFilePaths(home), syntax: 'export' }
}
const name = posix.basename(shell).toLowerCase()
if (name === 'zsh') {
return zshStartupFilePaths(home)
return { paths: zshStartupFilePaths(home), syntax: 'export' }
}
if (name === 'bash') {
return BASH_LOGIN_FILES.map((file) => posix.join(home, file))
return {
paths: BASH_LOGIN_FILES.map((file) => posix.join(home, file)),
syntax: 'export'
}
}
// Why: unsupported explicit shells (fish, nushell, custom wrappers) do not
// use Orca's zsh/bash shell-ready startup files, so scanning those files
if (name === 'fish') {
return {
paths: fishStartupFilePaths(home, configHome),
syntax: 'fish-set'
}
}
// Why: unsupported explicit shells (nushell, custom wrappers) do not use
// Orca's zsh/bash/fish shell-ready startup files, so scanning those files
// would mirror values the live PTY shell never sees.
return []
return { paths: [], syntax: 'export' }
}
function fishStartupFilePaths(home: string, configHome: string | undefined): readonly string[] {
const fishDir = posix.join(configHome?.trim() || posix.join(home, '.config'), 'fish')
return [
...fishSnippetPaths(posix.join(fishDir, FISH_SNIPPET_DIR)),
posix.join(fishDir, FISH_CONFIG_FILE)
]
}
function fishSnippetPaths(snippetDir: string): readonly string[] {
try {
return readdirSync(snippetDir)
.filter((entry) => entry.endsWith(FISH_SNIPPET_SUFFIX))
.sort()
.map((entry) => posix.join(snippetDir, entry))
} catch {
return []
}
}
function zshStartupFilePaths(home: string): readonly string[] {
@@ -139,21 +207,24 @@ const cache = new Map<string, string | undefined>()
* Limits (callers should treat the result as a hint, not authoritative):
* - Conditionals (`[[ ... ]] && export FOO=...`), sourced files, and
* `$VAR` substitution beyond `$HOME` / `${HOME}` / `~` are not evaluated.
* - Bare assignments (no `export` keyword) are ignored because POSIX shells
* do not export them to child processes.
* - Bare assignments (no `export` keyword, or fish `set` without `-x`) are
* ignored because they never reach child processes.
* - Files are scanned in shell evaluation order for the user's $SHELL family
* only (zsh OR bash, not both); unsupported explicit shells scan nothing.
* LAST matching assignment wins.
* only (zsh OR bash OR fish, never mixed); unsupported explicit shells scan
* nothing. LAST matching assignment wins.
* - fish universal variables (`set -Ux` stored in fish_variables) are only
* seen when the assignment is also written in a config file.
* - Windows is unsupported (PowerShell profile parsing is out of scope).
*
* Results are memoized per (name, home, shell) for the process lifetime —
* shell startup files do not change mid-session in any practical scenario,
* and PTY spawn is on the hot path.
* Results are memoized per (name, home, shell, configHome) for the process
* lifetime — shell startup files do not change mid-session in any practical
* scenario, and PTY spawn is on the hot path.
*/
export function readShellStartupEnvVar(
name: string,
home = process.env.HOME,
shell = process.env.SHELL
shell = process.env.SHELL,
configHome = process.env.XDG_CONFIG_HOME
): string | undefined {
if (!home || !isShellStartupEnvProbeSupported()) {
return undefined
@@ -164,20 +235,21 @@ export function readShellStartupEnvVar(
return undefined
}
const cacheKey = `${name}\0${home}\0${shell ?? ''}`
const cacheKey = `${name}\0${home}\0${shell ?? ''}\0${configHome ?? ''}`
if (cache.has(cacheKey)) {
return cache.get(cacheKey)
}
let lastMatch: string | undefined
for (const path of shellStartupFilePaths(home, shell)) {
const { paths, syntax } = shellStartupFiles(home, shell, configHome)
for (const path of paths) {
const content = readStartupFile(path)
if (content === null) {
continue
}
const match = parseExportedValue(content, name, home)
const match = parseAssignedValue(content, name, home, syntax)
if (match !== undefined) {
lastMatch = match
}
@@ -187,6 +259,34 @@ export function readShellStartupEnvVar(
return lastMatch
}
/** The env keys that decide which startup files a shell would actually source. */
export type ShellStartupEnvSource = {
HOME?: string | undefined
SHELL?: string | undefined
XDG_CONFIG_HOME?: string | undefined
}
/**
* Same probe, resolved against a session/PTY env with process.env as fallback.
*
* Why this exists rather than three inline `??` chains per call site: dropping
* XDG_CONFIG_HOME silently scans a *different* fish config than the shell will,
* so local launches and relay launches disagree about the same user's
* `set -gx` — a divergence with no visible symptom until it is wrong.
*/
export function readSessionShellStartupEnvVar(
name: string,
sessionEnv: ShellStartupEnvSource | undefined,
shellOverride?: string
): string | undefined {
return readShellStartupEnvVar(
name,
sessionEnv?.HOME ?? process.env.HOME,
shellOverride ?? sessionEnv?.SHELL ?? process.env.SHELL,
sessionEnv?.XDG_CONFIG_HOME ?? process.env.XDG_CONFIG_HOME
)
}
/**
* Test-only helper to reset the per-process cache between cases.
* Why: production callers never invalidate (rc files don't change at
+27
View File
@@ -104,4 +104,31 @@ describe('plugin overlay env source resolution', () => {
)
).toBe('/remote/original-prime')
})
// Why: the session env is the only place a fish user's XDG_CONFIG_HOME shows up
// (config.fish exports it, so no GUI-launched process inherits it). Dropping it
// here would scan ~/.config and disagree with the same lookup on the main side.
it.skipIf(process.platform === 'win32')(
'reads fish config under the session XDG_CONFIG_HOME',
() => {
const configHome = join(homeDir, 'xdg')
mkdirSync(join(configHome, 'fish'), { recursive: true })
writeFileSync(
join(configHome, 'fish', 'config.fish'),
'set -gx OPENCODE_CONFIG_DIR "$HOME/company-opencode"\n'
)
mkdirSync(join(homeDir, '.config', 'fish'), { recursive: true })
writeFileSync(
join(homeDir, '.config', 'fish', 'config.fish'),
'set -gx OPENCODE_CONFIG_DIR /wrong-default-config-home\n'
)
expect(
resolveOpenCodeSourceConfigDir(
{ HOME: homeDir, XDG_CONFIG_HOME: configHome },
'/opt/homebrew/bin/fish'
)
).toBe(join(homeDir, 'company-opencode'))
}
)
})
+4 -2
View File
@@ -1,4 +1,4 @@
import { readShellStartupEnvVar } from '../main/pty/shell-startup-env'
import { readSessionShellStartupEnvVar } from '../main/pty/shell-startup-env'
import {
PRIMARY_AGENT_DIR_ENV_BY_KIND,
SOURCE_AGENT_DIR_ENV_BY_KIND,
@@ -14,7 +14,9 @@ function readStartupEnv(
env: Record<string, string>,
shell: string | undefined
): string | undefined {
return readShellStartupEnvVar(name, env.HOME ?? process.env.HOME, shell ?? env.SHELL)
// Why the session env first: it is closer to the user's shell than the relay
// process env, and fish config lives under its XDG_CONFIG_HOME.
return readSessionShellStartupEnvVar(name, env, shell)
}
export function resolveOpenCodeSourceConfigDir(
@@ -0,0 +1,487 @@
/**
* Real-fish regression for #9993: Orca must never write `CSI ?997;Nn` into a PTY
* because fish armed DECSET 2031 around a prompt.
*
* fish toggles `?2031h ... ?2031l` in tty_handoff.rs every time it paints a prompt and
* withdraws before handing the tty to a child, so the arm window is ~1ms — shorter than
* a renderer IPC hop. Any reply lands after the withdrawal and is read by whatever owns
* the tty next: it paints as literal `?997;1n` at the prompt, or is swallowed by a child
* that reads stdin (npx / brew confirm prompts).
*
* The assertion is therefore about what a CHILD PROCESS READS, not what renders: a
* screen-level check passes while the child's stdin is still corrupted.
*
* Real production code under test: connectPanePty's live mode-2031 chunk observer. The
* mock transport writes whatever the renderer sends straight into the real fish PTY, so a
* reply-on-subscribe reaches the child exactly as it does in the app. DA1/CPR/OSC-10/11
* probes are answered by the harness because no real xterm is attached here — without
* them fish stalls ~10s on its DA1 wait and every timing claim becomes meaningless.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import type * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
fishRequirementViolation,
resolveFishBinary
} from '../../../../shared/fish-binary-requirement'
// Why a version floor and not just presence: DECSET 2031 arming lives in the Rust
// tty_handoff introduced by the fish 4.0 rewrite. An older fish cannot produce the
// subscribe at all, so the test would pass vacuously instead of guarding anything.
const FISH = resolveFishBinary(4)
const FISH_BIN = FISH.path
const itWithFish = FISH.available ? it : it.skip
const PROMPT_MARK = 'ORCA997> '
const COLOR_SCHEME_REPORT_PREFIX = '\x1b[?997'
const ARM_2031 = '\x1b[?2031h'
const WITHDRAW_2031 = '\x1b[?2031l'
const LEAF_1 = '11111111-1111-4111-8111-111111111111'
type MutableState = Record<string, unknown>
let mockStoreState: MutableState = {}
let storeSubscribers: ((state: MutableState) => void)[] = []
let transportFactoryQueue: unknown[] = []
vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() }))
vi.mock('./terminal-webgl-atlas-recovery', () => ({
scheduleTerminalWebglAtlasRecovery: vi.fn()
}))
vi.mock('@/lib/codex-stale-pane-sweep', () => ({ notifyCodexPaneBoundForStaleSweep: vi.fn() }))
vi.mock('sonner', () => ({ toast: { info: vi.fn() } }))
vi.mock('./cache-timer-seeding', () => ({ shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false) }))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => mockStoreState,
subscribe: (listener: (state: MutableState) => void) => {
storeSubscribers.push(listener)
return () => {
storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener)
}
}
}
}))
// Why: connectPanePty calls useNotificationDispatch's useCallback outside React.
vi.mock('react', async (importOriginal) => ({
...(await importOriginal<typeof React>()),
useCallback: <T>(fn: T): T => fn
}))
vi.mock('./pty-transport', () => ({
createIpcPtyTransport: vi.fn(() => {
const next = transportFactoryQueue.shift()
if (!next) {
throw new Error('No mock transport queued')
}
return next
})
}))
type ConnectCallbacks = { onData?: (data: string) => void }
/** Mock transport whose renderer→PTY writes land in the real fish master fd. */
function createPtyBackedTransport(write: (data: string) => void): {
transport: Record<string, unknown>
sent: string[]
emit: (data: string) => void
} {
const sent: string[] = []
const captured: { current: ((data: string) => void) | null } = { current: null }
const send = (data: string): boolean => {
sent.push(data)
write(data)
return true
}
const transport = {
attach: vi.fn(),
connect: vi.fn(async ({ callbacks }: { callbacks?: ConnectCallbacks }) => {
captured.current = callbacks?.onData ?? null
return 'fish-pty'
}),
disconnect: vi.fn(),
sendInput: vi.fn(send),
sendInputImmediate: vi.fn(send),
sendInputAccepted: vi.fn(async (data: string) => send(data)),
isConnected: vi.fn(() => true),
claimViewport: vi.fn(() => true),
resize: vi.fn(() => true),
getPtyId: vi.fn(() => 'fish-pty'),
getConnectionId: vi.fn(() => null)
}
return { transport, sent, emit: (data: string) => captured.current?.(data) }
}
function createPane(paneId: number): Record<string, unknown> {
const activeBuffer = { type: 'normal' as const, viewportY: 0, baseY: 0, cursorY: 0, cursorX: 0 }
const container = new EventTarget()
Object.defineProperty(container, 'dataset', { configurable: true, value: {} })
return {
id: paneId,
leafId: LEAF_1,
stablePaneId: LEAF_1,
container,
fitAddon: { fit: vi.fn(), proposeDimensions: vi.fn(() => ({ cols: 120, rows: 30 })) },
terminal: {
cols: 120,
rows: 30,
element: {},
buffer: { active: activeBuffer },
modes: { bracketedPasteMode: false, sendFocusMode: false },
options: { scrollback: 5_000, theme: { foreground: '#eeeeee', background: '#111111' } },
write: vi.fn(),
resize: vi.fn(),
clear: vi.fn(),
scrollToBottom: vi.fn(),
paste: vi.fn(),
onData: vi.fn(() => ({ dispose: vi.fn() })),
onResize: vi.fn(() => ({ dispose: vi.fn() })),
onRender: vi.fn(() => ({ dispose: vi.fn() })),
onTitleChange: vi.fn(() => ({ dispose: vi.fn() })),
hasSelection: vi.fn(() => false),
parser: {
registerCsiHandler: vi.fn(() => ({ dispose: vi.fn() })),
registerOscHandler: vi.fn(() => ({ dispose: vi.fn() }))
}
}
}
}
function createDeps(): Record<string, unknown> {
return {
tabId: 'tab-1',
worktreeId: 'wt-1',
cwd: '/tmp/wt-1',
startup: null,
restoredLeafId: null,
restoredPtyIdByLeafId: {},
paneTransportsRef: { current: new Map() },
paneMode2031Ref: { current: new Map<number, boolean>() },
paneKittyKeyboardModesRef: { current: new Map() },
paneLastThemeModeRef: { current: new Map<number, 'dark' | 'light'>() },
replayingPanesRef: { current: new Map() },
isActiveRef: { current: true },
isVisibleRef: { current: true },
onPtyExitRef: { current: vi.fn() },
onAgentExitedRef: { current: vi.fn() },
onPtyErrorRef: { current: vi.fn() },
clearTabPtyId: vi.fn(),
consumeSuppressedPtyExit: vi.fn(() => false),
isPtyShutdownPending: vi.fn(() => false),
updateTabTitle: vi.fn(),
setRuntimePaneTitle: vi.fn(),
clearRuntimePaneTitle: vi.fn(),
updateTabPtyId: vi.fn(),
markWorktreeUnread: vi.fn(),
markTerminalTabUnread: vi.fn(),
markTerminalPaneUnread: vi.fn(),
clearWorktreeUnread: vi.fn(),
clearTerminalTabUnread: vi.fn(),
clearTerminalPaneUnread: vi.fn(),
dispatchNotification: vi.fn(),
onShowSessionRestoredBanner: vi.fn(),
setCacheTimerStartedAt: vi.fn(),
syncPanePtyLayoutBinding: vi.fn(),
clearExitedPanePtyLayoutBinding: vi.fn()
}
}
function createManager(pane: Record<string, unknown>): Record<string, unknown> {
return {
setPaneGpuRendering: vi.fn(),
markPaneHasComplexScriptOutput: vi.fn(),
rebuildPaneWebgl: vi.fn(),
hasWebglRenderer: vi.fn(() => false),
getPanes: vi.fn(() => [pane]),
closePane: vi.fn(),
getActivePane: vi.fn(() => pane),
getNumericIdForLeaf: vi.fn(() => 1),
setActivePane: vi.fn()
}
}
const sleep = (msValue: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, msValue))
const countOf = (haystack: string, needle: string): number => haystack.split(needle).length - 1
async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (predicate()) {
return true
}
await sleep(10)
}
return false
}
describe('fish never receives a color-scheme report it did not query (#9993)', () => {
let configHome: string | null = null
// Always runs, so the CI lane cannot report green with the regression below skipped.
it('has the fish this suite needs when CI requires one', () => {
expect(fishRequirementViolation(FISH)).toBeNull()
})
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
transportFactoryQueue = []
storeSubscribers = []
mockStoreState = {
activeWorktreeId: 'wt-1',
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'fish-pty' }] },
ptyIdsByTabId: { 'tab-1': ['fish-pty'] },
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_1 },
activeLeafId: LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_1]: 'fish-pty' }
}
},
unreadTerminalTabs: {},
deleteStateByWorktreeId: {},
worktreesByRepo: { repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/tmp/wt-1' }] },
runtimeStatusByEnvironmentId: new Map(),
repos: [{ id: 'repo1', connectionId: null }],
projects: [],
sshConnectionStates: new Map(),
transientClearedAgentStatusConnectionIds: {},
cacheTimerByKey: {},
// Dark app mode: a pre-fix reply is `CSI ?997;1n`, the exact payload issue #9993 reports.
settings: {
theme: 'dark',
promptCacheTimerEnabled: true,
terminalMainSideEffectAuthority: false
},
codexRestartNoticeByPtyId: {},
deferredSshReconnectTargets: [],
deferredSshSessionIdsByTabId: {},
removeDeferredSshReconnectTarget: vi.fn(),
removeDeferredSshSessionId: vi.fn(),
consumePendingColdRestore: vi.fn(() => null),
consumePendingSnapshot: vi.fn(() => null),
runtimePaneTitlesByTabId: {},
agentStatusByPaneKey: {},
retainedAgentsByPaneKey: {},
paneForegroundAgentByPaneKey: {},
sleepingAgentSessionsByPaneKey: {},
suppressedPtyExitIds: {},
agentLaunchConfigByPaneKey: {},
getAgentLaunchConfigForStatusEntry: vi.fn(),
getAgentLaunchConfigForStatusMetadata: vi.fn(),
clearSleepingAgentSession: vi.fn(),
registerAgentLaunchConfig: vi.fn(),
clearAgentLaunchConfig: vi.fn(),
markWorktreeUnread: vi.fn(),
observeTerminalGitHubPullRequestLink: vi.fn(),
recordTerminalInput: vi.fn(),
setAgentStatus: vi.fn(),
removeAgentStatus: vi.fn(),
dropAgentStatus: vi.fn(),
retireAgentPaneAuthority: vi.fn(),
setPaneForegroundAgent: vi.fn(),
clearPaneForegroundAgent: vi.fn(),
markTerminalTabUnread: vi.fn(),
markTerminalPaneUnread: vi.fn(),
markAgentCompletionPaneUnread: vi.fn()
}
;(globalThis as unknown as { window: unknown }).window = {
api: {
pty: {
kill: vi.fn(),
signal: vi.fn(),
listSessions: vi.fn().mockResolvedValue([]),
hasPty: vi.fn().mockResolvedValue(true),
getSize: vi.fn().mockResolvedValue(null),
reportGeometry: vi.fn(),
getMainBufferSnapshot: vi.fn().mockResolvedValue(null),
getForegroundProcess: vi.fn().mockResolvedValue(null),
inspectProcess: vi.fn().mockResolvedValue(null),
confirmForegroundProcess: vi.fn().mockResolvedValue(null),
hasChildProcesses: vi.fn().mockResolvedValue(false),
write: vi.fn(),
writeAccepted: vi.fn().mockResolvedValue(true),
setHiddenRendererPty: vi.fn(),
setPtyDeliveryInterest: vi.fn(),
ackColdRestore: vi.fn(),
onClearBufferRequest: vi.fn(() => vi.fn()),
onSerializeBufferRequest: vi.fn(() => vi.fn()),
sendSerializedBuffer: vi.fn(),
declarePendingPaneSerializer: vi.fn().mockResolvedValue(1),
settlePaneSerializer: vi.fn().mockResolvedValue(undefined),
clearPendingPaneSerializer: vi.fn().mockResolvedValue(undefined),
reportRendererSerializerReady: vi.fn().mockResolvedValue(undefined)
},
platform: { get: vi.fn(() => ({ platform: process.platform, osRelease: '' })) },
notifications: {
dispatch: vi.fn().mockResolvedValue({ delivered: true }),
playSound: vi.fn().mockResolvedValue({ played: true })
},
runtime: { restoreTerminalFit: vi.fn().mockResolvedValue({ restored: true }) },
agentStatus: { inferInterrupt: vi.fn().mockResolvedValue(false) }
},
dispatchEvent: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
callback(0)
return 1
})
globalThis.cancelAnimationFrame = vi.fn()
})
afterEach(() => {
delete (globalThis as { requestAnimationFrame?: unknown }).requestAnimationFrame
delete (globalThis as { cancelAnimationFrame?: unknown }).cancelAnimationFrame
delete (globalThis as { window?: unknown }).window
if (configHome) {
rmSync(configHome, { recursive: true, force: true })
configHome = null
}
})
itWithFish(
'writes no CSI 997 while fish arms mode 2031 around its prompt, so a child reads clean stdin',
async () => {
const { connectPanePty } = await import('./pty-connection')
const nodePty = await import('node-pty')
configHome = mkdtempSync(path.join(tmpdir(), 'orca-fish-2031-'))
mkdirSync(path.join(configHome, 'fish'), { recursive: true })
// Plain prompt, no user config: fish core toggles DEC 2031 regardless of the prompt.
writeFileSync(
path.join(configHome, 'fish/config.fish'),
[
'set -g fish_greeting ""',
`function fish_prompt; printf '${PROMPT_MARK}'; end`,
'function fish_right_prompt; end',
''
].join('\n')
)
// Echoes the first stdin LINE back escaped, so leaked control bytes are visible on
// screen. Why a line and not the first chunk: leaked replies carry no newline, so a
// once('data') child reports them alone whenever they arrive in their own read —
// which is what the issue's own `sys.stdin.readline()` repro measures.
const childScript = path.join(configHome, 'read-stdin.mjs')
writeFileSync(
childScript,
"let buffered = ''\n" +
"process.stdin.on('data', (d) => {\n" +
" buffered += d.toString('utf8')\n" +
" if (!buffered.includes('\\n')) return\n" +
" process.stdout.write('CHILD-READ:' + JSON.stringify(buffered) + '\\n')\n" +
' process.exit(0)\n' +
'})\n'
)
const term = nodePty.spawn(FISH_BIN as string, ['-l', '-i'], {
name: 'xterm-256color',
cols: 120,
rows: 30,
cwd: configHome,
env: {
PATH: process.env.PATH ?? '/usr/bin:/bin',
HOME: configHome,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
LANG: 'en_US.UTF-8',
XDG_CONFIG_HOME: configHome,
XDG_DATA_HOME: path.join(configHome, 'data'),
ORCA_NODE_BIN: process.execPath,
ORCA_CHILD_SCRIPT: childScript
}
})
let rendered = ''
const { transport, sent, emit } = createPtyBackedTransport((data) => term.write(data))
transportFactoryQueue.push(transport)
// Why answered here: no real xterm is attached, and fish blocks its first prompt ~10s
// on DA1 and re-probes every prompt. These are harness bytes, never renderer output.
term.onData((chunk) => {
rendered += chunk
if (chunk.includes('\x1b[0c') || chunk.includes('\x1b[c')) {
term.write('\x1b[?62;4;6;22c')
}
if (chunk.includes('\x1b[6n')) {
term.write('\x1b[1;1R')
}
if (chunk.includes('\x1b]10;?') || chunk.includes('\x1b]11;?')) {
term.write('\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\')
}
emit(chunk)
})
let exited = false
term.onExit(() => {
exited = true
})
try {
const pane = createPane(1)
const deps = createDeps()
const binding = connectPanePty(pane as never, createManager(pane) as never, deps as never)
expect(await waitUntil(() => rendered.includes(PROMPT_MARK), 15_000)).toBe(true)
// The arm is the settle signal.
expect(await waitUntil(() => rendered.includes(ARM_2031), 5_000)).toBe(true)
// Vacuity guard: the renderer observer must actually have seen the subscribe,
// otherwise "sent nothing" is trivially true. Checked here and not at the end,
// because the last decision races between fish's subscribe and its withdrawal.
const paneMode2031 = (deps as { paneMode2031Ref: { current: Map<number, boolean> } })
.paneMode2031Ref.current
expect(await waitUntil(() => paneMode2031.get(1) === true, 5_000)).toBe(true)
// Type-ahead is the deterministic leak shape: queue the child command while an
// external command still owns the tty, so fish repaints the prompt (`?2031h`) and
// consumes the buffered line in the same breath — handoff lands sub-millisecond
// after the subscribe, inside any reply's flight time.
term.write('sleep 0.4\r')
// Withdrawal #1: `sleep` owns the tty now, so the next line is typed ahead.
expect(await waitUntil(() => countOf(rendered, WITHDRAW_2031) >= 1, 5_000)).toBe(true)
term.write('"$ORCA_NODE_BIN" "$ORCA_CHILD_SCRIPT"\r')
// Withdrawal #2: fish re-armed for the prompt and handed the tty to the child.
expect(await waitUntil(() => countOf(rendered, WITHDRAW_2031) >= 2, 5_000)).toBe(true)
const renderedBeforeChildInput = rendered.length
// Canonical mode buffers this in the tty, so it queues behind anything already
// written there — including a reply the renderer sent during the handoff.
term.write('hello\r')
expect(
await waitUntil(
() => rendered.slice(renderedBeforeChildInput).includes('CHILD-READ:'),
10_000
)
).toBe(true)
const childRead =
rendered.slice(renderedBeforeChildInput).match(/CHILD-READ:[^\r\n]*/)?.[0] ?? ''
// The merge-blocking assertion: the child's STDIN, not the screen. A rendered-output
// check passes while npx/brew confirm prompts still eat the bytes.
// Scope note: the harness' own DA1/CPR/OSC-11 answers can still land here — that is
// the separate probe-reply family (fish asks for those; nobody asked for 997).
expect(childRead).not.toContain('997')
expect(childRead).toContain('hello')
// Nothing the renderer sent was a color-scheme report, on any path.
expect(sent.filter((data) => data.includes(COLOR_SCHEME_REPORT_PREFIX))).toEqual([])
// And fish never echoed one back as literal prompt text.
expect(rendered).not.toContain('997;1n')
binding.dispose()
} finally {
term.write('exit\r')
await waitUntil(() => exited, 3_000)
try {
term.kill()
} catch {
// already gone
}
}
},
30_000
)
})
@@ -108,19 +108,19 @@ describe('startParkedTerminalByteWatcher', () => {
async function startWatcher(
overrides: Partial<ParkedTerminalByteWatcherOptions> = {}
): Promise<{ dispose: () => void; sendInput: ReturnType<typeof vi.fn> }> {
): Promise<{ dispose: () => void; ptyWrite: ReturnType<typeof vi.fn> }> {
const { startParkedTerminalByteWatcher } = await import('./parked-terminal-byte-watcher')
const sendInput = vi.fn()
const ptyWrite = vi.fn()
;(window as unknown as { api: { pty: Record<string, unknown> } }).api.pty.write = ptyWrite
const dispose = startParkedTerminalByteWatcher({
ptyId: PTY_ID,
tabId: TAB_ID,
worktreeId: WORKTREE_ID,
leafId: LEAF_ID,
paneId: PANE_ID,
sendInput,
...overrides
})
return { dispose, sendInput }
return { dispose, ptyWrite }
}
beforeEach(() => {
@@ -360,31 +360,20 @@ describe('startParkedTerminalByteWatcher', () => {
dispose()
})
it('answers a DECSET 2031 subscribe split across chunks via sendInput', async () => {
const { dispose, sendInput } = await startWatcher()
// Regression pin (#9993): DECSET 2031 subscribes to future color changes — it is not a
// query (that is CSI ?996n). A parked tab has no view to answer with and any reply lands
// after fish withdrew the mode, painting `?997;1n` at the prompt.
it('never writes a color-scheme reply for a DECSET 2031 subscribe', async () => {
const { dispose, ptyWrite } = await startWatcher()
emit('\x1b[?20')
expect(sendInput).not.toHaveBeenCalled()
emit('31h')
expect(sendInput).toHaveBeenCalledTimes(1)
// theme=system + prefers-dark → dark reply per terminal-color-scheme-protocol.
expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
emit('\x1b[?2031l')
expect(sendInput).toHaveBeenCalledTimes(1)
expect(ptyWrite).not.toHaveBeenCalled()
dispose()
})
it('stops answering DECSET 2031 after dispose', async () => {
const { dispose, sendInput } = await startWatcher()
dispose()
emit('\x1b[?2031h')
expect(sendInput).not.toHaveBeenCalled()
})
it('observes GitHub PR links across chunk boundaries', async () => {
const { dispose } = await startWatcher()
@@ -608,8 +597,7 @@ describe('startParkedTerminalByteWatcher', () => {
//
// With the kill switch on, the watcher must not register byte parsers —
// main is the single byte parser and the watcher's policy block consumes
// pty:sideEffect facts instead. The byte sidecar stays ONLY for the 2031
// reply (query authority never moves to main); PR links arrive as facts.
// pty:sideEffect facts instead; PR links arrive as facts.
describe('with main side-effect authority on', () => {
function enableMainAuthority(): void {
mockStoreState.settings = {
@@ -875,19 +863,15 @@ describe('startParkedTerminalByteWatcher', () => {
dispose()
})
it('answers DECSET 2031 from the main 2031-subscribe fact, never the byte scan', async () => {
// Why: with the hidden-delivery gate on (default), parked PTY bytes are
// dropped in main — the fact is the only 2031 signal, and the byte
// sidecar must NOT exist (its registration would re-enable delivery).
it('stays silent on a main 2031-subscribe fact', async () => {
// Regression pin (#9993): the fact records a subscription for theme-flip
// pushes; a parked watcher must never answer it.
enableMainAuthority()
const { dispose, sendInput } = await startWatcher()
const { dispose, ptyWrite } = await startWatcher()
emit('\x1b[?2031h')
expect(sendInput).not.toHaveBeenCalled()
await dispatchFacts([{ kind: '2031-subscribe' }])
expect(sendInput).toHaveBeenCalledTimes(1)
expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
expect(ptyWrite).not.toHaveBeenCalled()
// Why: pr-link facts arrive on the channel; byte-scanning here too
// would observe every link twice.
@@ -912,7 +896,7 @@ describe('startParkedTerminalByteWatcher', () => {
expect(setHiddenRendererPty).toHaveBeenLastCalledWith(PTY_ID, false)
})
it('keeps the byte 2031 responder and no hidden bit when the gate kill switch is off', async () => {
it('sets no hidden bit and stays silent on 2031 when the gate kill switch is off', async () => {
enableMainAuthority()
mockStoreState.settings = {
...mockStoreState.settings,
@@ -922,20 +906,14 @@ describe('startParkedTerminalByteWatcher', () => {
;(
window as unknown as { api: { pty: Record<string, unknown> } }
).api.pty.setHiddenRendererPty = setHiddenRendererPty
const { dispose, sendInput } = await startWatcher()
const { dispose, ptyWrite } = await startWatcher()
// Gate off — bytes keep flowing, so the split-chunk byte scan answers.
// Gate off — bytes keep flowing, and they still elicit nothing (#9993).
emit('\x1b[?20')
expect(sendInput).not.toHaveBeenCalled()
emit('31h')
expect(sendInput).toHaveBeenCalledTimes(1)
expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
// Why: a 2031-subscribe fact must not double-fire the reply in byte
// mode — exactly one responder owns the answer at any time.
await dispatchFacts([{ kind: '2031-subscribe' }])
expect(sendInput).toHaveBeenCalledTimes(1)
expect(ptyWrite).not.toHaveBeenCalled()
expect(setHiddenRendererPty).not.toHaveBeenCalled()
dispose()
})
@@ -5,12 +5,7 @@
import { isClaudeAgent } from '../../../../shared/agent-detection'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { useAppStore } from '@/store'
import {
mode2031SequenceFor,
resolveTerminalColorSchemeMode
} from '../../../../shared/terminal-color-scheme-protocol'
import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector'
import { getSystemPrefersDark } from '@/lib/terminal-theme'
import {
AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS,
isAgentTaskCompleteOsNotificationEnabledFromState,
@@ -22,7 +17,6 @@ import {
createParkedTerminalCommandStatusPolicy,
readInFlightCommandCodeTurn
} from './parked-terminal-command-status'
import { startParkedTerminalMode2031Responder } from './parked-terminal-mode2031-responder'
import { subscribeToPtyData } from './pty-data-sidecar-subscriptions'
import { createPtyOutputProcessor } from './pty-transport'
import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate'
@@ -61,8 +55,6 @@ export type ParkedTerminalByteWatcherOptions = {
initialTitle?: string
/** Pull main's title-only snapshot when a watcher starts before its pane ever mounted (ordinary park cycles already have a title). */
restoreTitleOnRegister?: boolean
/** Out-of-band reply channel to the PTY (mode-2031 color-scheme answers). */
sendInput: (data: string) => void
}
const parkedWatcherDisposersByPtyId = new Map<string, () => void>()
@@ -70,7 +62,7 @@ const parkedWatcherDisposersByPtyId = new Map<string, () => void>()
export function startParkedTerminalByteWatcher(
options: ParkedTerminalByteWatcherOptions
): () => void {
const { ptyId, tabId, worktreeId, paneId, sendInput } = options
const { ptyId, tabId, worktreeId, paneId } = options
const remoteRuntimePty = isRemoteRuntimePtyId(ptyId)
const drivesTabTitle = options.drivesTabTitle ?? true
const paneKey = makePaneKey(tabId, options.leafId)
@@ -213,16 +205,9 @@ export function startParkedTerminalByteWatcher(
runtimeEnvironmentId: null
})
const factSideEffectAuthority = mainSideEffectAuthority || remoteRuntimePty
// Why: decided once at watcher start — it picks which 2031 responder (byte sidecar vs fact reply) exists, so it must never flip per chunk.
const hiddenDeliveryGateActive =
mainSideEffectAuthority &&
isRendererHiddenPtyDeliveryGateEnabled(useAppStore.getState().settings)
const factOwnsMode2031 = hiddenDeliveryGateActive || remoteRuntimePty
const sendMode2031Reply = (): void => {
const settings = useAppStore.getState().settings
sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())))
}
// Why (byte-parser mode only): reuse the transport's output processor to keep exact live-path parsing semantics.
// initialAgentTitle: an agent already working at park time still produces a working→idle transition.
@@ -261,20 +246,13 @@ export function startParkedTerminalByteWatcher(
onCommandCodeWorking: commandStatusPolicy.onCommandCodeWorking,
onCommandCodeDone: commandStatusPolicy.onCommandCodeDone,
onPrLink: (link) =>
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link),
// Why (gate mode only): the 2031 subscribe arrives as a fact, but the reply stays here — query authority stays with the view/watcher (invariant 6).
...(factOwnsMode2031 ? { onMode2031Subscribe: sendMode2031Reply } : {})
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link)
},
// Why: activation-deferred tabs can start a watcher before any pane restored the title; ordinary parked tabs avoid this IPC.
restoreTitleOnRegister: options.restoreTitleOnRegister === true
})
: null
// Why: no xterm answers DECSET 2031 while parked; with the gate ON, the responder's sidecar would force-feed bytes to the gated PTY, so skip it.
const stopMode2031Responder = factOwnsMode2031
? null
: startParkedTerminalMode2031Responder({ ptyId, sendInput })
// Why: parked tabs are the canonical hidden view — mark the PTY gated so main stops renderer byte delivery.
const releaseHiddenDeliveryClaim = hiddenDeliveryGateActive
? acquireHiddenRendererPtyDeliveryClaim(ptyId)
@@ -308,7 +286,6 @@ export function startParkedTerminalByteWatcher(
processor?.disposePendingSideEffectGauge()
// Why: unhide BEFORE the reveal remount registers pane handlers, so main resumes delivery and emits the restore marker the pane consumes.
releaseHiddenDeliveryClaim?.()
stopMode2031Responder?.()
unsubscribeByteParsers?.()
unregisterFactConsumer?.()
// Why: clears tracker/timer/detector state so the watcher can't fire after the revealed pane's live parsers take over.
@@ -1,124 +0,0 @@
// Why: a parked tab has no xterm, so this byte sidecar is the only thing that
// answers DECSET 2031. fish toggles 2031 on and off around every prompt, so
// answering the sticky "an h appeared" flag writes `?997;1n` into a shell that
// already handed the tty to a child (#9993).
import { beforeEach, describe, expect, it, vi } from 'vitest'
const ESC = '\x1b'
const PTY_ID = 'pty-parked-2031'
// One fish prompt cycle: subscribe, paint, hand off the tty.
const FISH_PROMPT_HANDOFF = `${ESC}[?2031h${ESC}[0m~/orca ${ESC}[32m${ESC}[0m ${ESC}[?2031l`
let sidecarWatcher: ((data: string) => void) | null = null
const unsubscribe = vi.fn()
vi.mock('./pty-data-sidecar-subscriptions', () => ({
subscribeToPtyData: (_ptyId: string, watcher: (data: string) => void) => {
sidecarWatcher = watcher
return unsubscribe
}
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({ settings: { theme: 'dark' as const } })
}
}))
vi.mock('@/lib/terminal-theme', () => ({
getSystemPrefersDark: () => true
}))
const { startParkedTerminalMode2031Responder } =
await import('./parked-terminal-mode2031-responder')
function startResponder(): { sendInput: ReturnType<typeof vi.fn>; feed: (data: string) => void } {
const sendInput = vi.fn()
startParkedTerminalMode2031Responder({ ptyId: PTY_ID, sendInput })
return {
sendInput,
feed: (data: string) => sidecarWatcher?.(data)
}
}
beforeEach(() => {
sidecarWatcher = null
vi.clearAllMocks()
})
describe('parked-tab DECSET 2031 responder honors the chunk-final state (#9993)', () => {
it('does not reply to a subscribe the same chunk withdrew', () => {
const { sendInput, feed } = startResponder()
feed(FISH_PROMPT_HANDOFF)
expect(sendInput).not.toHaveBeenCalled()
})
it('replies once to a TUI that subscribes and keeps listening', () => {
const { sendInput, feed } = startResponder()
feed(`${ESC}[?2031h`)
expect(sendInput).toHaveBeenCalledTimes(1)
expect(sendInput).toHaveBeenCalledWith(`${ESC}[?997;1n`)
})
it('stays silent across a run of fish prompts', () => {
const { sendInput, feed } = startResponder()
feed(FISH_PROMPT_HANDOFF)
feed(FISH_PROMPT_HANDOFF)
feed(FISH_PROMPT_HANDOFF)
expect(sendInput).not.toHaveBeenCalled()
})
it('replies when a TUI subscribes after a prompt cycle in the same chunk', () => {
const { sendInput, feed } = startResponder()
feed(`${FISH_PROMPT_HANDOFF}${ESC}[?2031h`)
expect(sendInput).toHaveBeenCalledTimes(1)
})
it('still answers a subscribe split across two chunks', () => {
const { sendInput, feed } = startResponder()
feed(`${ESC}[?20`)
feed('31h')
expect(sendInput).toHaveBeenCalledTimes(1)
})
it('does not reply when the withdrawal is split across two chunks', () => {
const { sendInput, feed } = startResponder()
feed(`${ESC}[?2031h prompt ${ESC}[?20`)
expect(sendInput).not.toHaveBeenCalled()
feed('31l')
expect(sendInput).not.toHaveBeenCalled()
})
it('does not reply when an unrelated private mode appends a split withdrawal', () => {
const { sendInput, feed } = startResponder()
feed(`${ESC}[?2031h prompt ${ESC}[?25`)
expect(sendInput).not.toHaveBeenCalled()
feed(';2031l')
expect(sendInput).not.toHaveBeenCalled()
})
it('replies after an ambiguous tail resolves to another mode', () => {
const { sendInput, feed } = startResponder()
feed(`${ESC}[?2031h drawing ${ESC}[?20`)
expect(sendInput).not.toHaveBeenCalled()
feed('25h')
expect(sendInput).toHaveBeenCalledTimes(1)
})
})
@@ -1,49 +0,0 @@
/**
* DECSET 2031 color-scheme responder for parked terminals (byte-scan mode).
*
* Why a dedicated byte sidecar: no xterm exists while a tab is parked, so
* nothing answers a TUI's mode-2031 theme subscription. Query authority stays
* with the view/watcher (model/view contract invariant 6), so this reply can
* never move to main. Phase 4: this subscribeToPtyData registration doubles
* as a delivery-interest signal, so it is only used while the hidden-delivery
* gate is OFF — gated parked PTYs answer from the main tracker's
* '2031-subscribe' fact instead (parked-terminal-byte-watcher.ts).
*
* Survives Phase 6 (skip-grammar deletion): mounted switch-off hidden panes
* answer 2031 from xterm once the background queue drains, but a PARKED tab
* has no xterm in any switch-off mode, and the '2031-subscribe' fact is only
* consumed while the gate is ON — this sidecar stays the only answerer here.
*/
import {
INITIAL_MODE_2031_REPLY_SCAN_STATE,
mode2031SequenceFor,
resolveTerminalColorSchemeMode,
scanMode2031ReplyDecision
} from '../../../../shared/terminal-color-scheme-protocol'
import { useAppStore } from '@/store'
import { getSystemPrefersDark } from '@/lib/terminal-theme'
import { subscribeToPtyData } from './pty-data-sidecar-subscriptions'
export type ParkedTerminalMode2031ResponderOptions = {
ptyId: string
/** Out-of-band reply channel to the PTY (mode-2031 color-scheme answers). */
sendInput: (data: string) => void
}
export function startParkedTerminalMode2031Responder(
options: ParkedTerminalMode2031ResponderOptions
): () => void {
const { ptyId, sendInput } = options
let scanState = INITIAL_MODE_2031_REPLY_SCAN_STATE
return subscribeToPtyData(ptyId, (data) => {
const result = scanMode2031ReplyDecision(scanState, data)
scanState = result.state
if (result.decision !== 'subscribed') {
return
}
// Why: reply with the resolved theme so TUIs that subscribe while parked
// still learn it before the pane is ever revealed.
const settings = useAppStore.getState().settings
sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())))
})
}
@@ -99,10 +99,8 @@ export type PtyConnectionDeps = {
syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => void
clearExitedPanePtyLayoutBinding: (paneId: number, exitedPtyId: string) => void
deferPtyInput?: (paneId: number, data: string, forward: (data: string) => void) => void
/** Records a DECSET 2031 subscription answered from main's
* '2031-subscribe' fact, mirroring the xterm CSI handler's registry write
* (paneMode2031 + last replied theme) so later theme flips push CSI 997.
* The reply itself is sent by the fact handler — query authority stays
* with the view (model/view contract invariant 6). */
recordPaneMode2031Subscription?: (paneId: number, repliedMode: 'dark' | 'light') => void
/** Records a DECSET 2031 subscription seen through main's '2031-subscribe'
* fact (paneMode2031 + the mode at subscribe time) so later theme flips push
* CSI 997. Subscribing itself is silent — see #9993. */
recordPaneMode2031Subscription?: (paneId: number, subscribedMode: 'dark' | 'light') => void
}
@@ -12244,14 +12244,13 @@ describe('connectPanePty', () => {
dataCallback('startup probe output\r\n')
expect(setHiddenRendererPty).toHaveBeenCalledWith('pty-id', true)
// The fact stays the sole 2031 responder for gate-managed PTYs.
// The fact records the subscription for gate-managed PTYs; it never answers it (#9993).
factsHandler._dispatchTerminalSideEffectBatchForTest({
ptyId: 'pty-id',
seq: 8,
facts: [{ kind: '2031-subscribe' }]
})
expect(transport.sendInput).toHaveBeenCalledTimes(1)
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/))
})
it('latches model restore from the out-of-band marker and restores on reveal', async () => {
@@ -12287,7 +12286,8 @@ describe('connectPanePty', () => {
)
})
it('answers each 2031-subscribe fact exactly once, before any hidden mark exists', async () => {
it('never answers a 2031-subscribe fact, hidden or visible', async () => {
// Regression pin (#9993): a subscribe arms future notifications, it is not a query.
enableMainAuthority()
const deps = createDeps({ isVisibleRef: { current: false } })
const { transport } = await connectHiddenPane(deps)
@@ -12298,27 +12298,23 @@ describe('connectPanePty', () => {
transportOptions.onPtySpawn?.('pty-id')
const factsHandler = await import('./terminal-side-effect-facts-handler')
// Why: the fact can outrun the hidden mark (codex startup race) and must still reply — ownership is structural, not mark-dependent.
factsHandler._dispatchTerminalSideEffectBatchForTest({
ptyId: 'pty-id',
seq: 12,
facts: [{ kind: '2031-subscribe' }]
})
expect(transport.sendInput).toHaveBeenCalledTimes(1)
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
// Why: a visible gated pane still answers via the fact — the lifecycle suppresses xterm's CSI reply for gate-managed panes.
;(deps.isVisibleRef as { current: boolean }).current = true
factsHandler._dispatchTerminalSideEffectBatchForTest({
ptyId: 'pty-id',
seq: 24,
facts: [{ kind: '2031-subscribe' }]
})
expect(transport.sendInput).toHaveBeenCalledTimes(2)
expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[?997;1n')
expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/))
expect(transport.sendInputImmediate).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/))
})
it('registers the fact-answered 2031 subscription for later theme flips', async () => {
it('registers the fact-observed 2031 subscription for later theme flips', async () => {
enableMainAuthority()
const recordPaneMode2031Subscription = vi.fn()
const deps = createDeps({
@@ -12338,16 +12334,16 @@ describe('connectPanePty', () => {
facts: [{ kind: '2031-subscribe' }]
})
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
// Why: without the registry write, maybePushMode2031Flip won't push CSI 997 after a theme change, so the TUI keeps a stale theme.
expect(recordPaneMode2031Subscription).toHaveBeenCalledWith(1, 'dark')
expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/))
})
it('retires the fact-registered subscription when the TUI withdraws it', async () => {
// The counterpart to the test above. A gated pane never sees the withdrawal bytes
// (main drops them) and both the chunk scanner and xterm's CSI handler are disabled
// for it, so this fact is the only observer that can retire the subscription. Left
// registered, the next theme flip pushes CSI 997 at the shell that replaced the TUI.
// (main drops them) and the chunk scanner is disabled for it, so this fact is the only
// observer that can retire the subscription. Left registered, the next theme flip
// pushes CSI 997 at the shell that replaced the TUI.
enableMainAuthority()
const paneMode2031Ref = { current: new Map<number, boolean>() }
const paneLastThemeModeRef = { current: new Map<number, 'dark' | 'light'>() }
@@ -12356,9 +12352,9 @@ describe('connectPanePty', () => {
paneMode2031Ref,
paneLastThemeModeRef,
// Exactly what use-terminal-pane-lifecycle wires up for this callback.
recordPaneMode2031Subscription: (paneId: number, repliedMode: 'dark' | 'light') => {
recordPaneMode2031Subscription: (paneId: number, subscribedMode: 'dark' | 'light') => {
paneMode2031Ref.current.set(paneId, true)
paneLastThemeModeRef.current.set(paneId, repliedMode)
paneLastThemeModeRef.current.set(paneId, subscribedMode)
}
})
await connectHiddenPane(deps)
@@ -12385,8 +12381,7 @@ describe('connectPanePty', () => {
})
it('leaves the chunk scanner silent on a gate-managed PTY', async () => {
// Main's '2031-subscribe' fact already answers these; a chunk-boundary reply here
// would answer the same subscribe a second time.
// Main's '2031-subscribe' fact already records these; nothing on this path replies.
enableMainAuthority()
const deps = createDeps({ isVisibleRef: { current: false } })
const { transport, dataCallback } = await connectHiddenPane(deps)
@@ -12746,6 +12741,7 @@ describe('connectPanePty', () => {
dataCallback: (data: string, meta?: { seq?: number; rawLength?: number }) => void
getMainBufferSnapshot: ReturnType<typeof vi.fn>
transport: MockTransport
deps: ReturnType<typeof createDeps>
}> {
enableMainAuthority()
const deps = createDeps({ isVisibleRef: { current: true } })
@@ -12777,7 +12773,7 @@ describe('connectPanePty', () => {
expect.any(Function)
)
pane.terminal.write.mockClear()
return { pane, dataCallback, getMainBufferSnapshot, transport }
return { pane, dataCallback, getMainBufferSnapshot, transport, deps }
}
function writtenData(pane: ReturnType<typeof createPane>): string {
@@ -12798,24 +12794,25 @@ describe('connectPanePty', () => {
expect(writtenData(pane)).toContain('NEW')
})
it('answers a 2031 subscribe on a chunk the restored snapshot drops as duplicate', async () => {
// Why the scan runs before reconciliation: the snapshot restore replays bytes
// into xterm without answering queries, so this live delivery is the only
// chance to reply. Dropping the chunk as a duplicate must not drop the query.
// Gate off so the chunk scanner (not main's fact) owns this pane's reply.
it('records a 2031 subscribe on a chunk the restored snapshot drops as duplicate', async () => {
// Why the scan runs before reconciliation: the snapshot restore replays bytes into
// xterm without tracking modes, so this live delivery is the only chance to observe
// the subscription. Dropping the chunk as a duplicate must not drop that.
// Gate off so the chunk scanner (not main's fact) owns this pane's registry.
enableMainAuthority()
mockStoreState.settings = {
...mockStoreState.settings,
terminalHiddenDeliveryGate: false
} as StoreState['settings']
const { pane, dataCallback, transport } = await restoreVisiblePaneToBaseline()
const { pane, dataCallback, transport, deps } = await restoreVisiblePaneToBaseline()
transport.sendInput.mockClear()
dataCallback(`SUB\x1b[?2031h`, { seq: 60, rawLength: 11 })
await flushAsyncTicks(8)
expect(writtenData(pane)).not.toContain('SUB')
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
expect(deps.paneMode2031Ref.current.get(1)).toBe(true)
expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/))
})
it('slices a partial overlap when raw and clean lengths match', async () => {
@@ -13496,7 +13493,7 @@ describe('connectPanePty', () => {
binding.dispose()
})
it('answers a mode 2031 subscribe once, from the raw chunk boundary', async () => {
it('records a mode 2031 subscribe from the raw chunk boundary without answering', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
@@ -13526,8 +13523,8 @@ describe('connectPanePty', () => {
// Why the scanner and not xterm's CSI handler: xterm batches PTY chunks into one
// parse, so only this layer knows the chunk ended still subscribed (#9993).
expect(transport.sendInput).toHaveBeenCalledTimes(1)
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;2n')
expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/))
expect(transport.sendInputImmediate).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/))
// The bytes still reach xterm so the emulator tracks the mode itself.
expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?2031h')
} finally {
@@ -13537,12 +13534,12 @@ describe('connectPanePty', () => {
binding.dispose()
})
// Why these three: fish 4.7.1 enables and disables 2031 around *every* prompt with no
// opt-out, so back-to-back chunks each carrying a toggle are the normal case, not an edge
// case. Each chunk owes exactly one decision, and xterm cannot make it — it parses several
// chunks in one synchronous batch, so a reply deferred to the parser is either dropped or
// answered against a subscription a later chunk already withdrew (#9993).
describe('mode 2031 replies are decided per raw PTY chunk', () => {
// Why: fish 4.7.1 enables and disables 2031 around *every* prompt with no opt-out, so
// back-to-back chunks each carrying a toggle are the normal case. Subscribing is never
// answered (#9993) — these pin that silence plus the per-chunk subscription bookkeeping
// that theme-flip pushes depend on. xterm cannot do that bookkeeping: it parses several
// chunks in one synchronous batch and only sees the net result.
describe('mode 2031 subscriptions are tracked per raw PTY chunk, never answered', () => {
async function connectVisiblePane(): Promise<{
transport: ReturnType<typeof createMockTransport>
deps: ReturnType<typeof createDeps>
@@ -13581,56 +13578,60 @@ describe('connectPanePty', () => {
const replies = (transport: { sendInput: { mock: { calls: unknown[][] } } }): unknown[] =>
transport.sendInput.mock.calls.flat().filter((arg) => String(arg).includes('997'))
it('answers a chunk that ends subscribed even when the next chunk withdraws', async () => {
const { transport, emit, dispose } = await connectVisiblePane()
// Two separate PTY chunks. xterm would parse both in one batch and see only the net
// result; the first chunk still owes a reply.
it('registers a chunk that ends subscribed, then retires it when the next withdraws', async () => {
const { transport, deps, emit, dispose } = await connectVisiblePane()
// Two separate PTY chunks. xterm would parse both in one batch and see only the net result.
emit('\x1b[?2031h')
expect(deps.paneMode2031Ref.current.get(1)).toBe(true)
emit('\x1b[?2031l')
expect(replies(transport)).toEqual(['\x1b[?997;2n'])
expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined()
expect(replies(transport)).toEqual([])
dispose()
})
it('answers each chunk that ends subscribed', async () => {
const { transport, emit, dispose } = await connectVisiblePane()
it('re-registers a chunk that withdraws and subscribes again', async () => {
const { transport, deps, emit, dispose } = await connectVisiblePane()
emit('\x1b[?2031h')
emit('\x1b[?2031l\x1b[?2031h')
expect(replies(transport)).toHaveLength(2)
expect(deps.paneMode2031Ref.current.get(1)).toBe(true)
expect(replies(transport)).toEqual([])
dispose()
})
it('stays silent when one chunk both subscribes and withdraws', async () => {
const { transport, emit, dispose } = await connectVisiblePane()
// The fish prompt case: the subscription is gone before the program could read a reply.
it('registers nothing when one chunk both subscribes and withdraws', async () => {
const { transport, deps, emit, dispose } = await connectVisiblePane()
// The fish prompt case: the subscription is gone before the program could read anything.
emit('\x1b[?2031h prompt \x1b[?2031l')
expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined()
expect(replies(transport)).toEqual([])
dispose()
})
it('stays silent when the withdrawal is split across two chunks', async () => {
const { transport, emit, dispose } = await connectVisiblePane()
it('registers nothing when the withdrawal is split across two chunks', async () => {
const { transport, deps, emit, dispose } = await connectVisiblePane()
emit('\x1b[?2031h prompt \x1b[?20')
expect(replies(transport)).toEqual([])
emit('31l')
expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined()
expect(replies(transport)).toEqual([])
dispose()
})
it('stays silent when an unrelated private mode appends a split withdrawal', async () => {
const { transport, emit, dispose } = await connectVisiblePane()
it('registers nothing when an unrelated private mode appends a split withdrawal', async () => {
const { transport, deps, emit, dispose } = await connectVisiblePane()
emit('\x1b[?2031h prompt \x1b[?25')
expect(replies(transport)).toEqual([])
emit(';2031l')
expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined()
expect(replies(transport)).toEqual([])
dispose()
})
it('answers after an ambiguous tail resolves to another mode', async () => {
const { transport, emit, dispose } = await connectVisiblePane()
it('registers the subscription once an ambiguous tail resolves to another mode', async () => {
const { transport, deps, emit, dispose } = await connectVisiblePane()
emit('\x1b[?2031h drawing \x1b[?20')
expect(replies(transport)).toEqual([])
expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined()
emit('25h')
expect(replies(transport)).toEqual(['\x1b[?997;2n'])
expect(deps.paneMode2031Ref.current.get(1)).toBe(true)
expect(replies(transport)).toEqual([])
dispose()
})
@@ -13654,19 +13655,21 @@ describe('connectPanePty', () => {
dispose()
})
it('answers once when a TUI subscribes at the end of a fish prompt chunk', async () => {
const { transport, emit, dispose } = await connectVisiblePane()
it('registers a TUI that subscribes at the end of a fish prompt chunk', async () => {
const { transport, deps, emit, dispose } = await connectVisiblePane()
emit(`${FISH_PROMPT_HANDOFF}\x1b[?2031h`)
expect(replies(transport)).toHaveLength(1)
expect(deps.paneMode2031Ref.current.get(1)).toBe(true)
expect(replies(transport)).toEqual([])
dispose()
})
it('answers a subscribe whose withdrawal never arrives', async () => {
const { transport, emit, dispose } = await connectVisiblePane()
it('keeps a subscription whose withdrawal never arrives', async () => {
const { transport, deps, emit, dispose } = await connectVisiblePane()
// A real TUI: subscribe now, unsubscribe minutes later on exit.
emit('\x1b[?2031h')
emit('painting the ui')
expect(replies(transport)).toHaveLength(1)
expect(deps.paneMode2031Ref.current.get(1)).toBe(true)
expect(replies(transport)).toEqual([])
dispose()
})
@@ -13683,7 +13686,7 @@ describe('connectPanePty', () => {
})
it('discards a half-read escape prefix when the pane swaps PTYs', async () => {
const { transport, emit, dispose } = await connectVisiblePane()
const { transport, deps, emit, dispose } = await connectVisiblePane()
// Why: the tail is a byte range from the old stream. Splicing it onto the first
// chunk of a replacement PTY fabricates a subscribe no program ever sent.
transport.serializeBuffer = vi.fn().mockResolvedValue(null)
@@ -13694,12 +13697,13 @@ describe('connectPanePty', () => {
emit('31h')
expect(deps.paneMode2031Ref.current.get(1)).toBeUndefined()
expect(replies(transport)).toEqual([])
dispose()
})
})
it('answers hidden Codex mode 2031 subscribes split across becoming visible', async () => {
it('records hidden Codex mode 2031 subscribes split across becoming visible', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-id')
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
@@ -13734,8 +13738,9 @@ describe('connectPanePty', () => {
isVisibleRef.current = true
capturedDataCallback.current?.('31h')
expect(transport.sendInputImmediate).toHaveBeenCalledWith('\x1b[?997;2n')
expect(transport.sendInputImmediate).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/))
expect(paneMode2031Ref.current.get(1)).toBe(true)
// Seeded so the next appearance re-apply only pushes on a real color-mode flip.
expect(paneLastThemeModeRef.current.get(1)).toBe('light')
expect(pane.terminal.write).not.toHaveBeenCalledWith('31h', expect.any(Function))
@@ -14284,7 +14289,7 @@ describe('connectPanePty', () => {
await flushAsyncTicks(20)
expect(getMainBufferSnapshot).toHaveBeenCalled()
expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/))
expect(pane.terminal.write).toHaveBeenCalledWith(
expect.stringContaining('healed from snapshot'),
expect.any(Function)
@@ -14982,7 +14987,8 @@ describe('connectPanePty', () => {
undefined,
{ connectionId: null }
)
expect(transport.sendInput).toHaveBeenCalledWith(expect.stringContaining('\x1b['))
// Remote gated panes record the subscription from the fact and answer nothing (#9993).
expect(transport.sendInput).not.toHaveBeenCalledWith(expect.stringMatching(/\?997/))
expect(recordPaneMode2031Subscription).toHaveBeenCalledWith(1, expect.any(String))
deps.paneMode2031Ref.current.set(1, true)
deps.paneLastThemeModeRef.current.set(1, 'dark')
@@ -131,7 +131,6 @@ import { createShellReadyMarkerScanState, scanForShellReadyMarker } from './shel
import { getSystemPrefersDark } from '@/lib/terminal-theme'
import {
INITIAL_MODE_2031_REPLY_SCAN_STATE,
mode2031SequenceFor,
resolveTerminalColorSchemeMode,
scanMode2031ReplyDecision
} from '../../../../shared/terminal-color-scheme-protocol'
@@ -529,11 +528,10 @@ const e2eTerminalHiddenSnapshotOverrides = new Map<string, E2eTerminalHiddenSnap
// Why: the per-chunk hidden-skip grammar is deleted (Phase 6) — hidden bytes
// either never reach the renderer (delivery gate) or ride the background
// scheduler queue. Only the mode-2031 fact-reply counter still has a producer.
// scheduler queue.
type E2eTerminalPtyOutputDebugSnapshot = {
hiddenRendererSkipCount: number
hiddenRendererSkippedChars: number
hiddenRendererMode2031ReplyCount: number
}
type E2eTerminalPtyOutputDebugApi = {
@@ -566,14 +564,12 @@ type ColdRestoreAgentResumeStartup = PendingStartupCommand & {
const e2eTerminalPtyOutputDebugState: E2eTerminalPtyOutputDebugSnapshot = {
hiddenRendererSkipCount: 0,
hiddenRendererSkippedChars: 0,
hiddenRendererMode2031ReplyCount: 0
hiddenRendererSkippedChars: 0
}
function resetE2eTerminalPtyOutputDebug(): void {
e2eTerminalPtyOutputDebugState.hiddenRendererSkipCount = 0
e2eTerminalPtyOutputDebugState.hiddenRendererSkippedChars = 0
e2eTerminalPtyOutputDebugState.hiddenRendererMode2031ReplyCount = 0
}
function exposeE2eTerminalPtyOutputDebug(): void {
@@ -596,14 +592,6 @@ function recordHiddenRendererSkip(chars: number): void {
e2eTerminalPtyOutputDebugState.hiddenRendererSkippedChars += chars
}
function recordHiddenMode2031Reply(): void {
if (!e2eConfig.exposeStore) {
return
}
exposeE2eTerminalPtyOutputDebug()
e2eTerminalPtyOutputDebugState.hiddenRendererMode2031ReplyCount += 1
}
function exposeE2eTerminalPtyDataInjection(): void {
if (!e2eConfig.exposeStore || typeof window === 'undefined') {
return
@@ -3721,13 +3709,13 @@ export function connectPanePty(
})
// Why: Phase-4 hidden-delivery gate — only meaningful under main authority
// (renderer byte parsers need bytes otherwise). Decided once at pane
// creation: it picks the mode-2031 answer path (fact reply vs byte scan),
// which must have exactly one owner.
// creation: it picks which path records a mode-2031 subscription (main's
// fact vs the byte scan), which must have exactly one owner.
const hiddenDeliveryGateActive =
mainSideEffectAuthority && isRendererHiddenPtyDeliveryGateEnabled(state.settings)
// Why: structural per-PTY gate predicate (authority on + gate on + bytes
// transit local main, which implies snapshot-backed). Shared by the hidden
// mark sync and mode-2031 reply ownership so reply ownership can never
// mark sync and mode-2031 subscription recording so the recorder can never
// disagree with what main may drop — and never depends on the racy hidden
// mark (a fact can outrun the pty:data task that sets it).
const isHiddenDeliveryGateManagedPty = (ptyId: string | null): ptyId is string =>
@@ -3878,13 +3866,10 @@ export function connectPanePty(
// desktop silent while the elected mobile xterm owns query replies.
const sendDesktopQueryReplyImmediate = (data: string): boolean =>
canSendDesktopQueryReply() && transport.sendInputImmediate(data)
// Why (gate mode only): for gate-managed PTYs this fact is the SOLE 2031
// responder — visible, hidden, marked or not. Conditioning the reply on the
// hidden mark double-fired (mark set + bytes delivered live via interest →
// fact AND xterm both replied) or dropped the reply entirely (fact outran
// the pty:data task that set the mark). The xterm-side CSI reply and the
// skipped-byte scan are disabled for these panes (same structural
// predicate), so exactly one reply goes out.
// Why (gate mode only): gate-managed PTYs never see the subscribe bytes, so this fact is
// their only cue to record the subscription — without the registry entry a later theme
// flip never pushes the CSI 997 update and the TUI keeps a stale theme after reveal.
// Record-only: a subscribe is not a query (see observeLiveMode2031Chunk, #9993).
const handleHiddenMode2031SubscribeFact = (): void => {
const ptyId = transport.getPtyId()
if (disposed || (!isHiddenDeliveryGateManagedPty(ptyId) && remoteOutputGatedPtyId !== ptyId)) {
@@ -3894,22 +3879,14 @@ export function connectPanePty(
useAppStore.getState().settings,
getSystemPrefersDark()
)
// Why immediate: a mode-2031 query reply must beat the remote input debounce
// or it can miss the querying program's read window (#7329).
sendDesktopQueryReplyImmediate(mode2031SequenceFor(mode))
// Why: register the subscription exactly like the xterm CSI handler
// would — without the registry entry, later theme flips never push the
// CSI 997 update and the TUI keeps a stale theme after reveal.
deps.recordPaneMode2031Subscription?.(pane.id, mode)
recordHiddenMode2031Reply()
}
// Why (gate mode only): the counterpart to the subscribe fact. These panes never
// receive the withdrawal bytes — main drops them before delivery — and both the
// chunk scanner and the xterm CSI handler are disabled for them, so this fact is
// the ONLY observer that can retire the subscription. Without it a TUI that exits
// while hidden leaves paneMode2031 set, and the next theme flip pushes CSI 997
// into the shell that replaced it (#9993 via maybePushMode2031Flip). No reply is
// sent: a withdrawal is not a query.
// receive the withdrawal bytes — main drops them before delivery — and the chunk
// scanner is disabled for them, so this fact is the ONLY observer that can retire
// the subscription. Without it a TUI that exits while hidden leaves paneMode2031
// set, and the next theme flip pushes CSI 997 into the shell that replaced it
// (#9993 via maybePushMode2031Flip).
const handleHiddenMode2031UnsubscribeFact = (): void => {
const ptyId = transport.getPtyId()
if (disposed || (!isHiddenDeliveryGateManagedPty(ptyId) && remoteOutputGatedPtyId !== ptyId)) {
@@ -6351,14 +6328,14 @@ export function connectPanePty(
}
}
// Why record-only: DECSET 2031 subscribes to future color changes, it is not a query —
// the protocol's query is `CSI ?996n`. fish arms 2031 for the ~1ms it paints a prompt, so
// any reply lands after the withdrawal and paints `?997;1n` as literal text (#9993).
// Why here and not in xterm's CSI handler: xterm batches several PTY chunks into one
// synchronous parse, so a handler cannot tell where a chunk ended. fish enables and
// disables 2031 around every prompt, so answering a subscribe the same chunk withdraws
// pushes `?997;1n` into the prompt or a child's stdin as literal text (#9993). One raw
// chunk in, one order-aware decision out.
// synchronous parse, so a handler cannot tell where a chunk ended. One raw chunk in,
// one order-aware final state out.
function observeLiveMode2031Chunk(data: string): void {
// Main's '2031-subscribe' fact is the sole responder for gate-managed PTYs; a second
// reply from here would answer one subscribe twice.
// Gate-managed PTYs never see these bytes; main's '2031-subscribe' fact records them.
if (isHiddenDeliveryGateManagedPty(transport.getPtyId())) {
return
}
@@ -6372,13 +6349,13 @@ export function connectPanePty(
return
}
const settings = useAppStore.getState().settings
const mode = resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())
// Why immediate: the reply must land inside the program's read window, and hidden
// snapshot-backed panes skip xterm.write for PTY bytes entirely.
// Why seed the mode: maybePushMode2031Flip only pushes on a change, so an unseeded
// subscription would read as a flip on the next unrelated appearance re-apply.
deps.paneMode2031Ref.current.set(pane.id, true)
sendDesktopQueryReplyImmediate(mode2031SequenceFor(mode))
deps.paneLastThemeModeRef.current.set(pane.id, mode)
recordHiddenMode2031Reply()
deps.paneLastThemeModeRef.current.set(
pane.id,
resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())
)
}
// Why installed here: the handler observes CSI 3 J inside xterm's parse, so a
@@ -469,7 +469,8 @@ describe('pty input write queue', () => {
})
it('dual mode-2031 enqueues through the real queue never paint 997 under host echo-safe write', async () => {
// Issue path: xterm onData + mode-2031 scan each enqueue mode2031SequenceFor.
// Two 997s can still coalesce in one write: a fast theme flip, or an old client that
// still answers 2031 subscribes (#9993 made this host silent, mixed versions have not).
// Drive the real write queue → host intercept (extract + answerLiveQueryReply)
// → ingress echo strip, and assert no `997;1n` emission at the confirm prompt.
vi.useFakeTimers()
@@ -4,7 +4,7 @@
* The gate only operates when main holds side-effect authority for the PTY
* (see isMainTerminalSideEffectAuthorityForPty) AND the gate-specific kill
* switch is on. Callers decide once at pane/watcher creation — the decision
* picks which mode-2031 responder is registered (byte sidecar vs fact reply),
* picks which observer records mode-2031 subscriptions (byte scan vs fact),
* so it must never flip per chunk.
*/
import type { GlobalSettings } from '../../../../shared/types'
@@ -1,5 +1,5 @@
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
import { isRemoteRuntimePtyId, sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection'
import { useAppStore } from '@/store'
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher'
@@ -82,10 +82,7 @@ export function startParkedPtyWatcher(args: {
paneId: pane.paneId,
drivesTabTitle: pane.drivesTabTitle,
...(initialTitle !== undefined ? { initialTitle } : {}),
...(restoreTitleOnRegister ? { restoreTitleOnRegister: true } : {}),
sendInput: (data) => {
sendRuntimePtyInput(useAppStore.getState().settings, ptyId, data)
}
...(restoreTitleOnRegister ? { restoreTitleOnRegister: true } : {})
})
const unsubscribeExit = isRemoteRuntimePtyId(ptyId)
? () => {}
@@ -206,14 +206,6 @@ describe('terminal-parked-tab-watchers', () => {
expect(startedWatchers[0].options.restoreTitleOnRegister).toBe(true)
})
it('routes watcher sendInput to window.api.pty.write for the watched PTY', () => {
capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }])
syncParked()
startedWatchers[0].options.sendInput('\x1b[?2031;1$y')
expect(ptyWrite).toHaveBeenCalledWith(PTY_ID, '\x1b[?2031;1$y')
})
it('skips legacy non-UUID leaf ids instead of throwing in makePaneKey', () => {
capturePanes([
{ ptyId: PTY_ID, paneId: 1, leafId: 'legacy-leaf-1', drivesTabTitle: true },
@@ -86,8 +86,8 @@ export type TerminalSideEffectFactConsumerCallbacks = {
onCommandCodeWorking?: (prompt: string) => void
onCommandCodeDone?: (prompt: string) => void
/** DECSET 2031 subscribe observed by main's tracker. Registered only by
* hidden-delivery-gated consumers (their bytes never arrive); the theme
* reply is sent renderer-side — query authority stays with the view. */
* hidden-delivery-gated consumers (their bytes never arrive); it records the
* subscription for later theme-flip pushes, it does not answer. */
onMode2031Subscribe?: () => void
/** DECSET 2031 withdrawal observed by main's tracker. Clears the pane's
* subscription registry so later theme flips stop pushing CSI 997. */
@@ -879,10 +879,10 @@ export function useTerminalPaneLifecycle({
}
suppression.handlePtyInput(data, forward)
},
// Why: record the main-answered 2031 subscribe in the CSI handler's registries, else theme flips never push CSI 997.
recordPaneMode2031Subscription: (paneId: number, repliedMode: 'dark' | 'light') => {
// Why: record the fact-observed 2031 subscribe in the pane registries, else theme flips never push CSI 997.
recordPaneMode2031Subscription: (paneId: number, subscribedMode: 'dark' | 'light') => {
paneMode2031Ref.current.set(paneId, true)
paneLastThemeModeRef.current.set(paneId, repliedMode)
paneLastThemeModeRef.current.set(paneId, subscribedMode)
},
restoredPtyIdByLeafId: initialLayoutRef.current.ptyIdsByLeafId ?? {}
}
@@ -241,10 +241,32 @@ function resolveAiVaultResumeShell(args: AiVaultResumeWorktreeArgs): AgentStartu
state: args.state,
worktreeId: args.worktreeId,
platform,
isLocalSession
isLocalSession,
parsedByClientLoginShell: isLocalSession && runsOnClientLoginShell(args, platform)
})
}
/**
* Whether the resume line is handed to THIS machine's login shell.
*
* Why not `isLocalSession`: that only says the session file was scanned locally
* (no executionHostId), which is still true when the worktree lives on an SSH
* or runtime host — the command then goes to that host's shell. The WSL case is
* caught by the platform mismatch (a WSL worktree resolves to 'linux' on win32).
*/
function runsOnClientLoginShell(
args: AiVaultResumeWorktreeArgs,
platform: NodeJS.Platform
): boolean {
const executionHost = parseExecutionHostId(
getExecutionHostIdForWorktree(args.state, args.worktreeId ?? args.state.activeWorktreeId)
)
if (executionHost?.kind === 'ssh' || executionHost?.kind === 'runtime') {
return false
}
return platform === CLIENT_PLATFORM
}
export function getAiVaultAgentProviderSession(
session: Pick<AiVaultSession, 'agent' | 'sessionId'> & { filePath?: string }
): AgentProviderSessionMetadata | null {
@@ -0,0 +1,150 @@
import { describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store/types'
vi.mock('@/lib/new-workspace', () => ({
CLIENT_PLATFORM: 'darwin'
}))
const clientLoginShell = vi.hoisted(() => ({ value: '' }))
vi.mock('@/lib/client-login-shell', () => ({
getClientLoginShell: () => clientLoginShell.value
}))
import { buildAiVaultResumeCopyCommandForWorktree } from './ai-vault-resume-command'
import { resolveAiVaultResumeStartupShell } from './ai-vault-resume-shell'
type ResumeShellState = Parameters<typeof buildAiVaultResumeCopyCommandForWorktree>[0]['state']
function makeState(worktreeHostId?: string): ResumeShellState {
return {
activeRepoId: 'repo-1',
activeWorktreeId: 'repo-1::worktree-1',
folderWorkspaces: [],
projectGroups: [],
repos: [{ id: 'repo-1', path: '/home/alice/repo' }],
projects: [{ id: 'repo-1', sourceRepoIds: ['repo-1'] }],
settings: {
agentDefaultArgs: { codex: '' },
agentDefaultEnv: { codex: {} }
},
worktreesByRepo: {
'repo-1': [
{
id: 'repo-1::worktree-1',
repoId: 'repo-1',
path: '/home/alice/repo',
...(worktreeHostId ? { hostId: worktreeHostId } : {})
}
]
}
} as unknown as AppState
}
function withLoginShell<T>(shell: string, run: () => T): T {
clientLoginShell.value = shell
try {
return run()
} finally {
clientLoginShell.value = ''
}
}
describe('resolveAiVaultResumeStartupShell', () => {
it('reports the fish dialect for a local session under a fish login shell', () => {
expect(
withLoginShell('/opt/homebrew/bin/fish', () =>
resolveAiVaultResumeStartupShell({
state: makeState(),
worktreeId: 'repo-1::worktree-1',
platform: 'darwin',
isLocalSession: true,
parsedByClientLoginShell: true
})
)
).toBe('fish')
})
it('stays on sh for zsh users', () => {
expect(
withLoginShell('/bin/zsh', () =>
resolveAiVaultResumeStartupShell({
state: makeState(),
worktreeId: 'repo-1::worktree-1',
platform: 'darwin',
isLocalSession: true,
parsedByClientLoginShell: true
})
)
).toBe('posix')
})
it('stays on sh for a LOCAL session whose command a remote host parses', () => {
// The reachable case: a locally scanned session has no executionHostId, so
// isLocalSession stays true while the command is bound for an SSH host.
expect(
withLoginShell('/opt/homebrew/bin/fish', () =>
resolveAiVaultResumeStartupShell({
state: makeState(),
worktreeId: 'repo-1::worktree-1',
platform: 'linux',
isLocalSession: true,
parsedByClientLoginShell: false
})
)
).toBe('posix')
})
})
describe('copied real-home Codex resume command', () => {
const session = {
agent: 'codex' as const,
sessionId: 'session one',
cwd: '/home/alice/repo',
codexHome: null
}
it('clears inherited Codex homes with fish syntax under a fish login shell', () => {
expect(
withLoginShell('/opt/homebrew/bin/fish', () =>
buildAiVaultResumeCopyCommandForWorktree({
state: makeState(),
worktreeId: 'repo-1::worktree-1',
session
})
)
).toBe(
"set -e CODEX_HOME; set -e ORCA_CODEX_HOME; cd '/home/alice/repo' && codex 'resume' 'session one'"
)
})
it('keeps `unset` when a fish client targets an SSH worktree', () => {
// The session was scanned locally (no executionHostId), but the worktree lives
// on an SSH host: `set -e CODEX_HOME` would enable errexit there, not clear it.
expect(
withLoginShell('/opt/homebrew/bin/fish', () =>
buildAiVaultResumeCopyCommandForWorktree({
state: makeState('ssh:target-1'),
worktreeId: 'repo-1::worktree-1',
session
})
)
).toBe(
"unset CODEX_HOME; unset ORCA_CODEX_HOME; cd '/home/alice/repo' && codex 'resume' 'session one'"
)
})
it('keeps `unset` for sh-family login shells', () => {
expect(
withLoginShell('/bin/bash', () =>
buildAiVaultResumeCopyCommandForWorktree({
state: makeState(),
worktreeId: 'repo-1::worktree-1',
session
})
)
).toBe(
"unset CODEX_HOME; unset ORCA_CODEX_HOME; cd '/home/alice/repo' && codex 'resume' 'session one'"
)
})
})
+26 -13
View File
@@ -4,9 +4,11 @@ import { CLIENT_PLATFORM } from '@/lib/new-workspace'
import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../shared/local-windows-terminal-runtime'
import { resolveWindowsShellStartupFamily } from '../../../shared/windows-terminal-shell'
import {
resolveLoginShellStartupDialect,
resolveStartupShell,
type AgentStartupShell
} from '../../../shared/tui-agent-startup-shell'
import { getClientLoginShell } from '@/lib/client-login-shell'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import { parseWslUncPath } from '../../../shared/wsl-paths'
@@ -26,24 +28,35 @@ export function resolveAiVaultResumeStartupShell(args: {
worktreeId?: string | null
platform: NodeJS.Platform
isLocalSession: boolean
/**
* True only when this machine's own login shell parses the command. A local
* session is NOT enough: a locally scanned session carries no executionHostId
* yet can target an SSH/runtime/WSL worktree, whose shell is unrelated.
*/
parsedByClientLoginShell?: boolean
}): AgentStartupShell {
const projectRuntime =
args.platform === 'win32' && args.isLocalSession
? getLocalProjectExecutionRuntimeContext(args.state, args.worktreeId, CLIENT_PLATFORM)
: undefined
// Why: fish rejects `unset`, so the client's login shell decides the dialect —
// but only when it is the shell that reads the line; otherwise it stays sh.
if (args.platform !== 'win32') {
return args.parsedByClientLoginShell
? resolveLoginShellStartupDialect(getClientLoginShell())
: 'posix'
}
const projectRuntime = args.isLocalSession
? getLocalProjectExecutionRuntimeContext(args.state, args.worktreeId, CLIENT_PLATFORM)
: undefined
const workspacePath = getAiVaultResumeWorkspacePath(
args.state,
args.worktreeId ?? args.state.activeWorktreeId
)
const shellOverride =
args.platform === 'win32' && args.isLocalSession
? resolveLocalWindowsTerminalShellOverrideForTab({
explicitShellOverride: undefined,
defaultWindowsShell: args.state.settings?.terminalWindowsShell,
isWslWorktree: Boolean(workspacePath && parseWslUncPath(workspacePath)),
projectRuntime
})
: undefined
const shellOverride = args.isLocalSession
? resolveLocalWindowsTerminalShellOverrideForTab({
explicitShellOverride: undefined,
defaultWindowsShell: args.state.settings?.terminalWindowsShell,
isWslWorktree: Boolean(workspacePath && parseWslUncPath(workspacePath)),
projectRuntime
})
: undefined
const shell = shellOverride ? resolveWindowsShellStartupFamily(shellOverride) : undefined
return resolveStartupShell(args.platform, shell)
}
@@ -0,0 +1,15 @@
/**
* Login shell (`$SHELL`) of the machine running this client, or '' when
* unknown (web build, preload unavailable).
*
* Why: commands Orca hands the user to paste — or types into a locally
* spawned pane — are parsed by that shell, and fish does not share the sh
* grammar for every construct.
*/
export function getClientLoginShell(): string {
try {
return window.api?.platform?.get?.().shell ?? ''
} catch {
return ''
}
}
+2 -1
View File
@@ -4,6 +4,7 @@
import { TUI_AGENT_CONFIG } from './tui-agent-config'
import {
commandSeparator,
isPosixStartupShell,
quoteStartupArg,
type AgentStartupShell
} from './tui-agent-startup-shell'
@@ -98,7 +99,7 @@ function buildResumeShellCommandForShell(args: {
shell: Exclude<AgentStartupShell, 'cmd'>
}): string {
const { cwd, codexHome, shell } = args
if (shell === 'posix') {
if (isPosixStartupShell(shell)) {
// Why: git-bash on a Windows host runs a POSIX shell, so reuse the same
// inline-env + `cd '<cwd>'` prefix as the non-Windows path.
const envPrefix = codexHome ? `CODEX_HOME=${quoteStartupArg(codexHome, shell)} ` : ''
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import {
fishRequirementViolation,
REQUIRE_FISH_ENV_VAR,
resolveFishBinary,
type FishBinaryLookup
} from './fish-binary-requirement'
const FOUND: FishBinaryLookup = { available: true, path: '/usr/bin/fish', majorVersion: 4 }
const MISSING: FishBinaryLookup = {
available: false,
path: null,
majorVersion: 0,
reason: 'no fish binary on PATH'
}
describe('fishRequirementViolation', () => {
it('lets a missing fish skip when the requirement is not set', () => {
expect(fishRequirementViolation(MISSING, {})).toBeNull()
expect(fishRequirementViolation(MISSING, { [REQUIRE_FISH_ENV_VAR]: '0' })).toBeNull()
})
// The #9993 guard is CI's only end-to-end coverage; a skip there is a silent gap.
it('reports a violation naming the reason when CI requires fish and it is absent', () => {
const violation = fishRequirementViolation(MISSING, { [REQUIRE_FISH_ENV_VAR]: '1' })
expect(violation).toContain(REQUIRE_FISH_ENV_VAR)
expect(violation).toContain('no fish binary on PATH')
})
it('reports no violation when fish is present, requirement set or not', () => {
expect(fishRequirementViolation(FOUND, { [REQUIRE_FISH_ENV_VAR]: '1' })).toBeNull()
expect(fishRequirementViolation(FOUND, {})).toBeNull()
})
})
describe('resolveFishBinary', () => {
it('treats an installed fish below the floor as unavailable, with the version in the reason', () => {
const lookup = resolveFishBinary(Number.MAX_SAFE_INTEGER)
if (lookup.available) {
throw new Error('no fish can satisfy an unreachable version floor')
}
// Only when a fish is actually installed does the reason describe a version.
if (lookup.majorVersion > 0) {
expect(lookup.reason).toContain(`fish ${Number.MAX_SAFE_INTEGER}+ required`)
}
})
it('never reports a path on Windows', () => {
if (process.platform !== 'win32') {
return
}
expect(resolveFishBinary().path).toBeNull()
})
})
+65
View File
@@ -0,0 +1,65 @@
import { spawnSync } from 'node:child_process'
/**
* Locates the fish binary live shell tests run against, and decides how strict
* they are about finding one.
*
* Why the strictness is configurable: on a developer machine without fish these
* suites must skip, but the `shell contracts` CI job is a required check whose
* fish lane is the only end-to-end guard for #9993. There, a skip would report
* green with nothing exercised, so ORCA_REQUIRE_FISH=1 turns it into a failure.
*/
const FISH_CANDIDATES = ['fish', '/opt/homebrew/bin/fish', '/usr/local/bin/fish'] as const
/** Env var CI sets to make a missing or too-old fish fail instead of skip. */
export const REQUIRE_FISH_ENV_VAR = 'ORCA_REQUIRE_FISH'
export type FishBinaryLookup =
| { available: true; path: string; majorVersion: number }
| { available: false; path: null; majorVersion: number; reason: string }
export function resolveFishBinary(minMajorVersion = 1): FishBinaryLookup {
if (process.platform === 'win32') {
return {
available: false,
path: null,
majorVersion: 0,
reason: 'fish is not supported on Windows'
}
}
for (const path of FISH_CANDIDATES) {
const probe = spawnSync(path, ['--version'], { encoding: 'utf8' })
if (probe.status !== 0) {
continue
}
const majorVersion = Number(/version (\d+)/.exec(probe.stdout ?? '')?.[1] ?? '0')
if (majorVersion < minMajorVersion) {
return {
available: false,
path: null,
majorVersion,
reason: `fish ${minMajorVersion}+ required, found ${probe.stdout?.trim() || 'an unknown version'} at ${path}`
}
}
return { available: true, path, majorVersion }
}
return { available: false, path: null, majorVersion: 0, reason: 'no fish binary on PATH' }
}
/**
* The message to fail with when CI demanded fish and did not get it, else null.
*
* Assert this in a test that always runs, so the requirement cannot vanish with
* the suite it guards.
*/
export function fishRequirementViolation(
lookup: FishBinaryLookup,
env: NodeJS.ProcessEnv = process.env
): string | null {
if (lookup.available || env[REQUIRE_FISH_ENV_VAR] !== '1') {
return null
}
return `${REQUIRE_FISH_ENV_VAR}=1 but the live fish tests would skip: ${lookup.reason}`
}
+3 -2
View File
@@ -1,6 +1,7 @@
import { encodePowerShellCommand } from './powershell-command-encoding'
import {
buildShellCommandFromArgv,
isPosixStartupShell,
quoteStartupArg,
tokenizeStartupCommand,
type AgentStartupShell
@@ -114,7 +115,7 @@ function normalizeHermesArgv(
assignmentCount += 1
}
if (assignmentCount > 0) {
if (shell !== 'posix') {
if (!isPosixStartupShell(shell)) {
return null
}
commandPrefix = ['env', ...commandPrefix]
@@ -139,7 +140,7 @@ function normalizeHermesArgv(
}
function buildQueryCommand(argv: string[], shell: AgentStartupShell): string {
if (shell !== 'posix') {
if (!isPosixStartupShell(shell)) {
const invocation = buildShellCommandFromArgv(argv, 'powershell').replace(
quoteStartupArg(QUERY_ARG_PLACEHOLDER, 'powershell'),
`"--query=$${POWERSHELL_NATIVE_QUERY_VARIABLE}"`
+23 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { buildStartupCommandSubmission } from './startup-command-submission'
import {
buildStartupCommandSubmission,
isBracketedPasteSafeShell
} from './startup-command-submission'
describe('buildStartupCommandSubmission', () => {
it('appends the submit byte to a single-line command unchanged', () => {
@@ -44,3 +47,22 @@ describe('buildStartupCommandSubmission', () => {
).toBe(`${command}\n`)
})
})
describe('isBracketedPasteSafeShell', () => {
it('always trusts bash and zsh', () => {
for (const shellName of ['bash', 'zsh', 'BASH']) {
expect(isBracketedPasteSafeShell({ shellName, waitsForShellReady: false })).toBe(true)
}
})
it('trusts fish only behind the shell-ready barrier', () => {
expect(isBracketedPasteSafeShell({ shellName: 'fish', waitsForShellReady: true })).toBe(true)
expect(isBracketedPasteSafeShell({ shellName: 'fish', waitsForShellReady: false })).toBe(false)
})
it('rejects shells with no known bracketed-paste line editor', () => {
for (const shellName of ['sh', 'dash', 'nu', 'powershell.exe', '']) {
expect(isBracketedPasteSafeShell({ shellName, waitsForShellReady: true })).toBe(false)
}
})
})
+24 -2
View File
@@ -23,11 +23,33 @@ export type StartupCommandSubmissionOptions = {
* byte on `command` is preserved as-is. */
submit: string
/** Whether the target line editor has bracketed-paste mode active (Orca's
* wrapped bash/zsh). Only wrap multiline payloads when true — a shell without
* bracketed paste would echo the ESC[200~ markers as literal garbage. */
* wrapped bash/zsh/fish). Only wrap multiline payloads when true — a shell
* without bracketed paste would echo the ESC[200~ markers as literal garbage. */
bracketedPasteSafe: boolean
}
/**
* Whether a spawned POSIX shell will read a bracketed-paste payload as one
* multiline command.
*
* Why fish needs the ready barrier: bash readline and zsh zle interpret the
* ESC[200~ wrapper out of their buffered input, but fish consumes bytes during
* its startup terminal-query handshake, so a payload written before its reader
* is up lands as literal `200~` text and the command never runs (verified
* against fish 4.7). Waiting for the shell-ready barrier is what makes fish
* paste-safe, and it is what the daemon and relay backends already require.
*/
export function isBracketedPasteSafeShell(args: {
shellName: string
waitsForShellReady: boolean
}): boolean {
const name = args.shellName.toLowerCase()
if (name === 'bash' || name === 'zsh') {
return true
}
return name === 'fish' && args.waitsForShellReady
}
export function buildStartupCommandSubmission(
command: string,
{ submit, bracketedPasteSafe }: StartupCommandSubmissionOptions
@@ -4,13 +4,19 @@
// subscription the shell has already dropped, and the reply lands as literal
// text at the prompt or in a child's stdin (#9993).
import { describe, expect, it } from 'vitest'
import { scanMode2031Sequences } from './terminal-color-scheme-protocol'
import {
INITIAL_MODE_2031_REPLY_SCAN_STATE,
scanMode2031ReplyDecision,
scanMode2031Sequences,
type Mode2031ReplyDecision
} from './terminal-color-scheme-protocol'
import {
createTerminalTitleTracker,
type TerminalTitleTrackerCallbacks
} from './terminal-output-side-effects'
const ESC = '\x1b'
const BEL = '\x07'
// A fish prompt cycle: subscribe, paint the prompt, hand the tty to the child.
const FISH_PROMPT_HANDOFF = `${ESC}[?2031h${ESC}[0m~/orca ${ESC}[32m${ESC}[0m ${ESC}[?2031l`
@@ -131,3 +137,73 @@ describe('DECSET 2031 replies follow the chunk-final state (#9993)', () => {
expect(recorded.subscribes).toBe(1)
})
})
/**
* Real chunk boundaries captured from fish 4.7.1 over node-pty
* (tests/tools/fish-mode2031-repro/capture-fish-mode2031-trace.mjs). One prompt-accept:
* arm 2031 while painting the prompt, echo the typed line, withdraw 2031, exec the command.
* Measured gap between chunks 1 and 4 in that trace: 0.57ms — shorter than a renderer IPC hop.
*/
const FISH_PROMPT_ACCEPT_CHUNKS: readonly string[] = [
// 1. Prompt setup: capability probes plus the 2031 arm. Ends SUBSCRIBED.
`${ESC}]0;~/orca${BEL}${ESC}[m${ESC}]11;?${ESC}\\${ESC}[6n${ESC}[0c${ESC}[?2004h${ESC}[?2031h${ESC}[>4;1m${ESC}=`,
// 2. The prompt itself.
`${ESC}]133;A;click_events=1${BEL}~/orca ${ESC}]133;B${BEL}${ESC}[K\r${ESC}[9C`,
// 3. Echo of the typed command.
`npx${ESC}[12C -y${ESC}[15C`,
// 4. Accept: fish withdraws 2031 before handing the tty over. Ends UNSUBSCRIBED.
`${ESC}[?2004l${ESC}[?2031l${ESC}[>4;0m${ESC}>\r${ESC}[15C`,
// 5. The command is now running and owns the tty — any reply from chunk 1 lands HERE.
`${ESC}[m${ESC}]133;C;cmdline_url=npx${BEL}`
]
function trackerRecordingBothFacts(): {
facts: string[]
tracker: ReturnType<typeof createTerminalTitleTracker>
} {
const facts: string[] = []
return {
facts,
tracker: createTerminalTitleTracker({
onMode2031Subscribe: () => facts.push('2031-subscribe'),
onMode2031Unsubscribe: () => facts.push('2031-unsubscribe')
})
}
}
describe('a fish prompt-accept burst is tracked, never answered (#9993)', () => {
it('yields registry transitions only — subscribe then unsubscribe, two chunks apart', () => {
let state = INITIAL_MODE_2031_REPLY_SCAN_STATE
const decisions: Mode2031ReplyDecision[] = FISH_PROMPT_ACCEPT_CHUNKS.map((chunk) => {
const result = scanMode2031ReplyDecision(state, chunk)
state = result.state
return result.decision
})
expect(decisions).toEqual(['subscribed', null, null, 'unsubscribed', null])
// The subscription is already retired one chunk before the command starts, so a reply
// queued on the 'subscribed' decision cannot reach fish — only the child that replaced it.
expect(decisions.indexOf('unsubscribed')).toBeLessThan(FISH_PROMPT_ACCEPT_CHUNKS.length - 1)
})
it('still emits both side-effect facts so the subscription registry stays truthful', () => {
const recorded = trackerRecordingBothFacts()
for (const chunk of FISH_PROMPT_ACCEPT_CHUNKS) {
recorded.tracker.handleChunk(chunk)
}
// Facts survive the fix: maybePushMode2031Flip needs them to know who is listening.
expect(recorded.facts).toEqual(['2031-subscribe', '2031-unsubscribe'])
})
it('contains no color-scheme query, so silence is the whole correct answer', () => {
const burst = FISH_PROMPT_ACCEPT_CHUNKS.join('')
// Verified over a full real session: fish never sends the protocol's query (`CSI ?996n`).
// It only ever subscribes, so there is nothing here that a `CSI ?997;Nn` could be a reply to.
expect(burst).toContain(`${ESC}[?2031h`)
expect(burst).not.toContain('996')
expect(burst).not.toContain('997')
})
})
+1 -1
View File
@@ -67,7 +67,7 @@ export type TerminalTitleTrackerCallbacks = {
onPrLink?: (link: TerminalGitHubPRLink) => void
/**
* Fired per chunk containing a DECSET 2031 subscribe (chunk-boundary-safe): lets
* hidden-delivery-gated renderer views answer the color-scheme query without byte access.
* hidden-delivery-gated renderer views track the subscription without byte access.
*/
onMode2031Subscribe?: () => void
/**
+3 -2
View File
@@ -92,8 +92,9 @@ function cookedEchoSafeReplyEnd(data: string, start: number): number {
}
/**
* If `data` is entirely one or more consecutive cooked-echo-risk replies
* (e.g. repeated `?997;1n` coalesced by the input write queue), return each reply.
* If `data` is entirely one or more consecutive cooked-echo-risk replies, return each
* reply. Repeats still arrive coalesced from a rapid theme flip or an older client that
* answers 2031 subscribes (this host stopped — #9993), so peel them individually.
* Mixed payloads (reply + keystroke) return null so hosts fall through to raw write.
*/
export function extractOnlyCookedEchoSafeQueryReplies(data: string): string[] | null {
@@ -3,6 +3,24 @@ import {
EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE,
scanTerminalReplyQuerySequences
} from './terminal-reply-query-scan'
import {
INITIAL_MODE_2031_REPLY_SCAN_STATE,
scanMode2031ReplyDecision
} from './terminal-color-scheme-protocol'
/** Exactly what terminal.ts's subscribe path concatenates into the replay push. */
function replayDataFor(chunks: readonly string[]): string {
let state = EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE
let seq = 0
let replay = ''
for (const chunk of chunks) {
const scan = scanTerminalReplyQuerySequences(chunk, seq, state)
state = scan.state
seq += chunk.length
replay += scan.queries.map((query) => query.data).join('')
}
return replay
}
describe('terminal reply query scan', () => {
it('records reply-eliciting queries with their output high-water sequence', () => {
@@ -39,3 +57,56 @@ describe('terminal reply query scan', () => {
expect(second.queries).toEqual([])
})
})
// A late-attaching remote/mobile client is handed these sequences because the scrollback
// snapshot it also receives carries no control bytes. Carrying the 2031 arm without its
// withdraw made the client register a subscription the TUI had already retired, and the
// remote renderer then pushed CSI ?997;Nn into the shell on the next theme flip (#9993).
describe('replayed DECSET 2031 state matches the TUI final state (#9993)', () => {
it('carries the withdraw alongside the arm', () => {
const result = scanTerminalReplyQuerySequences(
'\x1b[?2031h\x1b[6nprompt$ \x1b[?2031l',
0,
EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE
)
expect(result.queries).toEqual([
{ data: '\x1b[?2031h', startSeq: 0, endSeq: 8 },
{ data: '\x1b[6n', startSeq: 8, endSeq: 12 },
{ data: '\x1b[?2031l', startSeq: 20, endSeq: 28 }
])
})
it('leaves a fish prompt-accept replay UNSUBSCRIBED, as fish actually is', () => {
// fish arms 2031 while painting the prompt and withdraws it before exec'ing the command.
const replay = replayDataFor([
'\x1b[?2004h\x1b[?2031h',
'~/orca ',
'\x1b[?2004l\x1b[?2031l',
'npx running now'
])
expect(replay).toBe('\x1b[?2031h\x1b[?2031l')
expect(scanMode2031ReplyDecision(INITIAL_MODE_2031_REPLY_SCAN_STATE, replay).decision).toBe(
'unsubscribed'
)
})
it('leaves a still-armed TUI SUBSCRIBED so it keeps receiving theme flips', () => {
const replay = replayDataFor(['\x1b[?2031h', 'a TUI that stays subscribed'])
expect(replay).toBe('\x1b[?2031h')
expect(scanMode2031ReplyDecision(INITIAL_MODE_2031_REPLY_SCAN_STATE, replay).decision).toBe(
'subscribed'
)
})
it('resolves a rearm after a withdraw to the last toggle, in byte order', () => {
const replay = replayDataFor(['\x1b[?2031h', 'x\x1b[?2031l', 'y\x1b[?2031h'])
expect(replay).toBe('\x1b[?2031h\x1b[?2031l\x1b[?2031h')
expect(scanMode2031ReplyDecision(INITIAL_MODE_2031_REPLY_SCAN_STATE, replay).decision).toBe(
'subscribed'
)
})
})
+17 -3
View File
@@ -24,7 +24,18 @@ export const EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE: TerminalReplyQueryScanState
pendingStartSeq: null
}
function isReplyElicitingCsi(sequence: string): boolean {
/**
* Sequences worth replaying to a late-attaching view, because a scrollback snapshot
* carries only rendered cells and drops the control bytes that produced them.
*
* Most entries are reply-eliciting queries the attaching emulator must answer once.
* The DECSET/DECRST 2031 pair is different: since #9993 nothing answers the subscribe,
* but a remote renderer derives its color-scheme subscription registry from these very
* bytes (pty-connection observeLiveMode2031Chunk), so both toggles must be carried —
* an arm without its withdraw would leave the client subscribed for a TUI that already
* retired the subscription, and fish rearms/withdraws on every prompt.
*/
function isReplayableCsi(sequence: string): boolean {
if (DEVICE_ATTRIBUTES_QUERY_RE.test(sequence)) {
return true
}
@@ -41,7 +52,10 @@ function isReplyElicitingCsi(sequence: string): boolean {
sequence === '\x1b[16t' ||
sequence === '\x1b[18t' ||
sequence === '\x1b[?u' ||
sequence === '\x1b[?2031h'
sequence === '\x1b[?2031h' ||
// Why paired with the arm above, exact-form only: replaying a combined DECRST such as
// `CSI ?2004;2031l` would also toggle unrelated modes in the attaching emulator.
sequence === '\x1b[?2031l'
)
}
@@ -81,7 +95,7 @@ export function scanTerminalReplyQuerySequences(
if (input.startsWith(`${ESC}[`, candidateIndex)) {
endIndex = findCsiFinalByteIndex(input, candidateIndex + 2)
if (endIndex !== -1) {
matches = isReplyElicitingCsi(input.slice(candidateIndex, endIndex + 1))
matches = isReplayableCsi(input.slice(candidateIndex, endIndex + 1))
}
} else if (input.startsWith(`${ESC}]`, candidateIndex)) {
const osc = parseTerminalOscColorQuery(input, candidateIndex)
+2 -3
View File
@@ -30,9 +30,8 @@ export type TerminalSideEffectFact =
| { kind: 'command-code-working'; prompt: string }
| { kind: 'command-code-done'; prompt: string }
/** DECSET 2031 color-scheme subscribe observed in the byte stream. Emitted
* so hidden-delivery-gated views (whose bytes never arrive) can still send
* the theme reply — the reply stays renderer-side because query authority
* belongs to the view (model/view contract invariant 6). */
* so hidden-delivery-gated views (whose bytes never arrive) can still record
* the subscription and push later theme flips; subscribing is never answered. */
| { kind: '2031-subscribe' }
/** DECSET 2031 withdrawal observed in the byte stream. Gated views never see
* these bytes, so without this fact their subscription registry goes stale
+57 -1
View File
@@ -1,5 +1,14 @@
import { describe, expect, it } from 'vitest'
import { tokenizeStartupCommand } from './tui-agent-startup-shell'
import {
buildShellCommandFromArgv,
clearEnvCommand,
commandSeparator,
isPosixStartupShell,
quoteStartupArg,
resolveLoginShellStartupDialect,
tokenizeStartupCommand
} from './tui-agent-startup-shell'
import { buildAgentDraftLaunchPlan } from './tui-agent-startup'
function expectSpansCoverTokens(source: string, shell: 'powershell' | 'cmd'): string[] {
const result = tokenizeStartupCommand(source, shell)
@@ -56,3 +65,50 @@ describe('tokenizeStartupCommand spans (windows shells)', () => {
expect(expectSpansCoverTokens('claude ""', 'cmd')).toEqual(['claude', '""'])
})
})
describe('fish startup shell dialect', () => {
it('clears variables with fish syntax instead of the sh `unset` fish rejects', () => {
expect(clearEnvCommand('CODEX_HOME', 'fish')).toBe('set -e CODEX_HOME')
expect(clearEnvCommand('CODEX_HOME', 'posix')).toBe('unset CODEX_HOME')
expect(clearEnvCommand('CODEX_HOME', 'cmd')).toBe('set "CODEX_HOME="')
expect(clearEnvCommand('CODEX_HOME', 'powershell')).toBe(
'Remove-Item Env:CODEX_HOME -ErrorAction SilentlyContinue'
)
})
it('shares POSIX quoting, tokenizing and chaining', () => {
expect(quoteStartupArg("it's", 'fish')).toBe(quoteStartupArg("it's", 'posix'))
expect(buildShellCommandFromArgv(['codex', 'resume', 'a b'], 'fish')).toBe(
buildShellCommandFromArgv(['codex', 'resume', 'a b'], 'posix')
)
expect(tokenizeStartupCommand('codex --arg "a b"', 'fish')).toEqual(
tokenizeStartupCommand('codex --arg "a b"', 'posix')
)
expect(commandSeparator('fish')).toBe('; ')
expect(isPosixStartupShell('fish')).toBe(true)
})
it('maps login shell paths to their dialect', () => {
expect(resolveLoginShellStartupDialect('/opt/homebrew/bin/fish')).toBe('fish')
expect(resolveLoginShellStartupDialect('/usr/local/bin/FISH')).toBe('fish')
expect(resolveLoginShellStartupDialect('/bin/zsh')).toBe('posix')
expect(resolveLoginShellStartupDialect('/bin/bash')).toBe('posix')
expect(resolveLoginShellStartupDialect('')).toBe('posix')
expect(resolveLoginShellStartupDialect(undefined)).toBe('posix')
})
// Contract-only: no POSIX caller threads a shell into buildAgentDraftLaunchPlan yet
// (`selectedRepoStartupShell` is Windows-only), so fish users still get `unset` here.
it('clears an agent draft prefill variable with fish syntax', () => {
const plan = buildAgentDraftLaunchPlan({
agent: 'pi',
draft: 'hello',
cmdOverrides: {},
platform: 'darwin',
shell: 'fish'
})
expect(plan?.launchCommand).toBe('pi; set -e ORCA_PI_PREFILL')
expect(plan?.env?.ORCA_PI_PREFILL).toBe('hello')
})
})
+31 -5
View File
@@ -1,6 +1,27 @@
import { tokenizeCustomCommandTemplate, type CommandTokenSpan } from './commit-message-prompt'
export type AgentStartupShell = 'posix' | 'powershell' | 'cmd'
// Why: fish shares POSIX word splitting, quoting and `;` chaining, so it is a
// separate dialect only where its grammar actually diverges (env clearing).
export type AgentStartupShell = 'posix' | 'fish' | 'powershell' | 'cmd'
type WindowsStartupShell = Extract<AgentStartupShell, 'powershell' | 'cmd'>
/** True for shells parsed with POSIX quoting/word rules (sh family + fish). */
export function isPosixStartupShell(shell: AgentStartupShell): boolean {
return shell === 'posix' || shell === 'fish'
}
function isWindowsStartupShell(shell: AgentStartupShell): shell is WindowsStartupShell {
return shell === 'powershell' || shell === 'cmd'
}
/** Maps a POSIX login-shell path (`$SHELL`) to the dialect that parses queued commands. */
export function resolveLoginShellStartupDialect(
loginShell: string | null | undefined
): AgentStartupShell {
const basename = loginShell?.trim().replaceAll('\\', '/').split('/').pop()?.toLowerCase() ?? ''
return basename === 'fish' ? 'fish' : 'posix'
}
export type StartupCommandTokens =
| { ok: true; tokens: string[]; spans: CommandTokenSpan[] }
@@ -19,7 +40,7 @@ function hasOddBackslashRun(value: string, quoteIndex: number): boolean {
function tokenizeWindowsStartupCommand(
value: string,
shell: Exclude<AgentStartupShell, 'posix'>
shell: WindowsStartupShell
): StartupCommandTokens {
const tokens: string[] = []
const spans: CommandTokenSpan[] = []
@@ -133,9 +154,9 @@ export function tokenizeStartupCommand(
value: string,
shell: AgentStartupShell
): StartupCommandTokens {
return shell === 'posix'
? tokenizeCustomCommandTemplate(value)
: tokenizeWindowsStartupCommand(value, shell)
return isWindowsStartupShell(shell)
? tokenizeWindowsStartupCommand(value, shell)
: tokenizeCustomCommandTemplate(value)
}
export function resolveStartupShell(
@@ -173,6 +194,11 @@ export function clearEnvCommand(name: string, shell: AgentStartupShell): string
if (shell === 'cmd') {
return `set "${name}="`
}
// Why: fish has no `unset`; the sh spelling errors out and silently leaves
// the variable exported (e.g. an account-routed CODEX_HOME survives).
if (shell === 'fish') {
return `set -e ${name}`
}
return `unset ${name}`
}
@@ -54,10 +54,8 @@ type HiddenPressureDeps<TMeasurement, TDebug, TScheduler, TMainPressure, TAckGat
writeInteractivePromptScript: (scriptPath: string, runId: string) => void
}
// Why: the renderer hidden-skip counters are gone with the skip grammar —
// withheld hidden output is observed via main's delivery-drop counters only.
type HiddenPressureDebug = {
hiddenRendererMode2031ReplyCount: number
hiddenRendererSkipCount: number
}
type HiddenPressureMeasurement = {
@@ -27,9 +27,7 @@ type RevisitPressureMeasurement = {
maxTimerDriftMs: number
}
// Why: the renderer hidden-skip counters were deleted with the skip grammar;
// only the mode-2031 fact-reply counter still exists renderer-side.
type RevisitPressureDebug = { hiddenRendererMode2031ReplyCount: number }
type RevisitPressureDebug = { hiddenRendererSkipCount: number }
type RevisitPressureSchedulerSnapshot = {
peakQueuedChars: number
@@ -56,11 +56,8 @@ type SyntheticOpenCodeWindow = Window & {
}
}
// Why: the renderer hidden-skip grammar is deleted — hidden bytes are dropped
// in main (gate) or ride the background queue. Only the mode-2031 fact-reply
// counter still has a renderer-side producer.
type TerminalPtyOutputDebugSnapshot = {
hiddenRendererMode2031ReplyCount: number
hiddenRendererSkipCount: number
}
type TerminalOutputSchedulerDebugSnapshot = {
@@ -373,7 +370,7 @@ function annotateTypingMeasurement(
mainPressure: MainPtyPressureDebugSnapshot | null = null,
ackGate: TerminalPtyAckGateSnapshot | null = null
): void {
const mode2031Summary = debug ? ` mode2031Replies=${debug.hiddenRendererMode2031ReplyCount}` : ''
const hiddenSkipSummary = debug ? ` hiddenRendererSkips=${debug.hiddenRendererSkipCount}` : ''
const schedulerSummary = scheduler
? ` deferredForegroundEnqueue=${scheduler.deferredForegroundEnqueueCount} deferredForegroundWrite=${scheduler.deferredForegroundWriteCount} scheduledDrains=${scheduler.scheduledDrainCount} rendererQueuedTerminals=${scheduler.queuedTerminalCount} rendererQueuedChars=${scheduler.queuedChars} rendererPeakQueuedTerminals=${scheduler.peakQueuedTerminalCount} rendererPeakQueuedChars=${scheduler.peakQueuedChars} rendererPeakQueuedCharsByTerminal=${scheduler.peakQueuedCharsByTerminal} rendererDroppedBacklogs=${scheduler.droppedBacklogCount}`
: ''
@@ -391,7 +388,7 @@ function annotateTypingMeasurement(
1
)}ms maxTimerDrift=${measurement.maxTimerDriftMs.toFixed(1)}ms samples=${measurement.latencies
.map((value) => value.toFixed(1))
.join(',')}${mode2031Summary}${schedulerSummary}${mainPressureSummary}${ackGateSummary}`
.join(',')}${hiddenSkipSummary}${schedulerSummary}${mainPressureSummary}${ackGateSummary}`
})
}
@@ -28,7 +28,7 @@ type HiddenTuiWindow = Window & {
__terminalPtyOutputDebug?: {
reset: () => void
snapshot: () => {
hiddenRendererMode2031ReplyCount: number
hiddenRendererSkipCount: number
}
}
}
@@ -60,7 +60,6 @@ type SchedulerDebugWindow = Window & {
type HiddenOutputDebugSnapshot = {
hiddenRendererSkipCount: number
hiddenRendererSkippedChars: number
hiddenRendererMode2031ReplyCount: number
}
type HiddenOutputRecoveryWindow = Window & {
@@ -0,0 +1,398 @@
#!/usr/bin/env node
/**
* Ground-truth repro harness for issue #9993: color-scheme (CSI ?997;1n) replies
* leaking into a child process' stdin under fish.
*
* Simulates Orca's terminal pipeline around a REAL fish shell:
* node-pty -> scanMode2031ReplyDecision() (the real scanner from
* src/shared/terminal-color-scheme-protocol.ts)
* -> reply `CSI ?997;1n` written back to the PTY, optionally after
* a delay that models the renderer IPC round-trip.
*
* Requires Node >= 22.18 (native TypeScript type stripping) so the real .ts
* scanner can be imported without a build step.
*
* Usage:
* node tests/tools/fish-mode2031-repro/capture-fish-mode2031-trace.mjs [options]
*
* --delay=<ms> reply delay; 0 = synchronous in-process (default 0)
* --shell=<path> fish binary (default /opt/homebrew/bin/fish)
* --user-config use the caller's real fish config (Tide etc.) instead of
* an isolated minimal config
* --prompt-cmd add an external command substitution to fish_prompt, so
* the prompt itself forces extra tty handoffs
* --no-probes do NOT answer DA1/DA2/CPR/XTVERSION/OSC 10/11 (fish then
* stalls 10s on its DA1 wait)
* --accept-delay=<ms> gap between typing the command and pressing Enter (300)
* --paste send command + Enter in a single write (paste-like)
* --no-reply never answer 2031 (control run)
* --json append a machine-readable summary
*/
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const REPO_ROOT = path.resolve(import.meta.dirname, '../../..')
const pty = require(path.join(REPO_ROOT, 'node_modules/node-pty'))
const { INITIAL_MODE_2031_REPLY_SCAN_STATE, mode2031SequenceFor, scanMode2031ReplyDecision } =
await import(path.join(REPO_ROOT, 'src/shared/terminal-color-scheme-protocol.ts'))
// ---------------------------------------------------------------- args
const argv = process.argv.slice(2)
const flag = (name) => argv.includes(`--${name}`)
const opt = (name, fallback) => {
const hit = argv.find((a) => a.startsWith(`--${name}=`))
return hit === undefined ? fallback : hit.slice(name.length + 3)
}
const REPLY_DELAY_MS = Number(opt('delay', '0'))
const SHELL = opt('shell', '/opt/homebrew/bin/fish')
const USE_USER_CONFIG = flag('user-config')
const ANSWER_PROBES = !flag('no-probes')
const REPLY_ENABLED = !flag('no-reply')
const EMIT_JSON = flag('json')
const ACCEPT_DELAY_MS = Number(opt('accept-delay', '300'))
const PASTE = flag('paste')
const PROMPT_CMD = flag('prompt-cmd')
const TYPEAHEAD = flag('typeahead')
const TYPEAHEAD_AT_MS = Number(opt('typeahead-at', '150'))
const SETTLE_MS = Number(opt('settle', '400'))
const PROMPT_MARK = 'HARNESS> '
const CHILD_CMD = `python3 -c 'import sys; d=sys.stdin.readline(); print("GOT:", repr(d))'`
// ---------------------------------------------------------------- tracing
const t0 = process.hrtime.bigint()
const ms = () => Number(process.hrtime.bigint() - t0) / 1e6
const stamp = () => ms().toFixed(3).padStart(10, ' ')
const trace = []
function log(dir, label, detail) {
const line = `[${stamp()}ms] ${dir.padEnd(12)} ${label}${detail ? ` ${detail}` : ''}`
trace.push(line)
console.log(line)
}
function esc(s) {
let out = ''
for (const ch of s) {
const c = ch.codePointAt(0)
if (ch === '\x1b') {
out += '\\e'
} else if (ch === '\n') {
out += '\\n'
} else if (ch === '\r') {
out += '\\r'
} else if (ch === '\t') {
out += '\\t'
} else if (c < 0x20 || c === 0x7f) {
out += `\\x${c.toString(16).padStart(2, '0')}`
} else if (c === 0x9b) {
out += '\\x9b'
} else {
out += ch
}
}
return out
}
const CHUNK_PRINT_CAP = 1200
function escCapped(s) {
const e = esc(s)
return e.length > CHUNK_PRINT_CAP
? `${e.slice(0, CHUNK_PRINT_CAP)}…<+${e.length - CHUNK_PRINT_CAP} esc-chars>`
: e
}
// ---------------------------------------------------------------- fish config
let configHome = null
const env = { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', LANG: 'en_US.UTF-8' }
delete env.FISH_HISTORY
if (!USE_USER_CONFIG) {
configHome = mkdtempSync(path.join(tmpdir(), 'fish-2031-'))
mkdirSync(path.join(configHome, 'fish'), { recursive: true })
// Plain prompt: no Tide, no async prompt machinery. fish core still toggles
// DEC 2031 in tty_handoff.rs regardless of the prompt.
const promptBody = PROMPT_CMD
? `set -l __x (/bin/echo -n ok); printf '${PROMPT_MARK}'`
: `printf '${PROMPT_MARK}'`
writeFileSync(
path.join(configHome, 'fish/config.fish'),
[
'set -g fish_greeting ""',
`function fish_prompt; ${promptBody}; end`,
'function fish_right_prompt; end',
''
].join('\n')
)
env.XDG_CONFIG_HOME = configHome
env.XDG_DATA_HOME = path.join(configHome, 'data')
}
// ---------------------------------------------------------------- pty
log(
'HARNESS',
'config',
JSON.stringify({
shell: SHELL,
replyDelayMs: REPLY_DELAY_MS,
acceptDelayMs: ACCEPT_DELAY_MS,
paste: PASTE,
promptCmd: PROMPT_CMD,
replyEnabled: REPLY_ENABLED,
userConfig: USE_USER_CONFIG,
answerProbes: ANSWER_PROBES,
xdgConfigHome: configHome
})
)
const term = pty.spawn(SHELL, ['-l', '-i'], {
name: 'xterm-256color',
cols: 120,
rows: 30,
cwd: REPO_ROOT,
env
})
let scanState = INITIAL_MODE_2031_REPLY_SCAN_STATE
let all = ''
let replyCount = 0
const replyEvents = []
const decisionEvents = []
let phase = 'boot'
let chunkSeq = 0
let lastDecision = null
let staleReplyCount = 0
function ptyWrite(bytes, label) {
log('HARNESS->PTY', label, `"${esc(bytes)}"`)
term.write(bytes)
}
function sendMode2031Reply(reason) {
replyCount += 1
// Stale = the scanner has already seen fish's `CSI ?2031l` (tty handed to a child)
// by the time this reply actually reaches the PTY. That reply lands in the child's stdin.
const stale = lastDecision === 'unsubscribed'
if (stale) {
staleReplyCount += 1
}
replyEvents.push({ at: ms(), phase, reason, delayMs: REPLY_DELAY_MS, stale })
ptyWrite(
mode2031SequenceFor('dark'),
`reply#${replyCount} CSI ?997;1n (${reason}, phase=${phase}${stale ? ', STALE: fish already sent ?2031l' : ''})`
)
}
// Modest emulation of the other terminal replies fish may probe for.
function answerProbes(data) {
if (!ANSWER_PROBES) {
return
}
// oxlint-disable no-control-regex -- terminal escape sequences require control chars
if (/\x1b\[(?:0)?c/.test(data)) {
ptyWrite('\x1b[?62;4;6;22c', 'probe reply DA1')
}
if (/\x1b\[>(?:0)?c/.test(data)) {
ptyWrite('\x1b[>1;95;0c', 'probe reply DA2')
}
if (/\x1b\[(?:6n)/.test(data)) {
ptyWrite('\x1b[1;1R', 'probe reply CPR')
}
if (/\x1b\[>q/.test(data)) {
ptyWrite('\x1bP>|orca-harness(1)\x1b\\', 'probe reply XTVERSION')
}
if (/\x1b\]10;\?/.test(data)) {
ptyWrite('\x1b]10;rgb:ffff/ffff/ffff\x1b\\', 'probe reply OSC 10')
}
if (/\x1b\]11;\?/.test(data)) {
ptyWrite('\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\', 'probe reply OSC 11')
}
// oxlint-enable no-control-regex
}
term.onData((data) => {
const seq = ++chunkSeq
all += data
// oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars
const mode2031ToggleRe = /\x1b\[\?([0-9;]+)([hl])/g
const toggles = [...data.matchAll(mode2031ToggleRe)]
.filter((m) => m[1].split(';').some((p) => Number(p) === 2031))
.map((m) => `?2031${m[2]}`)
log(
'PTY->SCANNER',
`chunk#${seq} len=${data.length}${toggles.length ? ` toggles=[${toggles.join(',')}]` : ''}`,
`"${escCapped(data)}"`
)
const before = scanState
const result = scanMode2031ReplyDecision(scanState, data)
scanState = result.state
if (result.decision || before.pendingSubscribe !== scanState.pendingSubscribe || scanState.tail) {
log(
'SCANNER',
`chunk#${seq} decision=${result.decision ?? 'null'}`,
`state={tail:"${esc(scanState.tail)}",pendingSubscribe:${scanState.pendingSubscribe}}`
)
}
if (result.decision) {
lastDecision = result.decision
decisionEvents.push({ at: ms(), seq, decision: result.decision, phase })
}
if (REPLY_ENABLED && result.decision === 'subscribed') {
if (REPLY_DELAY_MS <= 0) {
sendMode2031Reply(`chunk#${seq} sync`)
} else {
setTimeout(() => sendMode2031Reply(`chunk#${seq} +${REPLY_DELAY_MS}ms`), REPLY_DELAY_MS)
}
}
answerProbes(data)
})
let exitInfo = null
term.onExit((e) => {
exitInfo = e
log('PTY', 'exit', JSON.stringify(e))
})
// ---------------------------------------------------------------- driving
const sleep = (n) => new Promise((r) => setTimeout(r, n))
async function waitFor(pred, timeoutMs, what) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (pred()) {
return true
}
await sleep(10)
}
log('HARNESS', 'TIMEOUT', `waiting for ${what}`)
return false
}
const countPrompts = () => all.split(PROMPT_MARK).length - 1
try {
phase = 'boot'
await waitFor(() => all.includes(PROMPT_MARK), 15000, 'first prompt')
await sleep(SETTLE_MS)
let promptsBeforeAccept
let repliesBeforeAccept
let decisionsBeforeAccept
let outLenBeforeAccept
if (TYPEAHEAD) {
// Most realistic leak window: queue the command while an external command
// still owns the tty. fish redraws the prompt (`CSI ?2031h`) and consumes the
// buffered line in the same breath, so the handoff to the child happens
// sub-millisecond after the subscribe.
phase = 'pre-command'
log('HARNESS', 'MARK', '--- running `sleep 0.4` so the next line is typed ahead ---')
ptyWrite('sleep 0.4\r', 'pre-command')
await sleep(TYPEAHEAD_AT_MS)
promptsBeforeAccept = countPrompts()
repliesBeforeAccept = replyCount
decisionsBeforeAccept = decisionEvents.length
outLenBeforeAccept = all.length
phase = 'prompt-accept'
log('HARNESS', 'MARK', '--- type-ahead: command + Enter while sleep still owns the tty ---')
ptyWrite(`${CHILD_CMD}\r`, 'typeahead command + Enter')
} else if (PASTE) {
promptsBeforeAccept = countPrompts()
repliesBeforeAccept = replyCount
decisionsBeforeAccept = decisionEvents.length
outLenBeforeAccept = all.length
phase = 'prompt-accept'
log('HARNESS', 'MARK', '--- pasting command + Enter in one write (prompt-accept) ---')
ptyWrite(`${CHILD_CMD}\r`, 'paste command + Enter')
} else {
phase = 'type-command'
log('HARNESS', 'MARK', '--- typing child command (no Enter yet) ---')
ptyWrite(CHILD_CMD, 'type command')
await sleep(ACCEPT_DELAY_MS)
promptsBeforeAccept = countPrompts()
repliesBeforeAccept = replyCount
decisionsBeforeAccept = decisionEvents.length
outLenBeforeAccept = all.length
phase = 'prompt-accept'
log('HARNESS', 'MARK', '--- pressing Enter (prompt-accept -> tty handoff to child) ---')
ptyWrite('\r', 'Enter')
}
await sleep(1200)
const repliesDuringAccept = replyCount - repliesBeforeAccept
const decisionsDuringAccept = decisionEvents.length - decisionsBeforeAccept
phase = 'child-stdin'
log('HARNESS', 'MARK', '--- typing hello + Enter into the child ---')
ptyWrite('hello\r', 'child stdin')
await sleep(1500)
phase = 'teardown'
// python's repr renders ESC as the 4 literal chars \x1b, so match on the printed form.
const gotLine = (all.slice(outLenBeforeAccept).match(/GOT: '[^\r\n]*/) ?? [])[0] ?? null
log('HARNESS', 'RESULT GOT-line', gotLine === null ? '<none captured>' : `"${esc(gotLine)}"`)
const leaked = gotLine !== null && /\\x1b|\\033|\\u001b/.test(gotLine)
const leaked997 = gotLine !== null && gotLine.includes('997;1n')
log('HARNESS', 'RESULT leaked-escape-bytes-into-stdin', String(leaked))
log('HARNESS', 'RESULT leaked-CSI-997-1n-into-stdin', String(leaked997))
log(
'HARNESS',
'RESULT replies',
JSON.stringify({
total: replyCount,
duringPromptAccept: repliesDuringAccept,
decisionsDuringPromptAccept: decisionsDuringAccept,
staleReplies: staleReplyCount,
promptsBeforeAccept
})
)
ptyWrite('exit\r', 'exit')
await waitFor(() => exitInfo !== null, 3000, 'shell exit')
try {
term.kill()
} catch {}
if (EMIT_JSON) {
console.log(
`\n__JSON__${JSON.stringify({
delayMs: REPLY_DELAY_MS,
mode: TYPEAHEAD ? 'typeahead' : PASTE ? 'paste' : 'typed',
acceptDelayMs: ACCEPT_DELAY_MS,
promptCmd: PROMPT_CMD,
replyEnabled: REPLY_ENABLED,
userConfig: USE_USER_CONFIG,
answerProbes: ANSWER_PROBES,
gotLine,
leaked,
leaked997,
totalReplies: replyCount,
staleReplies: staleReplyCount,
repliesDuringPromptAccept: repliesDuringAccept,
decisions: decisionEvents,
replies: replyEvents
})}`
)
}
} finally {
if (configHome) {
rmSync(configHome, { recursive: true, force: true })
}
}
process.exit(0)