Repository navigation
fix(terminal): make DECSET 2031 subscriptions silent so fish stops reading them as stdin - #13904
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📥 CommitsReviewing files that changed from the base of the PR and between 8fd462e1c440b4bf1d892f2395efede4848eafac and 8ce34e3. 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThe change adds Fish 4 detection, CI enforcement, startup-file parsing, session-aware environment lookup, Fish shell command handling, and Fish resume-shell support. It preserves 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (5)
src/renderer/src/lib/client-login-shell.ts (1)
1-8: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueCondense this JSDoc.
Retain one brief rationale about Fish requiring a distinct startup dialect. Remove the return-value and fallback details that the function already makes clear.
As per coding guidelines, “Comments must be concise, non-obvious, and brief—prefer one line; do not explain obvious behavior or walk through code.”
Source: Coding guidelines
src/shared/fish-binary-requirement.ts (1)
3-11: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueCondense the new block comments.
Keep one short comment for the CI skip-to-failure contract. Remove the repeated implementation explanation.
As per coding guidelines: “Comments must be concise, non-obvious, and brief—prefer one line; do not explain obvious behavior or walk through code.”
Also applies to: 51-55
Source: Coding guidelines
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts (1)
407-409: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueThe harness answers an OSC 10 query with an OSC 11 report.
The condition matches
\x1b]10;?(foreground query) or\x1b]11;?(background query), but the reply is always\x1b]11;.... fish receives a background report for a foreground query. Today fish does not block on OSC 10, so the test still reaches the prompt. Answer each query with its matching OSC number so the harness stays faithful if fish's probe handling changes.🎨 Proposed probe-reply split
- if (chunk.includes('\x1b]10;?') || chunk.includes('\x1b]11;?')) { - term.write('\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\') - } + if (chunk.includes('\x1b]10;?')) { + term.write('\x1b]10;rgb:eeee/eeee/eeee\x1b\\') + } + if (chunk.includes('\x1b]11;?')) { + term.write('\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\') + }tests/tools/fish-mode2031-repro/capture-fish-mode2031-trace.mjs (1)
40-42: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider a POSIX-independent default shell and a file URL for the dynamic import.
Line 54 defaults
SHELLto/opt/homebrew/bin/fish, which exists only on Apple Silicon Homebrew. A Linux user must always pass--shell=. Line 42 imports a.tsfile through a bare absolute path; ESM on Windows requires afile://URL, soawait importfails there. This tool is a manual repro harness, so neither blocks the PR. Resolving fish fromPATHand usingpathToFileURLwould remove the friction.🔧 Proposed portability adjustments
+import { pathToFileURL } from 'node:url' + 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')) + await import(pathToFileURL(path.join(REPO_ROOT, 'src/shared/terminal-color-scheme-protocol.ts')).href)-const SHELL = opt('shell', '/opt/homebrew/bin/fish') +// Resolve from PATH so Linux and Intel-Homebrew hosts do not need --shell=. +const SHELL = opt('shell', 'fish')Also applies to: 54-54
mobile/src/terminal/terminal-webview-color-scheme-push-injected.ts (1)
32-35: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low valueThe mobile matcher is stricter than the desktop one.
Desktop uses
isMode2031ColorSchemeReport(data)insrc/renderer/src/components/terminal-pane/xterm-theme-write-color-scheme-push.ts. This twin uses exact string equality against the two report constants. If xterm ever emits the report joined with other bytes in oneonDatacall, the desktop side still drops it and this side forwards it.xterm emits
_reportColorSchemeas its ownonDatacall today, so the behavior matches in practice. Add a short note here recording that assumption, so a future xterm upgrade does not silently split the twins.💡 Proposed comment
function isSuppressedXtermColorSchemePush(data) { + // Exact match is enough: xterm emits _reportColorScheme as its own onData call, never + // concatenated. Desktop's isMode2031ColorSchemeReport is the tolerant twin of this check. return xtermColorSchemePushSuppressionDepth > 0 && (data === MODE_2031_DARK_REPORT || data === MODE_2031_LIGHT_REPORT); }
🔇 Additional comments (58)
src/main/ipc/pty-hidden-delivery-gate.ts (1)
21-21: LGTM!src/renderer/src/components/terminal-pane/pty-input-write-queue.test.ts (1)
472-473: LGTM!Also applies to: 525-564
src/renderer/src/lib/client-login-shell.ts (1)
9-15: LGTM!src/shared/terminal-mode-2031-final-state.test.ts (1)
7-19: LGTM!Also applies to: 140-209
src/main/daemon/shell-ready.test.ts (1)
8-8: LGTM!Also applies to: 20-21, 170-174
.github/workflows/pr.yml (1)
179-184: LGTM!Also applies to: 195-226, 240-240, 275-275
src/relay/plugin-overlay-env.ts (1)
1-1: 🩺 Stability & AvailabilityVerify relay bundle compatibility for this new main-process import.
src/relayis deployed as a separate bundle. Confirm thatsrc/main/pty/shell-startup-env.tsis relay-safe and run the established relay bundle check before merge. The PR reports that build was not run.Based on learnings: “
src/relayis bundled separately for remote deploy,” and imports fromsrc/mainrequire bundle-safety confirmation.Source: Learnings
src/relay/plugin-overlay-env.test.ts (1)
107-133: LGTM!src/main/codex/codex-pane-account-registry.ts (1)
114-114: LGTM!Also applies to: 211-211
src/main/codex/codex-real-home-path.test.ts (1)
1-1: LGTM!Also applies to: 14-22, 65-67, 87-132
src/main/codex/codex-real-home-path.ts (1)
8-9: LGTM!Also applies to: 59-72, 94-95, 111-118
src/renderer/src/lib/ai-vault-resume-shell.test.ts (1)
1-127: LGTM!src/renderer/src/lib/ai-vault-resume-shell.ts (1)
7-11: LGTM!Also applies to: 32-35
src/shared/tui-agent-startup-shell.ts (1)
3-24: LGTM!Also applies to: 86-88, 126-130
src/shared/tui-agent-startup-shell.test.ts (1)
13-55: LGTM!src/shared/startup-command-submission.test.ts (1)
2-5: LGTM!Also applies to: 50-68
src/shared/startup-command-submission.ts (1)
26-51: LGTM!src/main/providers/local-pty-provider.ts (1)
16-16: LGTM!Also applies to: 1050-1057
src/main/providers/local-pty-shell-ready.ts (1)
436-436: LGTM!src/shared/hermes-startup-query.ts (1)
4-4: LGTM!Also applies to: 118-118, 143-143
src/main/pty/shell-startup-env.ts (1)
1-1: LGTM!Also applies to: 14-79, 91-139, 210-289
src/main/pty/shell-startup-env.test.ts (1)
3-33: LGTM!Also applies to: 337-541
src/main/ipc/pty.ts (1)
130-130: LGTM!Also applies to: 1501-1501, 1516-1516, 1670-1670
src/shared/ai-vault-resume-command.ts (1)
102-107: 🎯 Functional CorrectnessKeep the existing Fish environment syntax.
Fish 3.1 and later support
CODEX_HOME=value commandfor a single command. The existing construction is valid, so the proposedenvbranch is unnecessary.> Likely an incorrect or invalid review comment.src/shared/terminal-color-scheme-protocol.ts (1)
14-18: LGTM!src/shared/terminal-reply-query-scan.ts (1)
27-38: LGTM!Also applies to: 55-58, 98-98
src/shared/terminal-reply-query-scan.test.ts (1)
6-23: LGTM!Also applies to: 60-112
src/shared/terminal-output-side-effects.ts (1)
68-72: LGTM!src/shared/terminal-query-reply.ts (1)
95-97: LGTM!src/shared/terminal-side-effect-facts.ts (1)
32-35: LGTM!src/renderer/src/components/terminal-pane/pty-connection-types.ts (1)
102-105: LGTM!src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts (1)
882-886: LGTM!src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts (1)
88-91: LGTM!src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts (1)
7-7: LGTM!tests/e2e/artificial-opencode-revisit-pressure-scenario.ts (1)
30-30: Already covered by the debug-field rename verification ontests/e2e/artificial-opencode-hidden-pressure-scenario.ts.tests/e2e/artificial-opencode-terminal-load.spec.ts (2)
59-60: Already covered by the debug-field rename verification ontests/e2e/artificial-opencode-hidden-pressure-scenario.ts.
373-373: LGTM!Also applies to: 391-391
src/renderer/src/components/terminal-pane/terminal-mode-2031-replies.ts (1)
16-19: LGTM!src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts (1)
65-65: LGTM!Also applies to: 208-210, 249-249, 288-290
src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts (1)
111-123: LGTM!Also applies to: 363-373, 866-874, 899-916
src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts (1)
2-2: LGTM!Also applies to: 85-85
mobile/src/terminal/terminal-webview-theme-injected.ts (1)
105-111: 🩺 Stability & Availability
⚠️ Unverified finding
Sandbox verification was unavailable.Verify that the color-scheme push script is injected before the theme script.
applyTerminalThemenow callswithSuppressedXtermColorSchemePush, whichTERMINAL_COLOR_SCHEME_PUSH_JSdefines. Ifterminal-webview-html.tsinjectsTERMINAL_WEBVIEW_THEME_JSfirst, the first theme apply throws aReferenceErrorand the WebView keeps the default theme. The unit test builds its own load order, so it does not cover the shipped HTML.src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts (1)
223-225: 🩺 Stability & Availability
⚠️ Unverified finding
Sandbox verification was unavailable.Confirm that this always-on test does not fail where fish is absent.
itWithFishskips the regression when fish 4 is missing, but this test always runs and assertsfishRequirementViolation(FISH)isnull. IffishRequirementViolationreports a violation whenever fish 4 is unavailable, the suite fails on every macOS, Linux, and Windows machine without fish 4, not only on the CI lane that requires it.src/shared/fish-binary-requirement.tsis not in this review context.tests/e2e/artificial-opencode-hidden-pressure-scenario.ts (1)
57-58: 📐 Maintainability & Code Quality
⚠️ Unverified finding
Sandbox verification was unavailable.Confirm the renderer debug snapshot emits
hiddenRendererSkipCount.This type now declares
hiddenRendererSkipCount. The producer of__terminalPtyOutputDebug.snapshot()is not in this review context. If the producer still emitshiddenRendererMode2031ReplyCount, the annotation printsundefinedand the diagnostic silently loses value. The same rename appears intests/e2e/artificial-opencode-revisit-pressure-scenario.ts,tests/e2e/artificial-opencode-terminal-load.spec.ts, andtests/e2e/terminal-hidden-tui-visual-restore.spec.ts.src/renderer/src/components/terminal-pane/terminal-appearance.test.ts (3)
226-242: 📐 Maintainability & Code QualityThe static analysis hint on this line is a false positive.
OpenGrep reports
child_process.execcommand injection at line 233. The call isRegExp.prototype.execon a color string, notchild_process.exec. No action is required.Source: Linters/SAST tools
336-353: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
⚠️ Unverified finding
Sandbox verification was unavailable.Consider clearing the fit override instead of overwriting it.
The
finallyblock writes adesktop-fitentry at 0x0 forpty-held. That leaves module-level state behind for later tests in this file. Ifmobile-fit-overridesexposes a clear/delete API, prefer it so the module returns to its pre-test state.
202-224: LGTM!Also applies to: 251-334
src/renderer/src/components/terminal-pane/xterm-theme-write-color-scheme-push.ts (1)
1-34: LGTM!src/renderer/src/components/terminal-pane/terminal-appearance.ts (1)
113-147: LGTM!Also applies to: 191-191, 201-205, 251-256
src/renderer/src/components/terminal-pane/pty-connection.ts (2)
3874-3884: 🗄️ Data Integrity & Integration
⚠️ Unverified finding
Sandbox verification was unavailable.Verify the
recordPaneMode2031Subscriptiondependency and the new module edge.Two points need confirmation:
deps.recordPaneMode2031Subscription?.()is optional. If no caller supplies it, a gate-managed pane records no subscription and never receives a later theme flip. Confirm every production caller wires it.pty-connection.tsnow importsresolveTerminalPaneColorSchemeModefrom./terminal-appearance. Confirmterminal-appearance.tsdoes not import back frompty-connection.ts, so no module cycle is created.
4103-4108: LGTM!Also applies to: 6344-6368
src/renderer/src/components/terminal-pane/pty-connection.test.ts (1)
12250-12256: LGTM!Also applies to: 12292-12320, 13499-13536, 13596-13687, 13721-13924, 13967-13969, 15216-15217
mobile/src/terminal/terminal-webview-color-scheme-push-injected.ts (1)
18-30: LGTM!mobile/src/terminal/terminal-webview-color-scheme-push-injected.test.ts (1)
1-127: LGTM!mobile/src/terminal/terminal-webview-html.ts (1)
13-13: LGTM!Also applies to: 223-223
mobile/src/terminal/terminal-webview-query-reply-injected.ts (1)
14-20: LGTM!mobile/src/terminal/terminal-webview-query-reply.test.ts (1)
4-4: LGTM!Also applies to: 23-23
mobile/src/terminal/terminal-webview-theme-injected.test.ts (1)
5-21: LGTM!
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 93f5c478-bfdd-4098-9b61-c94a1d849da3
📒 Files selected for processing (63)
.github/workflows/pr.ymlmobile/src/terminal/terminal-webview-color-scheme-push-injected.test.tsmobile/src/terminal/terminal-webview-color-scheme-push-injected.tsmobile/src/terminal/terminal-webview-html.tsmobile/src/terminal/terminal-webview-query-reply-injected.tsmobile/src/terminal/terminal-webview-query-reply.test.tsmobile/src/terminal/terminal-webview-theme-injected.test.tsmobile/src/terminal/terminal-webview-theme-injected.tssrc/main/codex/codex-pane-account-registry.tssrc/main/codex/codex-real-home-path.test.tssrc/main/codex/codex-real-home-path.tssrc/main/daemon/shell-ready.test.tssrc/main/ipc/pty-hidden-delivery-gate.tssrc/main/ipc/pty.tssrc/main/providers/local-pty-provider.tssrc/main/providers/local-pty-shell-ready.tssrc/main/pty/shell-startup-env.test.tssrc/main/pty/shell-startup-env.tssrc/relay/plugin-overlay-env.test.tssrc/relay/plugin-overlay-env.tssrc/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.tssrc/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.tssrc/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.tssrc/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.test.tssrc/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.tssrc/renderer/src/components/terminal-pane/pty-connection-types.tssrc/renderer/src/components/terminal-pane/pty-connection.test.tssrc/renderer/src/components/terminal-pane/pty-connection.tssrc/renderer/src/components/terminal-pane/pty-input-write-queue.test.tssrc/renderer/src/components/terminal-pane/terminal-appearance.test.tssrc/renderer/src/components/terminal-pane/terminal-appearance.tssrc/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.tssrc/renderer/src/components/terminal-pane/terminal-mode-2031-replies.tssrc/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.tssrc/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.tssrc/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.tssrc/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.tssrc/renderer/src/components/terminal-pane/xterm-theme-write-color-scheme-push.tssrc/renderer/src/lib/ai-vault-resume-shell.test.tssrc/renderer/src/lib/ai-vault-resume-shell.tssrc/renderer/src/lib/client-login-shell.tssrc/shared/ai-vault-resume-command.tssrc/shared/fish-binary-requirement.test.tssrc/shared/fish-binary-requirement.tssrc/shared/hermes-startup-query.tssrc/shared/startup-command-submission.test.tssrc/shared/startup-command-submission.tssrc/shared/terminal-color-scheme-protocol.tssrc/shared/terminal-mode-2031-final-state.test.tssrc/shared/terminal-output-side-effects.tssrc/shared/terminal-query-reply.tssrc/shared/terminal-reply-query-scan.test.tssrc/shared/terminal-reply-query-scan.tssrc/shared/terminal-side-effect-facts.tssrc/shared/terminal-view-attributes.tssrc/shared/tui-agent-startup-shell.test.tssrc/shared/tui-agent-startup-shell.tstests/e2e/artificial-opencode-hidden-pressure-scenario.tstests/e2e/artificial-opencode-revisit-pressure-scenario.tstests/e2e/artificial-opencode-terminal-load.spec.tstests/e2e/terminal-hidden-tui-visual-restore.spec.tstests/e2e/terminal-tab-switch-visual-restore.spec.tstests/tools/fish-mode2031-repro/capture-fish-mode2031-trace.mjs
💤 Files with no reviewable changes (4)
- tests/e2e/terminal-tab-switch-visual-restore.spec.ts
- src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.test.ts
- src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts
- src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts
| expect( | ||
| await waitUntil( | ||
| () => rendered.slice(renderedBeforeChildInput).includes('CHILD-READ:'), | ||
| 10_000 | ||
| ) | ||
| ).toBe(true) |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
The internal wait deadlines can exceed the test timeout.
The sequential waitUntil budgets are 15s (prompt) + 5s (arm) + 5s (withdrawal #1) + 5s (withdrawal #2) + 10s (child read), which totals 40s. The test timeout is 30s. On a slow runner the suite reports a generic Vitest timeout instead of the specific waitUntil assertion that failed, which makes triage harder. Raise the test timeout above the sum of the internal deadlines, or lower the internal deadlines.
⏱️ Proposed timeout budget fix
- 30_000
+ // Must exceed the sum of the internal waitUntil deadlines (15+5+5+5+10s),
+ // or a slow runner reports a generic timeout instead of the failing wait.
+ 45_000Also applies to: 474-474
| } finally { | ||
| term.write('exit\r') | ||
| await waitUntil(() => exited, 3_000) | ||
| try { | ||
| term.kill() | ||
| } catch { | ||
| // already gone | ||
| } | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Guard the teardown write so it cannot mask an assertion failure.
term.write('exit\r') runs in finally. If fish already exited, the write can throw on the closed master fd. That throw replaces the original assertion error and hides the real failure. term.kill() below already handles this case.
🛡️ Proposed teardown guard
} finally {
- term.write('exit\r')
- await waitUntil(() => exited, 3_000)
+ try {
+ term.write('exit\r')
+ await waitUntil(() => exited, 3_000)
+ } catch {
+ // already gone
+ }
try {
term.kill()
} catch {
// already gone
}
}| 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}` | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Continue after an incompatible Fish candidate.
Line 37 returns when fish on PATH is too old. This prevents probing /opt/homebrew/bin/fish or /usr/local/bin/fish when either contains a compatible Fish version. Record the incompatible result, continue probing, and report it only when no candidate meets the minimum. Add a regression test with an old PATH candidate followed by a compatible fixed-path candidate.
Proposed fix
+ let incompatibleFish:
+ | { path: string; majorVersion: number; version: string }
+ | undefined
+
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}`
- }
+ incompatibleFish = {
+ path,
+ majorVersion,
+ version: probe.stdout?.trim() || 'an unknown version'
+ }
+ continue
}
return { available: true, path, majorVersion }
}
+ if (incompatibleFish) {
+ return {
+ available: false,
+ path: null,
+ majorVersion: incompatibleFish.majorVersion,
+ reason: `fish ${minMajorVersion}+ required, found ${incompatibleFish.version} at ${incompatibleFish.path}`
+ }
+ }
return { available: false, path: null, majorVersion: 0, reason: 'no fish binary on PATH' }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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}` | |
| } | |
| let incompatibleFish: | |
| | { path: string; majorVersion: number; version: string } | |
| | undefined | |
| 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) { | |
| incompatibleFish = { | |
| path, | |
| majorVersion, | |
| version: probe.stdout?.trim() || 'an unknown version' | |
| } | |
| continue | |
| } | |
| return { available: true, path, majorVersion } | |
| } | |
| if (incompatibleFish) { | |
| return { | |
| available: false, | |
| path: null, | |
| majorVersion: incompatibleFish.majorVersion, | |
| reason: `fish ${minMajorVersion}+ required, found ${incompatibleFish.version} at ${incompatibleFish.path}` | |
| } | |
| } | |
| return { available: false, path: null, majorVersion: 0, reason: 'no fish binary on PATH' } |
🧰 Tools
🪛 ast-grep (0.45.1)
[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { spawnSync } from 'node:child_process'
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
(detect-child-process-typescript)
🪛 OpenGrep (1.26.0)
[ERROR] 36-36: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.
(coderabbit.command-injection.exec-js)
| /** Resolved APP color-scheme mode. NOT a color-scheme answer for programs: | ||
| * both the DSR ?996n reply and the 2031/997 flip push follow the composed | ||
| * background's relative luminance like a visible xterm (_reportColorScheme), | ||
| * and the two can disagree (e.g. dark terminal theme in light app mode). */ |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Rename the mode in this contract.
colorSchemeMode is terminal-background-derived. It is not an app color-scheme mode. Line 28 conflicts with Line 31 and can mislead consumers of TerminalViewAttributes.
Proposed fix
- /** Resolved APP color-scheme mode. NOT a color-scheme answer for programs:
+ /** Resolved terminal color-scheme mode. NOT a color-scheme answer for programs:📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /** Resolved APP color-scheme mode. NOT a color-scheme answer for programs: | |
| * both the DSR ?996n reply and the 2031/997 flip push follow the composed | |
| * background's relative luminance like a visible xterm (_reportColorScheme), | |
| * and the two can disagree (e.g. dark terminal theme in light app mode). */ | |
| /** Resolved terminal color-scheme mode. NOT a color-scheme answer for programs: | |
| * both the DSR ?996n reply and the 2031/997 flip push follow the composed | |
| * background's relative luminance like a visible xterm (_reportColorScheme), | |
| * and the two can disagree (e.g. dark terminal theme in light app mode). */ |
| reset: () => void | ||
| snapshot: () => { | ||
| hiddenRendererMode2031ReplyCount: number | ||
| hiddenRendererSkipCount: number |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
The comment above this field now contradicts the field name.
The preceding comment states that only the mode-2031 fact-reply counter survives and that the hidden-skip counters were deleted. This change renames the field to hiddenRendererSkipCount, and this PR removes the mode-2031 reply counter. The comment now describes the opposite of the code. Update the comment text.
📝 Proposed comment fix
- // Why: only the mode-2031 fact-reply counter survives Phase 6 — the
- // hidden-skip counters were deleted with the renderer skip grammar.
+ // Why: the mode-2031 reply counter is gone (`#9993` made subscribes silent);
+ // the hidden-renderer skip count is the remaining withheld-output signal.| * 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 | ||
| */ |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Document the --typeahead, --typeahead-at, and --settle options.
Lines 62 to 64 implement these three options, but the usage block does not list them. The comment at Lines 294 to 298 describes --typeahead as the most realistic leak window, so it is the primary mode of this harness. A user reading the usage block cannot discover it.
📝 Proposed usage-block additions
* --accept-delay=<ms> gap between typing the command and pressing Enter (300)
* --paste send command + Enter in a single write (paste-like)
+ * --typeahead queue the command while an external command still owns
+ * the tty; the most realistic leak window
+ * --typeahead-at=<ms> delay before the typed-ahead line is written (150)
+ * --settle=<ms> quiet period after the first prompt (400)
* --no-reply never answer 2031 (control run)
* --json append a machine-readable summary📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| * 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 | |
| */ | |
| * 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) | |
| * --typeahead queue the command while an external command still owns | |
| * the tty; the most realistic leak window | |
| * --typeahead-at=<ms> delay before the typed-ahead line is written (150) | |
| * --settle=<ms> quiet period after the first prompt (400) | |
| * --no-reply never answer 2031 (control run) | |
| * --json append a machine-readable summary | |
| */ |
| 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')) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Validate the numeric options so a typo cannot silently change the experiment.
Number(opt('delay', '0')) returns NaN for a non-numeric value such as --delay=abc. NaN <= 0 is false, so the code takes the delayed branch at Line 255 and setTimeout(..., NaN) runs at 1ms. The harness then reports a delay the run did not use. The same problem applies to --accept-delay, --typeahead-at, and --settle. Reject non-numeric values instead.
🛡️ Proposed numeric-option validation
+const num = (name, fallback) => {
+ const raw = opt(name, fallback)
+ const value = Number(raw)
+ if (!Number.isFinite(value)) {
+ console.error(`--${name} must be a number, got "${raw}"`)
+ process.exit(2)
+ }
+ return value
+}
+
-const REPLY_DELAY_MS = Number(opt('delay', '0'))
+const REPLY_DELAY_MS = num('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 ACCEPT_DELAY_MS = num('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 TYPEAHEAD_AT_MS = num('typeahead-at', '150')
+const SETTLE_MS = num('settle', '400')📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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 num = (name, fallback) => { | |
| const raw = opt(name, fallback) | |
| const value = Number(raw) | |
| if (!Number.isFinite(value)) { | |
| console.error(`--${name} must be a number, got "${raw}"`) | |
| process.exit(2) | |
| } | |
| return value | |
| } | |
| const REPLY_DELAY_MS = num('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 = num('accept-delay', '300') | |
| const PASTE = flag('paste') | |
| const PROMPT_CMD = flag('prompt-cmd') | |
| const TYPEAHEAD = flag('typeahead') | |
| const TYPEAHEAD_AT_MS = num('typeahead-at', '150') | |
| const SETTLE_MS = num('settle', '400') |
dc15aa3 to
e8a8b58
Compare
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts (1)
1-19: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winCondense the file header comment.
Lines 1-19 repeat test mechanics that the code already shows. Keep a brief comment that states the regression contract and references
#9993.As per coding guidelines: “Comments must be concise, non-obvious, and brief—prefer one line; do not explain obvious behavior or walk through code.”
Source: Coding guidelines
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 9764a60a-a94f-40df-8121-9506e6fccb58
📒 Files selected for processing (11)
src/main/ipc/pty.tssrc/main/providers/local-pty-provider.tssrc/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.tssrc/renderer/src/components/terminal-pane/pty-connection.test.tssrc/renderer/src/components/terminal-pane/pty-connection.tssrc/renderer/src/components/terminal-pane/pty-input-write-queue.test.tssrc/renderer/src/lib/ai-vault-resume-command.tssrc/renderer/src/lib/ai-vault-resume-shell.test.tssrc/renderer/src/lib/ai-vault-resume-shell.tssrc/shared/tui-agent-startup-shell.test.tssrc/shared/tui-agent-startup-shell.ts
🚧 Files skipped from review as they are similar to previous changes (6)
- src/main/ipc/pty.ts
- src/renderer/src/lib/ai-vault-resume-shell.test.ts
- src/main/providers/local-pty-provider.ts
- src/shared/tui-agent-startup-shell.test.ts
- src/renderer/src/lib/ai-vault-resume-shell.ts
- src/shared/tui-agent-startup-shell.ts
| 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 |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
rg -n -C 8 'getExecutionHostIdForWorktree|return .*executionHostId|return .*hostId|runtime:unresolved-owner' src/renderer/src/lib/worktree-runtime-owner.ts
rg -n -C 8 'resolveAgentResumeLaunchTarget|isRemote|connectionId|parseExecutionHostId' src/renderer/src/lib/agent-resume-launch-target.tsRepository: stablyai/orca
Length of output: 5041
🏁 Script executed:
#!/bin/bash
sed -n '1,230p' src/renderer/src/lib/worktree-runtime-owner.ts
rg -n -C 6 'function parseExecutionHostId|export function parseExecutionHostId|type ExecutionHostId|ExecutionHostId =' src/shared src/renderer/src
rg -n -C 5 'getExecutionHostIdForWorktree\(' src/renderer/src src/mainRepository: stablyai/orca
Length of output: 50370
🏁 Script executed:
#!/bin/bash
sed -n '1,180p' src/shared/execution-host.ts
rg -n -C 8 'getExecutionHostIdForWorktree|runsOnClientLoginShell|runtime:unresolved-owner|malformed|unknown host|parseExecutionHostId' src/renderer/src/lib src/shared --glob '*.test.ts' --glob '*.test.tsx'Repository: stablyai/orca
Length of output: 50370
🏁 Script executed:
#!/bin/bash
python3 - <<'PY'
from urllib.parse import unquote
def parse_execution_host_id(value):
normalized = value.strip() if isinstance(value, str) else None
if not normalized:
return None
if normalized == "local":
return {"kind": "local"}
for prefix, kind in (("ssh:", "ssh"), ("runtime:", "runtime")):
if normalized.startswith(prefix):
encoded = normalized[len(prefix):]
if not encoded:
return None
try:
decoded = unquote(encoded)
except Exception:
return None
return {"kind": kind} if decoded else None
return None
def current_decision(host_id, platform):
execution_host = parse_execution_host_id(host_id)
return execution_host is None or platform == "client"
def strict_decision(host_id, platform):
return parse_execution_host_id(host_id) == {"kind": "local"}
for host_id in ("local", "ssh:host", "runtime:env", "runtime:", "unknown-host", "ssh:%E0%A4%A"):
print(host_id, parse_execution_host_id(host_id), current_decision(host_id, "client"), strict_decision(host_id, "client"))
PYRepository: stablyai/orca
Length of output: 358
🏁 Script executed:
#!/bin/bash
python3 - <<'PY'
from urllib.parse import unquote
def parse_execution_host_id(value):
normalized = value.strip() if isinstance(value, str) else None
if not normalized:
return None
if normalized == "local":
return {"kind": "local"}
for prefix, kind in (("ssh:", "ssh"), ("runtime:", "runtime")):
if normalized.startswith(prefix):
encoded = normalized[len(prefix):]
if not encoded:
return None
try:
decoded = unquote(encoded, errors="strict")
except Exception:
return None
return {"kind": kind} if decoded else None
return None
def current_decision(host_id, platform):
execution_host = parse_execution_host_id(host_id)
if execution_host and execution_host["kind"] in ("ssh", "runtime"):
return False
return platform == "client"
def strict_decision(host_id, platform):
return parse_execution_host_id(host_id) == {"kind": "local"}
for host_id in ("local", "ssh:host", "runtime:env", "runtime:", "unknown-host", "ssh:%E0%A4%A"):
print(host_id, parse_execution_host_id(host_id), current_decision(host_id, "client"), strict_decision(host_id, "client"))
PYRepository: stablyai/orca
Length of output: 349
Treat only an explicit local host as client-local.
Unknown or malformed host IDs parse as null, then fall through to the client-platform check. Return true only when executionHost?.kind === 'local', and add tests for unknown and malformed IDs.
d279bd2 to
8fd462e
Compare
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>
8fd462e to
8ce34e3
Compare
Two fish ergonomics gaps.
Worktree-scoped history did nothing for fish: `historyFilename` returned null,
so every fish worktree shared one global history. fish ignores HISTFILE
entirely — history lives at `$XDG_DATA_HOME/fish/${fish_history}_history`, so
the only isolation knob is the session NAME. fish imports env vars as global
variables at startup, so exporting `fish_history=orca_<hash>` in the spawn env
picks the session with no `--init-command`. Consequence: the file sits in the
user's fish data dir rather than Orca's history root, so the session name is
recorded in that worktree's meta.json and the tombstone path (explicit delete
and GC alike) removes the out-of-tree file first.
The fish draft-prefill teardown was unreachable: `clearEnvCommand`'s fish
branch existed, but no POSIX caller ever threaded a fish shell in, so fish
users still got `pi; unset ORCA_PI_PREFILL` and "Unknown command: unset". The
client's login-shell dialect now flows through on POSIX, behind the guard
#13904 established — the local shell may pick the dialect only when it is
provably the one parsing the line, since `set -e` in remote bash enables
errexit instead of clearing anything. Main-process launch paths use this
process's own `$SHELL`, because the renderer helper does not exist there and
on a remote host it is that host's shell that runs the command.
Two fish ergonomics gaps.
Worktree-scoped history did nothing for fish: `historyFilename` returned null,
so every fish worktree shared one global history. fish ignores HISTFILE
entirely — history lives at `$XDG_DATA_HOME/fish/${fish_history}_history`, so
the only isolation knob is the session NAME. fish imports env vars as global
variables at startup, so exporting `fish_history=orca_<hash>` in the spawn env
picks the session with no `--init-command`. Consequence: the file sits in the
user's fish data dir rather than Orca's history root, so the session name is
recorded in that worktree's meta.json and the tombstone path (explicit delete
and GC alike) removes the out-of-tree file first.
The fish draft-prefill teardown was unreachable: `clearEnvCommand`'s fish
branch existed, but no POSIX caller ever threaded a fish shell in, so fish
users still got `pi; unset ORCA_PI_PREFILL` and "Unknown command: unset". The
client's login-shell dialect now flows through on POSIX, behind the guard
#13904 established — the local shell may pick the dialect only when it is
provably the one parsing the line, since `set -e` in remote bash enables
errexit instead of clearing anything. Main-process launch paths use this
process's own `$SHELL`, because the renderer helper does not exist there and
on a remote host it is that host's shell that runs the command.
Upstream stablyai#13904 (between fork base and PR stablyai#13998 base) introduced this self-contained test helper; stablyai#13998's node-pty tests import it. Pull the two files directly so the cherry-picked PR compiles without dragging in stablyai#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 stablyai#9993 Co-authored-by: Orca <help@stably.ai>
Summary
^[[?997;1nno longer leaks into fish. Interactive prompts that read a keystroke right after a fish prompt (brew,npx,git add -p, any[y/N]) stop swallowing escape bytes.Closes #9993. Supersedes #12580 and #10054 — see Why not the proposed fixes.
Root cause
fish arms
CSI ?2031himmediately before painting each prompt and withdraws it withCSI ?2031lwhen it hands the tty to a child. Measured on real fish 4.7.1, that window is ~1 ms. Orca answered the subscribe withCSI ?997;Nnacross a renderer IPC hop of 1–3 ms, so the reply arrived after the withdrawal and was read as stdin by whatever fish had just handed the tty to.Two measurements decide the fix:
staleReplies: 0). Any "drop the stale reply" design can only drop what it already knows is stale, so it under-drops in exactly the failure case.CSI ?996n.You cannot win a race you enter 1–3 ms late. So don't enter it.
Verified against real implementations, not just the spec
CSI ?2031hforce=trueis reachable only from the?996nDSR (stream_handler.zig:869); every other emit is gated on the mode (Termio.zig:729)case 2031: sendUnsolicitedDarkModeDSR = mode(VT100Terminal.m:995)colorSchemeUpdates(InputHandler.ts:2035)17 real programs measured under a pty. Only four subscribe: fish, tmux, claude, opencode. None blocks on a reply. A paired 5×2 fish benchmark gave deterministic byte counts per arm (1911 vs 1935 — the delta is exactly two redundant
OSC 11 + DA1re-queries) with fully overlapping startup latency; the no-answer arm was faster in 2 of 5 runs. Rendering diffs across all four subscribers show one redundant palette re-query and zero rendering change. tmux is the only program that sends?996n, which Orca still answers. The retained flip push still reaches all four.fish genuinely decodes the report — it reacts to a real
?997;2nbut ignores garbage and CPR — and uses it purely as a "re-query OSC 11" trigger, which it already does unprompted at every prompt paint.What changed
pty-connection.ts,parked-terminal-byte-watcher.ts, andparked-terminal-mode2031-responder.ts(deleted — it existed only to reply).CSI ?996nDSR answer, the theme-flip push, and thepaneMode2031/paneLastThemeModeregistries.paneLastThemeModeis still seeded at subscribe time, or the next unrelated appearance re-apply would read "no last mode" as a flip and push a spurious 997.?2031lalongside?2031h(terminal-reply-query-scan.ts). Snapshots drop control bytes, so the arm must still be replayed for a late-attaching remote client to build its registry — but an arm with no withdraw left that client subscribed for a TUI that had already retired the subscription, reproducing [Bug]:?997;1ncolor-scheme replies leak into fish and corrupt stdin of interactive prompts (repro on v1.4.152) #9993 on the remote path. RenamedisReplyElicitingCsi→isReplayableCsi.Fish-integration gaps closed alongside
clearEnvCommandemittedunset VAR, which fish lacksCODEX_HOMEat Orca's routed value, socodex resume <id>reported the session missing — a silent wrong resultshell-startup-env.tshandled only zsh/bashCODEX_HOME/PI_CODING_AGENT_DIR/OMP_CODING_AGENT_DIRbracketedPasteSafediffered across local / daemon / relay for one fish userThe dialect is chosen only where the client's login shell actually parses the command. A locally-scanned Codex session can target a remote worktree, and emitting fish
set -einto remote bash would not clear the variable — it would enable errexit.Why not the proposed fixes
#12580 (drop stale replies at the write boundary) — refuted by measurement 1: the reply is usually not stale by main's view at write time, so the gate under-drops precisely when it matters. Its predicate also exact-matches a 2-element set, so a coalesced doubled
?997;1n— the literal payload in the issue title — passes through. And #13309 moved the real write boundary below it intoPtyStartupReplyDelivery. The byte-level trace in that PR is accurate and was genuinely useful; only the conclusion drawn from it doesn't hold.#10054 reached the right answer but justified it with "fish probes with
?2031h+?996n", which is false — fish never sends?996n(0 occurrences across a full session).Deliberately out of scope
maybePushMode2031Flipthe sole owner of the flip push and suppressed xterm's own report. Adversarial review found it regressed real cases twice: a flip-triggered push structurally cannot cover what a write-triggered push covered (a palette change within one darkness class notified nothing; a reattached pane was left with no emitter at all). It was never needed for [Bug]:?997;1ncolor-scheme replies leak into fish and corrupt stdin of interactive prompts (repro on v1.4.152) #9993 — it only addressed a cosmetic doubling on theme flip. Reverted. Filing separately.Screenshots
No visual change.
Testing
pnpm lint—oxlint0 errors; code-quality native and type-aware clean; max-lines ratchet and reliability gates pass, no new suppressionspnpm typecheck— node / web / cli cleanpnpm test— 2673 files / 32405 tests passing acrosssrc/pnpm build— not run. No build-surface change: renderer + main TypeScript and generated shell text only, all covered by typecheck.The merge-blocking proof is
fish-color-scheme-child-stdin.node-pty.test.ts: real fish 4.7.1 under node-pty, driving the realpty-connectionmodule, asserting on what the child process read. A screen-level assertion would pass whilenpx/brewstayed broken.Verified as a real guard rather than assumed: re-injecting the reply makes it fail with the child reading the CPR reply, the OSC 11 report, then
?997;1nimmediately ahead ofhello— the exact shape from the issue. Restoring makes it pass.CI (
shell_contracts, required) installs fish 4 viappa:fish-shell/release-4with retry and gates on the version — 2031 arming lives in fish 4's Rusttty_handoff, so on Noble's fish 3.7 this test would fail rather than skip.ORCA_REQUIRE_FISH=1turns a missing-fish skip into a failure in CI while still skipping cleanly on a dev machine without fish.tests/tools/fish-mode2031-repro/holds the standalone trace harness.AI Review Report
Multiple adversarial review rounds with a fix pass between each. Findings fixed and re-verified:
?997;1ncolor-scheme replies leak into fish and corrupt stdin of interactive prompts (repro on v1.4.152) #9993 still reproduced on the remote path via the arm-without-withdraw replay grammar. Fixed.set -ecould reach a remote POSIX shell where it enables errexit. Fixed; the pre-existing test pinnedisLocalSession: false, which short-circuits before the fish branch, so it was false coverage. Replaced with the reachable case plus an end-to-end assertion.oxlinterrors in the harness; a duplicatevitestimport). Fixed.Final round returned zero blocker/major findings. Three minors were addressed: stale comments describing the removed reply, a retry around the fish PPA install so a Launchpad blip cannot red an unrelated PR, and a note that
buildAgentDraftLaunchPlan's fish branch is contract-only for now (selectedRepoStartupShellis Windows-only, so fish users still getunseton that path — follow-up).Cross-platform checked: byte-level and platform-neutral. Windows/ConPTY query replies untouched. SSH, relay, folder workspaces, and remote clients cross the same emitters.
Remote wire (
docs/reference/remote-wire-compatibility.md): no RPC param, stream opcode, or fact-kind change — only what a client publishes into the PTY. Old host + new client: the client stops writing a byte the host merely forwarded. New host + old client: 2031 query authority was always renderer-side, so an old client keeps replying, and the host's echo-safe coalesced-997 handling is retained for exactly that case. No capability negotiation needed.Security Audit
No new command execution, filesystem access, network surface, or IPC endpoint. The change removes an outbound byte sequence and classifies sequences already flowing through existing paths; it cannot inject or transform input. The fish
set -echange replaces a command that was a no-op error under fish with the correct spelling, narrowing rather than widening what executes.config.fishparsing is read-only, bounded, and does not execute the file.Notes
pr.ymlwill conflict mechanically with fix(pty): give fish login shells the Prime Agent status wrapper #13875, which edits the same hunks. The fish-4 PPA is load-bearing for this PR's test; fix(pty): give fish login shells the Prime Agent status wrapper #13875's own fish tests do not need 2031 and run fine on 3.7.mobile-create-pr-action.test.ts) is unrelated and reproduces at the branch base — verified by stashing.