Merge remote-tracking branch 'origin/main' into brennanb2025/c5-read-child-records

This commit is contained in:
Brennan Benson
2026-09-30 01:36:43 -07:00
180 changed files with 4225 additions and 2667 deletions
@@ -9,9 +9,9 @@
//
// Those numbers are the `!useConptyDll` branch, which is the branch a RELAY runs. Every desktop
// site that opens a terminal pane sets `useConptyDll: true` and takes the other branch, where
// upstream already destroys the input socket. Two hidden rate-limit probes
// (`src/main/rate-limits/claude-pty.ts`, `codex-pty-rate-limit-probe.ts`) do omit the option and so
// do run this hunk, but no user-visible pane does. The divergence pinned below is about which
// upstream already destroys the input socket. The hidden rate-limit probe
// (`src/main/rate-limits/claude-pty.ts`) does omit the option and so does run this hunk, but no
// user-visible pane does. The divergence pinned below is about which
// branch each host runs for terminals -- not about a regression in the panes users open.
import { createRequire } from 'node:module'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+1
View File
@@ -107,6 +107,7 @@
"../src/main/claude/hook-settings.ts",
"../src/main/claude/hook-service.ts",
"../src/main/claude/statusline-script.ts",
"../src/main/claude/windows-hook-files.ts",
"../src/main/claude-accounts/keychain.ts",
"../src/main/macos-keychain/generic-password.ts",
"../src/main/codex/codex-app-server-capability-cache.ts",
+16 -16
View File
@@ -274,7 +274,7 @@ _shorten the interpreter chain_ rather than to hide a window.
#18875 is a worked example of that doctrine. The Claude Code lifecycle hook was
registered as `powershell.exe -NoProfile -EncodedCommand <...>` whose entire
decoded payload was a `Test-Path` and a call to `~/.orca/agent-hooks/claude-hook.cmd`.
It now registers the script path itself (`<path> || echo {}`), so `bash ->
It now registers the bare script path itself, with no shell operators, so `bash ->
powershell -> cmd -> curl` became `bash -> cmd -> curl` and one
`powershell.exe -EncodedCommand` per hook event — a first-class Defender alert
title — leaves the tree. The reporting box fired ~6 900 of them in five days,
@@ -287,24 +287,24 @@ concurrency, invoked as Claude Code invokes it. **No EDR verdict on either tree
was measured**, so claim the removed `-EncodedCommand` spelling and the shorter
chain, not a score. `cmd.exe` remains in the tree, spelled by MSYS's own `.cmd`
spawn rather than by us — the doc's one "unavoidable for `.cmd`/`.bat`" case,
carrying an absolute path and two literal tokens, with no caret escaping, no
carrying only an absolute path, with no caret escaping, no
encoding and no free text. The encoded launcher is still the shape for profile
paths the shells cannot carry bare (a space, `%`, `^`, `&`, non-ASCII, a UNC
profile) and for hosts where Git Bash is not resolvable, because PowerShell 5.1
rejects `||` (measured: parse error, exit 1).
profile).
That last clause is the standing assumption of this change, and it is worth
stating plainly because it is **not** measured. `||` parses in Git Bash, cmd.exe
and pwsh, but not in Windows PowerShell 5.1, so the direct shape is correct for
any host that is one of the first three. Claude Code itself is a Git Bash host on
native Windows. What no one here has verified is which host a _compat consumer_
uses: cursor-agent and Devin import `~/.claude/settings.json` and run `command`
through their own launcher (the managed `.cmd` carries a `DEVIN_PROJECT_DIR` skip
for exactly that). If one of them spawns hook strings through Windows PowerShell
5.1, its imported Claude events become a parse error with empty stdout, which is
the fail-closed case #14818 exists to prevent. The encoded launcher had no such
assumption — it was a `powershell.exe` invocation and therefore parsed anywhere.
Before widening the direct shape to another agent, measure that consumer's host.
The direct shape first shipped as `<path> || echo {}`, gated on Git Bash being
resolvable. That gate guessed at a choice Claude Code makes on its own: it runs
hooks under Windows PowerShell 5.1 when it picks PowerShell, and 5.1 rejects `||`
(parse error, exit 1), so every hook failed (STA-8913). The registered command is
now the bare path, which parses in Git Bash, cmd.exe, pwsh and PowerShell 5.1.
The neutral `{}` for a missing payload lives in the `claude-hook.cmd` entry, which
hands off to `claude-hook-impl.cmd` in the same `cmd.exe`; a deleted entry is an
ordinary non-blocking hook error (never exit 2) until the next install rewrites it.
Compat consumers such as cursor-agent and Devin import `~/.claude/settings.json`
and run `command` through their own launcher (the payload carries a
`DEVIN_PROJECT_DIR` skip for that); one that cannot start a `.cmd` at all still
gets a launch failure. Before widening the direct shape to another agent, measure
that consumer's host.
### Computer use: screen capture, synthetic input, runtime-compiled MSIL
@@ -0,0 +1,37 @@
import { expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { retireLandedMobileNativeChatPending } from './mobile-native-chat-pending-retirement'
const boundary: NativeChatMessage = {
id: 'boundary',
role: 'assistant',
blocks: [{ type: 'text', text: 'before' }],
timestamp: null,
source: 'transcript'
}
// An old host still sends Claude's raw paste envelope; the shared normalizer must match it.
it('retires an echo against an old-host wrapped row and keeps the second identical send', () => {
const text = 'one\ntwo'
const echo: NativeChatMessage = {
...boundary,
id: 'user',
role: 'user',
blocks: [{ type: 'text', text: `<pasted_content id="a">\n${text}\n</pasted_content id="a">` }]
}
const reply: NativeChatMessage = { ...boundary, id: 'reply' }
const entry = {
id: 'one',
text,
expectedOccurrence: 1,
baselineTailMessageId: 'boundary',
baselineResolved: true
}
expect(
retireLandedMobileNativeChatPending(
[boundary, echo, reply],
[entry, { ...entry, id: 'two', expectedOccurrence: 2 }],
new Set()
).map((item) => item.id)
).toEqual(['two'])
})
@@ -78,6 +78,8 @@ export type TerminalDocumentState = {
lastEmittedModes: TerminalDocumentModes
/** `terminal-init`: whether the terminal has ever reached ready. */
everReady: boolean
/** `mouse-mode-decset-scan`: whether the encoding is proven (1006/1016 set or reset, RIS, or a live tracking enable). */
mouseEncodingKnown: boolean
/** `mouse-mode-decset-scan`: the tail of the last chunk, in case a DECSET straddles two writes. */
mouseModeScanTail: string
/** `mouse-mode-decset-scan`: the mouse tracking mode the TUI last asked for. */
@@ -262,6 +264,7 @@ function createTerminalDocumentState(): TerminalDocumentState {
sgrMousePixelsMode: false
},
everReady: false,
mouseEncodingKnown: false,
mouseModeScanTail: '',
trackedMouseTrackingMode: 'none',
sgrMouseMode: false,
@@ -94,6 +94,7 @@ export function handleMsg(scope: TerminalDocumentScope, msg: TerminalHostMessage
scope.statusDotPendingSelector = false
scope.afterDrainCallbacks = []
scope.writesDraining = false
scope.mouseEncodingKnown = false
scope.mouseModeScanTail = ''
scope.trackedMouseTrackingMode = 'none'
scope.sgrMouseMode = false
@@ -24,7 +24,7 @@ export type TerminalMouseGesture = {
// One report per transition, built with the same encoding ladder as
// buildMouseClickInput: SGR pixels (1016) > SGR (1006) > default. Returns ''
// when the mode does not report this transition (x10 has no release, only
// drag/any report motion) or the cell is not encodable.
// drag/any report motion), the encoding is unproven, or the cell is not encodable.
export function buildMouseButtonReport(
scope: TerminalDocumentScope,
kind: string,
@@ -32,7 +32,7 @@ export function buildMouseButtonReport(
clientY: number
) {
const mouseTrackingMode = getMouseTrackingMode(scope)
if (mouseTrackingMode === 'none') {
if (mouseTrackingMode === 'none' || !scope.mouseEncodingKnown) {
return ''
}
if (kind === 'motion' && mouseTrackingMode !== 'drag' && mouseTrackingMode !== 'any') {
@@ -103,7 +103,8 @@ export function abandonMouseGesture(scope: TerminalDocumentScope) {
export function beginMouseDrag(scope: TerminalDocumentScope, gesture: TerminalMouseGesture) {
gesture.moved = true
if (getMouseTrackingMode(scope) !== 'none') {
// Why: with no proven encoding the program would get no reports, so select locally instead.
if (getMouseTrackingMode(scope) !== 'none' && scope.mouseEncodingKnown) {
gesture.mode = 'tracking'
gesture.lastCellKey = mouseReportCellKey(scope, gesture.startX, gesture.startY)
const press = buildMouseButtonReport(scope, 'press', gesture.startX, gesture.startY)
@@ -61,6 +61,9 @@ export function buildMouseWheelSequence(
clientX: number,
clientY: number
) {
if (!scope.mouseEncodingKnown) {
return ''
}
const cell = viewportToMouseReportCell(scope, clientX, clientY)
if (!cell) {
return ''
@@ -105,7 +108,7 @@ export function buildMouseClickInput(
clientY: number
) {
const mouseTrackingMode = getMouseTrackingMode(scope)
if (!isClickMouseTrackingMode(mouseTrackingMode)) {
if (!isClickMouseTrackingMode(mouseTrackingMode) || !scope.mouseEncodingKnown) {
return ''
}
const cell = viewportToMouseReportCell(scope, clientX, clientY)
@@ -168,8 +171,18 @@ export function isWheelMouseTrackingMode(mode: string) {
return mode !== 'none' && mode !== 'x10'
}
// Why: a replay can carry tracking without its encoding (?1006h/?1016h); a guessed
// legacy report would type `ESC[M` bytes into the program, so scroll locally instead.
export function isWheelEncodingUnproven(scope: TerminalDocumentScope, mode: string) {
return isWheelMouseTrackingMode(mode) && !scope.mouseEncodingKnown
}
export function shouldRouteScrollToTerminalInput(scope: TerminalDocumentScope) {
return isWheelMouseTrackingMode(getMouseTrackingMode(scope)) || isAlternateBufferActive(scope)
const mode = getMouseTrackingMode(scope)
if (isWheelEncodingUnproven(scope, mode)) {
return false
}
return isWheelMouseTrackingMode(mode) || isAlternateBufferActive(scope)
}
export function buildMouseWheelScrollInput(
@@ -221,6 +234,10 @@ export function routeScrollLines(
}
const mouseTrackingMode = getMouseTrackingMode(scope)
const alternateBufferActive = isAlternateBufferActive(scope)
if (isWheelEncodingUnproven(scope, mouseTrackingMode)) {
scope.term.scrollLines(lines)
return
}
if (isWheelMouseTrackingMode(mouseTrackingMode)) {
// Why: xterm sends wheel events to mouse-aware TUIs before considering
// scrollback, even if the app stays on the normal buffer.
@@ -22,7 +22,8 @@ export function normalizeInitialData(data: unknown) {
return on > 0 ? data.slice(on) : data
}
export function updateMouseModeFromData(scope: TerminalDocumentScope, data: unknown) {
// `live`: bytes the program just wrote, as opposed to a snapshot replay.
export function updateMouseModeFromData(scope: TerminalDocumentScope, data: unknown, live = false) {
if (typeof data !== 'string' || data.length === 0) {
return
}
@@ -35,6 +36,7 @@ export function updateMouseModeFromData(scope: TerminalDocumentScope, data: unkn
let match: RegExpExecArray | null
while ((match = re.exec(input)) !== null) {
if (match[0] === ESC + 'c') {
scope.mouseEncodingKnown = true
scope.trackedMouseTrackingMode = 'none'
scope.sgrMouseMode = false
scope.sgrMousePixelsMode = false
@@ -50,6 +52,11 @@ export function updateMouseModeFromData(scope: TerminalDocumentScope, data: unkn
if (!Number.isInteger(param)) {
continue
}
// Why: a program enabling tracking live states its encoding in that same burst, or
// means the default; only a replay can carry tracking without its encoding.
if (live && enabled && (param === 9 || param === 1000 || param === 1002 || param === 1003)) {
scope.mouseEncodingKnown = true
}
if (param === 9) {
scope.trackedMouseTrackingMode = enabled ? 'x10' : 'none'
}
@@ -63,10 +70,12 @@ export function updateMouseModeFromData(scope: TerminalDocumentScope, data: unkn
scope.trackedMouseTrackingMode = enabled ? 'any' : 'none'
}
if (param === 1006) {
scope.mouseEncodingKnown = true
scope.sgrMouseMode = enabled
scope.sgrMousePixelsMode = false
}
if (param === 1016) {
scope.mouseEncodingKnown = true
scope.sgrMouseMode = false
scope.sgrMousePixelsMode = enabled
}
+6 -1
View File
@@ -59,7 +59,12 @@ export function notifyTerminalSurfaceTap(
notify(scope, { type: 'terminal-input', bytes: clickInput })
}
// Touch still needs native input focus after the TUI consumes its mouse click.
if (focusKeyboard || !isClickMouseTrackingMode(getMouseTrackingMode(scope))) {
// A click with an unproven encoding reached no program, so it acts as a plain tap.
if (
focusKeyboard ||
!isClickMouseTrackingMode(getMouseTrackingMode(scope)) ||
!scope.mouseEncodingKnown
) {
notify(scope, { type: 'terminal-tap' })
}
}
@@ -122,6 +122,7 @@ export function init(
scope.wheelAccumDeltaY = 0
scope.mouseModeScanTail = ''
scope.trackedMouseTrackingMode = 'none'
scope.mouseEncodingKnown = false
scope.sgrMouseMode = false
scope.sgrMousePixelsMode = false
scope.lastEmittedModes = {
@@ -208,7 +209,7 @@ export function init(
}
export function write(scope: TerminalDocumentScope, data: string) {
updateMouseModeFromData(scope, data)
updateMouseModeFromData(scope, data, true)
enqueueWrite(scope, data)
pumpWrites(scope, scope.terminalGeneration)
// Why: first live data chunk after init may widen the buffer past
@@ -0,0 +1,144 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import { ESC, useTerminalMouseWebViewHarness } from './terminal-webview-mouse-test-harness'
type DocumentMessage = {
type: string
data?: string
cols?: number
rows?: number
initialData?: string
}
function message(data: DocumentMessage) {
window.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(data) }))
}
function wheel(deltaY = 120) {
document.getElementById('terminal-surface')!.dispatchEvent(
new WheelEvent('wheel', {
bubbles: true,
cancelable: true,
deltaY,
clientX: 40,
clientY: 60
})
)
}
function swipe() {
const surface = document.getElementById('terminal-surface')!
for (const [type, y] of [
['touchstart', 240],
['touchmove', 120],
['touchend', 120]
] as const) {
const event = new Event(type, { bubbles: true, cancelable: true })
const touch = { identifier: 1, clientX: 40, clientY: y }
Object.defineProperty(event, 'touches', { value: type === 'touchend' ? [] : [touch] })
Object.defineProperty(event, 'changedTouches', { value: [touch] })
surface.dispatchEvent(event)
}
}
function touchTap() {
const surface = document.getElementById('terminal-surface')!
for (const type of ['touchstart', 'touchend']) {
const event = new Event(type, { bubbles: true, cancelable: true })
const touches = type === 'touchend' ? [] : [{ identifier: 0, clientX: 40, clientY: 60 }]
Object.defineProperty(event, 'touches', {
value: touches.map((touch) => ({ ...touch, target: surface }))
})
Object.defineProperty(event, 'target', { value: surface })
document.dispatchEvent(event)
}
}
// What a desktop pane snapshot looks like: normal-buffer scrollback, the alternate screen, the
// tracking modes the serializer writes, then the encoding the pane appends at the very end.
const PANE_PREFIX = `scrollback\r\n${ESC}[?1049h${ESC}[H codex prompt${ESC}[?1003h`
describe('mobile mouse encoding proof', () => {
const mouse = useTerminalMouseWebViewHarness()
function bootAltScreen(initialData: string, mode = 'any') {
mouse.boot(initialData)
mouse.showAlternateBuffer()
mouse.activeTerminal().modes.mouseTrackingMode = mode
mouse.clearPostedMessages()
}
it('reports a swipe and a wheel as SGR from a pane snapshot that ends with ?1006h', () => {
bootAltScreen(`${PANE_PREFIX}${ESC}[?1006h`)
swipe()
wheel(-120)
const bytes = mouse.terminalInputBytes()
expect(bytes).toContain(`${ESC}[<64;`)
wheel()
expect(mouse.terminalInputBytes()).toContain(`${ESC}[<65;`)
expect(mouse.terminalInputBytes()).not.toContain(`${ESC}[M`)
})
it('loses an encoding that sits before the alternate screen the phone replays from', () => {
bootAltScreen(`${ESC}[?1006h${PANE_PREFIX}`)
wheel()
expect(mouse.terminalInputBytes()).toBe('')
})
it('sends nothing for a wheel, a swipe or a tap while tracking has no proven encoding', () => {
bootAltScreen(PANE_PREFIX)
wheel()
swipe()
touchTap()
expect(mouse.terminalInputBytes()).toBe('')
expect(mouse.postedMessages().filter((posted) => posted.type === 'terminal-tap')).toHaveLength(
1
)
})
it.each([
['SGR', '?1006h', '[<65;'],
['pixel', '?1016h', '[<65;'],
['explicit legacy', '?1006l', '[Ma']
])('keeps %s encoding proven by a live write', (_name, mode, prefix) => {
mouse.boot()
mouse.activeTerminal().modes.mouseTrackingMode = 'any'
message({ type: 'write', data: `${ESC}[${mode}${ESC}[?1003h` })
wheel()
expect(mouse.terminalInputBytes()).toContain(`${ESC}${prefix}`)
})
it('reports legacy wheel input from a pane snapshot that states the default encoding', () => {
bootAltScreen(`${PANE_PREFIX}${ESC}[?1006l`)
wheel()
expect(mouse.terminalInputBytes()).toContain(`${ESC}[Ma`)
})
it('reports legacy wheel input for a program that enables tracking live without an encoding', () => {
mouse.boot()
message({ type: 'write', data: `${ESC}[?1049h${ESC}[?1000h` })
mouse.showAlternateBuffer()
mouse.activeTerminal().modes.mouseTrackingMode = 'vt200'
wheel()
expect(mouse.terminalInputBytes()).toContain(`${ESC}[Ma`)
})
it('forgets encoding proof on the next snapshot', () => {
mouse.boot()
message({ type: 'write', data: `${ESC}[?1006l` })
message({ type: 'init', cols: 40, rows: 24, initialData: `${ESC}[?1003h` })
mouse.activeTerminal().modes.mouseTrackingMode = 'any'
wheel()
expect(mouse.terminalInputBytes()).toBe('')
})
it('keeps arrow-key scrolling for a click-only (x10) program on the alternate screen', () => {
bootAltScreen(`${ESC}[?1049h${ESC}[?9h`, 'x10')
wheel(-120)
swipe()
const bytes = mouse.terminalInputBytes()
expect(bytes).toContain(`${ESC}[B`)
expect(bytes).toContain(`${ESC}[A`)
expect(bytes).not.toContain(`${ESC}[M`)
})
})
@@ -7,6 +7,7 @@ describe('terminal WebView external mouse click', () => {
it('reports a mouse click to a click-tracking TUI the way a touch tap does (#8818)', () => {
mouse.boot()
mouse.writeLegacyMouseEncoding()
mouse.activeTerminal().modes.mouseTrackingMode = 'vt200'
mouse.mouseClick(40, 60)
@@ -21,6 +22,18 @@ describe('terminal WebView external mouse click', () => {
expect(mouse.postedMessages().filter((message) => message.type === 'terminal-tap')).toEqual([])
})
it('sends no click report and focuses the keyboard while the encoding is unproven', () => {
mouse.boot()
mouse.activeTerminal().modes.mouseTrackingMode = 'vt200'
mouse.mouseClick(40, 60)
expect(mouse.terminalInputBytes()).toBe('')
expect(
mouse.postedMessages().filter((message) => message.type === 'terminal-tap')
).toHaveLength(1)
})
it('dismisses an existing selection with a click without focusing the keyboard', () => {
mouse.boot()
mouse.mouseDrag(40, 60, 160, 90)
@@ -11,6 +11,7 @@ describe('terminal WebView external mouse drag', () => {
it('sends press, per-cell motion, and release for a drag-tracking mouse drag', () => {
mouse.boot()
mouse.writeLegacyMouseEncoding()
mouse.activeTerminal().modes.mouseTrackingMode = 'drag'
mouse.mouseDrag(40, 60, 160, 60)
@@ -29,6 +30,7 @@ describe('terminal WebView external mouse drag', () => {
it('does not report motion or release for an x10 click-only TUI drag', () => {
mouse.boot()
mouse.writeLegacyMouseEncoding()
mouse.activeTerminal().modes.mouseTrackingMode = 'x10'
mouse.mouseDrag(40, 60, 160, 60)
@@ -53,8 +55,20 @@ describe('terminal WebView external mouse drag', () => {
expect(modes).toEqual([{ type: 'set-select-mode', enabled: true }])
})
it('selects text instead of reporting a drag while the encoding is unproven', () => {
mouse.boot(`${ESC}[?1002h`)
mouse.activeTerminal().modes.mouseTrackingMode = 'drag'
mouse.clearPostedMessages()
mouse.mouseDrag(40, 60, 160, 90)
expect(mouse.terminalInputBytes()).toBe('')
expect(mouse.selectionSpy()).toHaveBeenCalled()
})
it('releases a tracked drag when the button state shows the pointerup was lost', () => {
mouse.boot()
mouse.writeLegacyMouseEncoding()
mouse.activeTerminal().modes.mouseTrackingMode = 'drag'
mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 })
@@ -74,6 +88,7 @@ describe('terminal WebView external mouse drag', () => {
it('releases a tracked drag when the pointer is cancelled mid-gesture', () => {
mouse.boot()
mouse.writeLegacyMouseEncoding()
mouse.activeTerminal().modes.mouseTrackingMode = 'drag'
mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 })
@@ -150,12 +150,12 @@ export function useTerminalMouseWebViewHarness() {
let select: Select
let terminals: TerminalStub[]
function boot(): void {
function boot(initialData = ''): void {
document.body.innerHTML = bodyMarkup()
runInThisContext(TERMINAL_DOCUMENT_SCRIPT)
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({ type: 'init', cols: 40, rows: 24, initialData: '' })
data: JSON.stringify({ type: 'init', cols: 40, rows: 24, initialData })
})
)
// Why: init commits the replacement surface on the next animation frame.
@@ -164,6 +164,15 @@ export function useTerminalMouseWebViewHarness() {
}
}
// Why: the document sends no mouse report until replayed bytes prove the encoding.
function writeLegacyMouseEncoding(): void {
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({ type: 'write', data: `${ESC}[?1006l` })
})
)
}
function activeTerminal(): TerminalStub {
const terminal = terminals.at(-1)
if (!terminal) {
@@ -231,6 +240,9 @@ export function useTerminalMouseWebViewHarness() {
return {
activeTerminal,
boot,
showAlternateBuffer: () => {
buffer.type = 'alternate'
},
clearPostedMessages: () => postMessage.mockClear(),
dispatchPointer,
mouseClick,
@@ -238,6 +250,7 @@ export function useTerminalMouseWebViewHarness() {
postedMessages: () => postedMessages(postMessage),
selectionSpy: () => select,
terminalInputBytes: () => terminalInputBytes(postMessage),
terminalSurface
terminalSurface,
writeLegacyMouseEncoding
}
}
@@ -148,6 +148,11 @@ describe('terminal WebView tap routing', () => {
it('focuses native input after reporting a touch tap to a mouse-tracking TUI', async () => {
const { posted } = boot('interactive prompt', undefined, 'drag')
await settle()
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({ type: 'write', data: '\u001b[?1006l' })
})
)
fireTouch('touchstart', [{ x: 20, y: tapY }])
fireTouch('touchend', [])
@@ -159,6 +164,17 @@ describe('terminal WebView tap routing', () => {
).toEqual(['terminal-input', 'terminal-tap'])
})
it('focuses native input without mouse bytes while the tracking encoding is unproven', async () => {
const { posted } = boot('interactive prompt', undefined, 'drag')
await settle()
fireTouch('touchstart', [{ x: 20, y: tapY }])
fireTouch('touchend', [])
expect(posted.find((message) => message.type === 'terminal-input')).toBeUndefined()
expect(posted.filter((message) => message.type === 'terminal-tap')).toHaveLength(1)
})
it('reports a non-mouse touch tap without terminal mouse bytes', async () => {
const { posted } = boot('plain prompt')
await settle()
@@ -218,6 +218,11 @@ describe('terminal WebView external pointer wheel scrolling', () => {
throw new Error('terminal missing')
}
terminal.modes.mouseTrackingMode = 'any'
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({ type: 'write', data: `${ESC}[?1006l` })
})
)
dispatchWheel(CELL_HEIGHT)
@@ -67,11 +67,7 @@ const buildersByAgent = new Map<string, CommandBuilders>([
[
'claude',
{
local: (path) =>
[true, false].map(
(gitBashAvailable) =>
getManagedLifecycleHook(path, CLAUDE_HOOK_SETTINGS, { gitBashAvailable }).command
),
local: (path) => [getManagedLifecycleHook(path, CLAUDE_HOOK_SETTINGS).command],
remote: (path) => [getClaudeRemoteCommand(path)]
}
],
@@ -69,4 +69,21 @@ describe('managed hook script refresh stays off the main thread', () => {
expect(syncCallsUnderHome()).toEqual([])
})
it('keeps the Windows Claude entry and payload refresh off the main thread', async () => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
try {
const hooksDir = join(state.home, '.orca', 'agent-hooks')
await mkdir(hooksDir, { recursive: true })
await writeFile(join(hooksDir, 'claude-hook.cmd'), 'stale', 'utf-8')
state.syncCalls = []
await MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS.find(([agent]) => agent === 'claude')![1]()
expect(syncCallsUnderHome()).toEqual([])
} finally {
Object.defineProperty(process, 'platform', platform)
}
})
})
@@ -40,6 +40,10 @@ const { homedirMock } = vi.hoisted(() => ({
homedirMock: vi.fn<() => string>()
}))
vi.mock('../codex/codex-hook-trust-grant', () => ({
grantManagedCodexHookTrust: async () => ({ lane: 'fallback', reason: 'unsupported' })
}))
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/orca-user-data'
@@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto'
import { chmod, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { grantDirAclAsync, isPermissionError } from '../win32-utils'
@@ -28,7 +28,7 @@ async function readExistingScript(scriptPath: string): Promise<ExistingScript> {
}
}
async function scriptStillExists(scriptPath: string): Promise<boolean> {
export async function scriptStillExists(scriptPath: string): Promise<boolean> {
try {
await stat(scriptPath)
return true
@@ -61,12 +61,26 @@ async function writeScriptWithAclRetry(scriptPath: string, content: string): Pro
export async function refreshManagedScriptIfPresent(
scriptPath: string,
content: string
): Promise<boolean> {
return writeManagedScriptAtomically(scriptPath, content, false)
}
// Callers must establish ownership from a surviving managed script or registration first.
export async function restoreManagedScript(scriptPath: string, content: string): Promise<void> {
await mkdir(dirname(scriptPath), { recursive: true })
await writeManagedScriptAtomically(scriptPath, content, true)
}
async function writeManagedScriptAtomically(
scriptPath: string,
content: string,
allowMissing: boolean
): Promise<boolean> {
const existing = await readExistingScript(scriptPath)
if (!existing.exists) {
if (!existing.exists && !allowMissing) {
return false
}
if (existing.content === content) {
if (existing.exists && existing.content === content) {
if (process.platform !== 'win32') {
await chmod(scriptPath, 0o755)
}
@@ -79,7 +93,7 @@ export async function refreshManagedScriptIfPresent(
if (process.platform !== 'win32') {
await chmod(tmpPath, 0o755)
}
if (!(await scriptStillExists(scriptPath))) {
if (!allowMissing && !(await scriptStillExists(scriptPath))) {
return false
}
await rename(tmpPath, scriptPath)
@@ -34,6 +34,10 @@ const { homedirMock } = vi.hoisted(() => ({
homedirMock: vi.fn<() => string>()
}))
vi.mock('../codex/codex-hook-trust-grant', () => ({
grantManagedCodexHookTrust: async () => ({ lane: 'fallback', reason: 'unsupported' })
}))
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/orca-user-data'
@@ -296,7 +300,7 @@ describe('Windows managed hook stdin structure', () => {
expect(script, `${fileName} no ORCA_* guard may route to the more.com drain`).not.toMatch(
/ORCA_[A-Z_]+.*goto :?orca_agent_hook_drain_stdin/
)
// Why: the epilogue stays shared — claude-hook.cmd still jumps to it from the
// Why: the epilogue stays shared — claude-hook-impl.cmd still jumps to it from the
// Devin-imports-.claude skip, which now sits below these guards.
expect(script, `${fileName} drain epilogue`).toContain(
[
@@ -310,7 +314,7 @@ describe('Windows managed hook stdin structure', () => {
// Why (#11549): the Devin skip is the only remaining in-script jump to more.com, so it
// must sit below the env guards — otherwise a Devin session outside an Orca pane still
// parks there and strands the hook exactly like the pre-fix guards did.
const claude = readFileSync(join(hooksDir, 'claude-hook.cmd'), 'utf8')
const claude = readFileSync(join(hooksDir, 'claude-hook-impl.cmd'), 'utf8')
expect(claude, 'claude devin guard present').toContain(
'if not "%DEVIN_PROJECT_DIR%"=="" goto :orca_agent_hook_drain_stdin'
)
@@ -524,7 +528,7 @@ describe('Windows managed hook stdin structure', () => {
// Why: the encoded launcher resolves %USERPROFILE% at run time, so redirecting it is
// what makes the script vanish for that shape. The direct launcher (#18875) carries
// an absolute path, so here it asserts only that a bogus profile changes nothing; its
// missing-script fallback is covered live in windows-direct-cmd-hook-command.test.ts.
// missing-entry failure (never exit 2) is covered live in windows-direct-cmd-hook-command.test.ts.
name: 'missing managed script',
env: hookEnvironment({ USERPROFILE: absentProfile })
}
@@ -1,23 +1,23 @@
// Why (#18875): the registered Windows Claude hook is now the script path itself, so this file
// pins the two things that make that safe — the shape carries nothing MSYS or cmd.exe rewrites,
// and it still answers with neutral JSON when the script is gone. The live legs run the string
// through BOTH hosts Claude Code can pick, because the shape has to parse in either.
// The registered command must not depend on the shell Claude selects.
import { describe, expect, it } from 'vitest'
import { execFileSync } from 'node:child_process'
import { runProcess } from '../../shared/child-process/run-process'
import { existsSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { removeTreeSync } from '../../shared/windows-transient-lock-removal'
import { WINDOWS_CMD_SAFE_PATH } from './installer-utils'
import { wrapWindowsDirectCmdHookCommand } from './windows-direct-cmd-hook-command'
import { getWindowsClaudeHookEntry } from '../claude/windows-hook-files'
import { getManagedScript } from '../claude/hook-script'
import { getWindowsPowerShellExecutablePath } from './windows-powershell-hook-launcher'
import { findGitBash } from './windows-git-bash-path.test-fixture'
const SAFE_PATH = 'C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd'
describe('wrapWindowsDirectCmdHookCommand', () => {
it('emits the script path with forward slashes and a neutral-JSON fallback', () => {
it('emits an operator-free path that PowerShell 5.1 can parse', () => {
expect(wrapWindowsDirectCmdHookCommand(SAFE_PATH)).toBe(
'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd || echo {}'
'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd'
)
})
@@ -33,6 +33,7 @@ describe('wrapWindowsDirectCmdHookCommand', () => {
// Why: `2>nul` writes a literal file named `nul` into the cwd under MSYS (measured), and no
// stderr sink parses in both hosts. The missing-script line is left on stderr deliberately.
expect(command).not.toContain('2>')
expect(command).not.toMatch(/[|&;]/)
})
it('declines any path the shells cannot carry bare', () => {
@@ -53,91 +54,142 @@ describe('wrapWindowsDirectCmdHookCommand', () => {
})
})
describe.skipIf(process.platform !== 'win32')('direct hook command, run by both hook hosts', () => {
// Why: the fixture throws when Git Bash is absent, and that is a skip here, not a failure —
// a box without it never gets this command shape in the first place.
const gitBash = ((): string | null => {
try {
return findGitBash()
} catch {
return null
}
})()
function runInCmd(command: string, cwd: string): { stdout: string; status: number } {
return runCapture('cmd.exe', ['/d', '/c', command], cwd)
}
function runInBash(command: string, cwd: string): { stdout: string; status: number } {
return runCapture(gitBash!, ['-c', command], cwd)
}
function runCapture(file: string, args: string[], cwd: string) {
try {
const stdout = execFileSync(file, args, {
cwd,
input: '{"hook_event_name":"PreToolUse"}',
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe']
})
return { stdout, status: 0 }
} catch (error) {
const failure = error as { stdout?: string; status?: number }
return { stdout: failure.stdout ?? '', status: failure.status ?? 1 }
}
}
// Why: a runner whose TEMP sits under a profile with a space is the encoded-launcher case,
// so these legs skip rather than assert a contract that shape never claimed.
const tempIsCmdSafe = WINDOWS_CMD_SAFE_PATH.test(join(tmpdir(), 'orca-direct-hook-x', 'x.cmd'))
const canRunLive = Boolean(gitBash) && tempIsCmdSafe
function withTempDir(run: (dir: string, scriptPath: string, command: string) => void): void {
const dir = mkdtempSync(join(tmpdir(), 'orca-direct-hook-'))
try {
const scriptPath = join(dir, 'claude-hook.cmd')
const command = wrapWindowsDirectCmdHookCommand(scriptPath)
expect(command, 'precondition: temp path must be cmd-safe').not.toBeNull()
run(dir, scriptPath, command!)
} finally {
// Why: cmd.exe/bash have just exited in this tree; a raw recursive rm throws EPERM on
// Windows while their handles drain.
removeTreeSync(dir)
}
}
it.skipIf(!canRunLive)('answers {} and exit 0 in both hosts when the script exists', () => {
withTempDir((dir, scriptPath, command) => {
writeFileSync(scriptPath, '@echo off\r\necho {}\r\nexit /b 0\r\n', 'utf8')
for (const result of [runInCmd(command, dir), runInBash(command, dir)]) {
expect(result.stdout.trim()).toBe('{}')
expect(result.status).toBe(0)
describe.skipIf(process.platform !== 'win32')(
'direct hook command, run by Windows hook hosts',
() => {
// Git Bash is optional; cmd.exe and Windows PowerShell still exercise the command without it.
const gitBash = ((): string | null => {
try {
return findGitBash()
} catch {
return null
}
})
})
})()
it.skipIf(!canRunLive)(
'still answers {} and exit 0 in both hosts when the script is gone',
() => {
// Why: compat consumers require neutral JSON even with no managed script (#14818). The
// encoded launcher did this with a Test-Path; `|| echo {}` does it with no interpreter.
withTempDir((dir, scriptPath, command) => {
expect(existsSync(scriptPath)).toBe(false)
for (const result of [runInCmd(command, dir), runInBash(command, dir)]) {
expect(result.stdout.trim()).toBe('{}')
expect(result.status).toBe(0)
}
})
async function runInHosts(command: string, cwd: string) {
const hosts: [string, string[]][] = [
['cmd.exe', ['/d', '/c', command]],
[getWindowsPowerShellExecutablePath(), ['-NoProfile', '-Command', command]]
]
if (gitBash) {
hosts.push([gitBash, ['-c', command]])
}
const pwsh = join(
process.env.ProgramFiles ?? 'C:\\Program Files',
'PowerShell',
'7',
'pwsh.exe'
)
if (existsSync(pwsh)) {
hosts.push([pwsh, ['-NoProfile', '-Command', command]])
}
const results: Awaited<ReturnType<typeof runCapture>>[] = []
for (const [file, args] of hosts) {
results.push(await runCapture(file, args, cwd))
}
return results
}
)
it.skipIf(!canRunLive)('leaves no stray `nul` file behind in the working directory', () => {
// Why this is worth a test: adding `2>nul` to silence the missing-script line looks like
// tidy-up, but under MSYS it creates a real file named `nul` in the cwd — which is the
// user's repo. Measured on Windows 11. Keep stderr unredirected.
withTempDir((dir, _scriptPath, command) => {
runInBash(command, dir)
expect(readdirSync(dir)).not.toContain('nul')
})
})
})
// Why async runProcess: runProcessSync cannot take a string stdin (it forces encoding 'buffer').
async function runCapture(file: string, args: string[], cwd: string) {
const result = await runProcess({
program: file,
args,
cwd,
// Why PATHEXT: without it Windows PowerShell 5.1 prints nothing and exits 0 for a .cmd path
// (measured); every real hook host inherits it.
env: {
SystemRoot: process.env.SystemRoot,
PATH: process.env.PATH,
PATHEXT: process.env.PATHEXT,
ComSpec: process.env.ComSpec,
HOME: cwd,
USERPROFILE: cwd
},
input: '{"hook_event_name":"PreToolUse"}',
timeoutMs: 5_000
})
expect(result.timedOut, result.stderr).toBe(false)
return { stdout: result.stdout, status: result.code, label: `${file}: ${result.stderr}` }
}
// Why: a runner whose TEMP sits under a profile with a space is the encoded-launcher case,
// so these legs skip rather than assert a contract that shape never claimed.
const tempIsCmdSafe = WINDOWS_CMD_SAFE_PATH.test(join(tmpdir(), 'orca-direct-hook-x', 'x.cmd'))
const canRunLive = tempIsCmdSafe
async function withTempDir(
run: (dir: string, scriptPath: string, command: string) => Promise<void>
): Promise<void> {
const dir = mkdtempSync(join(tmpdir(), 'orca-direct-hook-'))
try {
const scriptPath = join(dir, 'claude-hook.cmd')
const command = wrapWindowsDirectCmdHookCommand(scriptPath)
expect(command, 'precondition: temp path must be cmd-safe').not.toBeNull()
await run(dir, scriptPath, command!)
} finally {
// Why: cmd.exe/bash have just exited in this tree; a raw recursive rm throws EPERM on
// Windows while their handles drain.
removeTreeSync(dir)
}
}
it.skipIf(!canRunLive)(
'answers {} and exit 0 in both hosts when the script exists',
async () => {
await withTempDir(async (dir, scriptPath, command) => {
writeFileSync(scriptPath, '@echo off\r\necho {}\r\nexit /b 0\r\n', 'utf8')
for (const result of await runInHosts(command, dir)) {
expect(result.stdout.trim(), result.label).toBe('{}')
expect(result.status, result.label).toBe(0)
}
})
}
)
it.skipIf(!canRunLive)(
'runs the generated pair and answers when its payload is missing',
async () => {
await withTempDir(async (dir, scriptPath, command) => {
writeFileSync(scriptPath, getWindowsClaudeHookEntry(), 'utf8')
for (const result of await runInHosts(command, dir)) {
expect(result.stdout.trim(), result.label).toBe('{}')
expect(result.status, result.label).toBe(0)
}
writeFileSync(join(dir, 'claude-hook-impl.cmd'), getManagedScript(), 'utf8')
for (const result of await runInHosts(command, dir)) {
expect(result.stdout.trim(), result.label).toBe('{}')
expect(result.status, result.label).toBe(0)
}
})
}
)
it.skipIf(!canRunLive)(
'reports a non-blocking failure, never exit 2, when the entry is gone',
async () => {
await withTempDir(async (dir, scriptPath, command) => {
expect(existsSync(scriptPath)).toBe(false)
for (const result of await runInHosts(command, dir)) {
expect(result.stdout.trim(), result.label).toBe('')
expect(result.status, result.label).toBeGreaterThan(0)
expect(result.status, result.label).not.toBe(2)
}
})
}
)
it.skipIf(!canRunLive)(
'leaves no stray `nul` file behind in the working directory',
async () => {
// Why this is worth a test: adding `2>nul` to silence the missing-script line looks like
// tidy-up, but under MSYS it creates a real file named `nul` in the cwd — which is the
// user's repo. Measured on Windows 11. Keep stderr unredirected.
await withTempDir(async (dir, _scriptPath, command) => {
await runInHosts(command, dir)
expect(readdirSync(dir)).not.toContain('nul')
})
}
)
}
)
@@ -1,30 +1,13 @@
import { WINDOWS_CMD_SAFE_PATH } from './installer-utils'
// Why: a drive-letter path only. WINDOWS_CMD_SAFE_PATH also admits a UNC profile, and
// `//server/share/...` is not a command cmd.exe reliably starts.
// UNC paths cannot be started consistently by all Windows hook hosts.
const WINDOWS_DRIVE_LETTER_PATH = /^[A-Za-z]:\\/
/**
* Shortest launcher for a managed Windows `.cmd` hook: the script path itself (#18875).
*
* The encoded PowerShell launcher spent a full interpreter start-up per hook event to reach a
* script that exits at its first `ORCA_PANE_KEY` guard, and left a stdout-holding orphan behind
* when the hook's timeout kill landed. Measurements and the EDR trade are in
* `docs/reference/windows-edr-posture.md`.
*
* Returns null when the caller must keep the encoded launcher: a path either shell would mangle.
*/
/** A bare path works without guessing whether Claude selected Bash or PowerShell. */
export function wrapWindowsDirectCmdHookCommand(scriptPath: string): string | null {
if (!WINDOWS_CMD_SAFE_PATH.test(scriptPath) || !WINDOWS_DRIVE_LETTER_PATH.test(scriptPath)) {
return null
}
// Why: forward slashes are the one separator both hosts read, and no token here is a switch
// MSYS can rewrite — a literal `cmd.exe /d /c <path>` does not survive Git Bash (measured).
const invocation = scriptPath.replaceAll('\\', '/')
// Why: neutral JSON when the script is missing (#14818), with no interpreter to Test-Path with.
// Valid in bash and cmd.exe; PowerShell 5.1 rejects `||`, which is what gates this on Git Bash.
// It also fires when cmd.exe itself exits non-zero (a failing AutoRun), printing `{}` twice —
// on that same box the encoded launcher exited 1 instead, so neither shape is clean there.
// Stderr stays unredirected: `2>nul` writes a literal `nul` file into the cwd under MSYS.
return `${invocation} || echo {}`
// The script owns failure handling; PowerShell 5.1 cannot parse a shell-level `||`.
return scriptPath.replaceAll('\\', '/')
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { runProcess } from '../../shared/child-process/run-process'
import { getWindowsManagedLifecycleHook } from '../claude/hook-settings'
const command = getWindowsManagedLifecycleHook(
'C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd'
).command
const input = '{"message":"café 日本語 & %PATH%", "hook_event_name":"Stop"}'
// A POSIX fixture at the exact registered spelling exercises tokenization, not Windows batch execution.
describe.skipIf(process.platform === 'win32')('Windows command under available POSIX hosts', () => {
// PowerShell parsing is covered by the Windows-only host legs in windows-direct-cmd-hook-command.test.ts.
for (const shell of ['/bin/bash', '/bin/zsh']) {
it.skipIf(!existsSync(shell))(
`preserves invocation and reports a missing entry without exit 2: ${shell}`,
async () => {
const root = mkdtempSync(join(tmpdir(), 'claude-command-host-'))
const fixture = join(root, 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd')
const run = () =>
runProcess({
program: '/usr/bin/env',
args: ['-i', `HOME=${root}`, 'PATH=/usr/bin:/bin', shell, '-c', command],
cwd: root,
env: {},
input,
timeoutMs: 5_000
})
try {
mkdirSync(dirname(fixture), { recursive: true })
writeFileSync(fixture, '#!/bin/sh\nprintf "{}\\n"\ncat > payload.txt\nexit 0\n', {
mode: 0o755
})
const present = await run()
expect(present.code, present.stderr).toBe(0)
expect(present.stdout.trim()).toBe('{}')
expect(readFileSync(join(root, 'payload.txt'), 'utf8')).toBe(input)
rmSync(fixture)
const missing = await run()
expect(missing.timedOut).toBe(false)
expect(missing.code).toBeGreaterThan(0)
expect(missing.code).not.toBe(2)
expect(missing.stdout.trim()).toBe('')
expect(missing.stderr).not.toBe('')
} finally {
rmSync(root, { recursive: true, force: true })
}
}
)
}
})
@@ -15,6 +15,10 @@ const { homedirMock } = vi.hoisted(() => ({
homedirMock: vi.fn<() => string>()
}))
vi.mock('../codex/codex-hook-trust-grant', () => ({
grantManagedCodexHookTrust: async () => ({ lane: 'fallback', reason: 'unsupported' })
}))
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/orca-user-data'
@@ -42,8 +42,7 @@ export function getWindowsPowerShellExecutablePath(): string {
*
* #18875 took that answer for the Claude lifecycle hook, which now registers the
* managed `.cmd` path directly (`windows-direct-cmd-hook-command.ts`) and reaches
* this launcher only when the profile path is not cmd-safe or Git Bash is not
* resolvable. Every other caller still comes through here on every event.
* this launcher only when the profile path is not cmd-safe. Every other caller still comes through here on every event.
*/
export const WINDOWS_POWERSHELL_HOOK_SWITCHES = '-NoProfile'
+7 -5
View File
@@ -16,6 +16,12 @@ import {
buildWindowsHookStdinDrainEpilogue
} from '../agent-hooks/hook-stdin-contract'
// Why: a backgrounded session runs in a daemon worker that inherited the dispatching
// pane's env, so ORCA_PANE_KEY names a pane this session does not run in (#9236).
// Why exit, not the drain label: the drain parks in more.com and a worker is outside
// an Orca pane — the abandoned-stdin hang #11549 guards against.
export const WINDOWS_CLAUDE_BACKGROUND_JOB_GUARD = 'if not "%CLAUDE_JOB_DIR%"=="" exit /b 0'
export function getManagedScript(
target: 'local' | 'posix' = 'local',
options: {
@@ -36,11 +42,7 @@ export function getManagedScript(
// Why (#11549): the env guards must outrank the Devin skip — the Devin skip parks in more.com,
// and outside an Orca pane the caller can abandon stdin, so more.com never returns.
...buildWindowsHookEnvironmentGuardLines(),
// Why: a backgrounded session runs in a daemon worker that inherited the dispatching
// pane's env, so ORCA_PANE_KEY names a pane this session does not run in (#9236).
// Why exit, not the drain label: the drain parks in more.com and a worker is outside
// an Orca pane — the abandoned-stdin hang #11549 guards against.
'if not "%CLAUDE_JOB_DIR%"=="" exit /b 0',
WINDOWS_CLAUDE_BACKGROUND_JOB_GUARD,
...(options.skipWhenGrokImportsClaude ? buildWindowsGrokReplayGuardLines() : []),
...(options.skipWhenDevinImportsClaude
? [
+32 -22
View File
@@ -15,8 +15,7 @@ vi.mock('electron', () => ({
}
}))
// Why: the installed hook shape depends on whether Git Bash is resolvable on the host, so the
// install assertions below have to state which host they describe rather than inherit the box's.
// Mock discovery so registration can be checked with Git Bash both present and absent.
const { gitBashAvailableMock } = vi.hoisted(() => ({ gitBashAvailableMock: { value: true } }))
vi.mock('../git-bash', async (importOriginal) => ({
...(await importOriginal<typeof GitBashModule>()),
@@ -55,10 +54,10 @@ describe('getWindowsManagedLifecycleHook', () => {
// Why this is the whole point: the encoded launcher spent a PowerShell start-up per hook
// event (471ms vs 201ms measured) before the .cmd could reach its ORCA_PANE_KEY guard, and
// its orphan outlived the hook's timeout kill still holding the stdout the agent reads.
const hook = getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH, { gitBashAvailable: true })
const hook = getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH)
expect(hook.args).toBeUndefined()
expect(hook.command).toBe('C:/Users/alice/.orca/agent-hooks/claude-hook.cmd || echo {}')
expect(hook.command).toBe('C:/Users/alice/.orca/agent-hooks/claude-hook.cmd')
expect(hook.command).not.toMatch(/powershell|-EncodedCommand|conhost/i)
// Why: Git Bash/MSYS mangles backslash paths and rewrites slash-prefixed switches.
expect(hook.command).not.toMatch(/\\/)
@@ -66,7 +65,7 @@ describe('getWindowsManagedLifecycleHook', () => {
})
it('falls back to the encoded launcher when the profile path is not cmd-safe', () => {
const hook = getWindowsManagedLifecycleHook(UNSAFE_SCRIPT_PATH, { gitBashAvailable: true })
const hook = getWindowsManagedLifecycleHook(UNSAFE_SCRIPT_PATH)
expect(hook.args).toBeUndefined()
expect(hook.command).toMatch(/\/powershell\.exe -NoProfile -EncodedCommand /)
@@ -79,18 +78,21 @@ describe('getWindowsManagedLifecycleHook', () => {
expect(decoded).toContain('.orca\\agent-hooks\\claude-hook.cmd')
})
it('falls back to the encoded launcher when Git Bash is not resolvable', () => {
// Why: without Git Bash, Claude Code hosts the hook in PowerShell, and PowerShell 5.1
// rejects `||` as a statement separator (measured) — every event would be a parse error.
const hook = getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH, { gitBashAvailable: false })
expect(hook.command).toMatch(/\/powershell\.exe -NoProfile -EncodedCommand /)
it('does not consult Git Bash availability', () => {
gitBashAvailableMock.value = false
try {
expect(getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH).command).toBe(
'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd'
)
} finally {
gitBashAvailableMock.value = true
}
})
it('is still recognized as managed by createManagedCommandMatcher (#14825)', () => {
for (const hook of [
getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH, { gitBashAvailable: true }),
getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH, { gitBashAvailable: false })
getWindowsManagedLifecycleHook(SAFE_SCRIPT_PATH),
getWindowsManagedLifecycleHook(UNSAFE_SCRIPT_PATH)
]) {
expect(isClaudeManagedCommand(hook.command)).toBe(true)
}
@@ -242,7 +244,7 @@ describe('ClaudeHookService.install', () => {
// Why: POSIX resolves the profile at runtime (`${HOME-}`, STA-3348). Windows cannot —
// no single token expands in both Git Bash and cmd.exe — so it registers the absolute
// path, as Codex/Grok/Devin/Antigravity already do (#18875). A moved profile is caught
// by getStatus's exact match and rewritten, and `|| echo {}` keeps a stale entry neutral.
// by getStatus's exact match and rewritten; a deleted entry reports an ordinary hook error.
if (process.platform !== 'win32') {
expect(JSON.stringify(managedHook)).not.toContain(tmpHome.replaceAll('\\', '/'))
}
@@ -258,7 +260,12 @@ describe('ClaudeHookService.install', () => {
true
)
const managedScript = readFileSync(
join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME),
join(
tmpHome,
'.orca',
'agent-hooks',
process.platform === 'win32' ? 'claude-hook-impl.cmd' : CLAUDE_SCRIPT_FILE_NAME
),
'utf-8'
)
expect(managedScript).toContain('DEVIN_PROJECT_DIR')
@@ -506,7 +513,7 @@ describe('ClaudeHookService.install', () => {
readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf-8')
) as { hooks: Record<string, { hooks: TestHook[] }[]> }
const expected = `${scriptPath.replaceAll('\\', '/')} || echo {}`
const expected = scriptPath.replaceAll('\\', '/')
for (const { eventName } of CLAUDE_EVENTS) {
const hook = settings.hooks[eventName]?.[0]?.hooks?.[0]
expect(hook?.args, eventName).toBeUndefined()
@@ -537,7 +544,9 @@ describe('ClaudeHookService.install', () => {
try {
const settingsPath = join(tmpHome, '.claude', 'settings.json')
mkdirSync(join(tmpHome, '.claude'), { recursive: true })
const stale = getWindowsManagedLifecycleHook(scriptPath, { gitBashAvailable: false })
const stale = getWindowsManagedLifecycleHook(
'C:\\Users\\%name%\\.orca\\agent-hooks\\claude-hook.cmd'
)
writeFileSync(
settingsPath,
JSON.stringify({
@@ -605,9 +614,7 @@ describe('ClaudeHookService.install', () => {
hooks: Record<string, { hooks: TestHook[] }[]>
}
expect(JSON.stringify(settings.hooks)).not.toContain('someone-else')
expect(settings.hooks.PreToolUse[0].hooks[0].command).toBe(
`${scriptPath.replaceAll('\\', '/')} || echo {}`
)
expect(settings.hooks.PreToolUse[0].hooks[0].command).toBe(scriptPath.replaceAll('\\', '/'))
expect(new ClaudeHookService().getStatus().state).toBe('installed')
} finally {
vi.unstubAllEnvs()
@@ -625,7 +632,7 @@ describe('ClaudeHookService.install', () => {
try {
expect(new ClaudeHookService().install().state).toBe('installed')
const script = readFileSync(
join(tmpHome, '.orca', 'agent-hooks', CLAUDE_SCRIPT_FILE_NAME),
join(tmpHome, '.orca', 'agent-hooks', 'claude-hook-impl.cmd'),
'utf-8'
)
expect(script).toContain('%SystemRoot%\\System32\\curl.exe')
@@ -705,7 +712,10 @@ describe('backgrounded-session pane guard (#9236)', () => {
vi.stubEnv('USERPROFILE', tmpHome)
try {
expect(new ClaudeHookService().install().state).toBe('installed')
const script = readFileSync(join(tmpHome, '.orca', 'agent-hooks', 'claude-hook.cmd'), 'utf-8')
const script = readFileSync(
join(tmpHome, '.orca', 'agent-hooks', 'claude-hook-impl.cmd'),
'utf-8'
)
const guard = script.split('\r\n').find((line) => line.includes('CLAUDE_JOB_DIR'))
expect(guard).toBe('if not "%CLAUDE_JOB_DIR%"=="" exit /b 0')
// Why: the drain parks in more.com, and a daemon worker is exactly the
+31 -26
View File
@@ -16,6 +16,11 @@ import {
} from '../agent-hooks/installer-utils-remote'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import { getManagedScript } from './hook-script'
import {
getWindowsClaudeHookFileStatus,
installWindowsClaudeHookFiles,
refreshWindowsClaudeHookFiles
} from './windows-hook-files'
export { getManagedScript }
import { getManagedStatusLineScript } from './statusline-script'
@@ -72,6 +77,18 @@ export class ClaudeHookService {
this.options = options
}
private get usesWindowsEntry(): boolean {
return process.platform === 'win32' && this.options.agent === 'claude'
}
private managedScript(target: 'local' | 'posix' = 'local'): string {
return getManagedScript(target, {
source: this.options.source,
skipWhenDevinImportsClaude: this.options.agent === 'claude',
skipWhenGrokImportsClaude: this.options.agent === 'claude'
})
}
// Why: Claude's settings loader rejects events newer than the running CLI, so its plan follows the
// resolved version; OpenClaude and Qoder read their own settings files.
private managedHookPlan(options: ClaudeHookInstallOptions): ClaudeManagedHookPlan {
@@ -124,18 +141,16 @@ export class ClaudeHookService {
state = 'partial'
detail = `Managed hook missing for events: ${missing.join(', ')}`
}
return { agent: this.options.agent, state, configPath, managedHooksPresent, detail }
const status = { agent: this.options.agent, state, configPath, managedHooksPresent, detail }
return this.usesWindowsEntry ? getWindowsClaudeHookFileStatus(status, scriptPath) : status
}
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(
getManagedScriptPath(this.options.settings),
getManagedScript('local', {
source: this.options.source,
skipWhenDevinImportsClaude: this.options.agent === 'claude',
skipWhenGrokImportsClaude: this.options.agent === 'claude'
})
)
const scriptPath = getManagedScriptPath(this.options.settings)
const payload = this.managedScript()
await (this.usesWindowsEntry
? refreshWindowsClaudeHookFiles(scriptPath, payload)
: refreshManagedScriptIfPresent(scriptPath, payload))
// Why: no agent gate — the statusline script only ever exists for claude, so presence is the gate.
await refreshManagedScriptIfPresent(
getStatusLineScriptPath(this.options.settings),
@@ -165,14 +180,12 @@ export class ClaudeHookService {
getManagedScriptFileName(this.options.settings),
plan
)
writeManagedScript(
scriptPath,
getManagedScript('local', {
source: this.options.source,
skipWhenDevinImportsClaude: this.options.agent === 'claude',
skipWhenGrokImportsClaude: this.options.agent === 'claude'
})
)
const payload = this.managedScript()
if (this.usesWindowsEntry) {
installWindowsClaudeHookFiles(scriptPath, payload)
} else {
writeManagedScript(scriptPath, payload)
}
if (plan.statusLine === 'install') {
nextConfig = this.installManagedStatusLine(nextConfig)
} else if (plan.statusLine === 'retire') {
@@ -257,15 +270,7 @@ export class ClaudeHookService {
// Why: write scripts before settings to avoid settings pointing to missing scripts.
// Why: SSH scripts always use POSIX .sh paths, regardless of the local OS.
await writeManagedScriptRemote(
sftp,
remoteScriptPath,
getManagedScript('posix', {
source: this.options.source,
skipWhenDevinImportsClaude: this.options.agent === 'claude',
skipWhenGrokImportsClaude: this.options.agent === 'claude'
})
)
await writeManagedScriptRemote(sftp, remoteScriptPath, this.managedScript('posix'))
// Why: no statusline install here — this path serves SSH remotes and WSL guests, whose relay hook
// listener doesn't route /statusline/claude, and an SSH box's Claude login can be a different
// account than the locally selected one, so its usage must not feed the local bar (live feed is host-local only).
+5 -17
View File
@@ -15,7 +15,6 @@ import {
import { quotePowerShellLiteral } from '../../shared/powershell-native-argument'
import { wrapRuntimeHomeHookCommand } from '../agent-hooks/runtime-home-hook-command'
import { wrapWindowsDirectCmdHookCommand } from '../agent-hooks/windows-direct-cmd-hook-command'
import { isGitBashAvailable } from '../git-bash'
import type { ClaudeManagedHookPlan } from './claude-managed-hook-events'
export type ClaudeCompatibleHookSettings = {
@@ -91,8 +90,7 @@ export function getManagedCommand(
export function getManagedLifecycleHook(
scriptPath: string,
settings = CLAUDE_HOOK_SETTINGS,
options: WindowsManagedLifecycleHookOptions = {}
settings = CLAUDE_HOOK_SETTINGS
): HookCommandConfig {
if (process.platform !== 'win32' || !settings.usesWindowsCompatLauncher) {
return buildManagedCommandHook(getManagedCommand(scriptPath, { neutralJsonWhenMissing: true }))
@@ -105,22 +103,12 @@ export function getManagedLifecycleHook(
timeout: MANAGED_HOOK_TIMEOUT_SECONDS
}
}
return getWindowsManagedLifecycleHook(scriptPath, options)
return getWindowsManagedLifecycleHook(scriptPath)
}
export type WindowsManagedLifecycleHookOptions = { gitBashAvailable?: boolean }
// Why: some Claude-compatible consumers ignore `args`, so the invocation must be self-contained.
export function getWindowsManagedLifecycleHook(
scriptPath: string,
options: WindowsManagedLifecycleHookOptions = {}
): HookCommandConfig {
// Why (#18875): the encoded launcher cost a PowerShell start-up per hook event. Take the direct
// path only where the host can parse `||` — Git Bash can, Windows PowerShell 5.1 cannot.
const directCommand =
(options.gitBashAvailable ?? isGitBashAvailable())
? wrapWindowsDirectCmdHookCommand(scriptPath)
: null
// Some compatible consumers ignore args, so keep the invocation self-contained.
export function getWindowsManagedLifecycleHook(scriptPath: string): HookCommandConfig {
const directCommand = wrapWindowsDirectCmdHookCommand(scriptPath)
if (directCommand) {
return { type: 'command', command: directCommand, timeout: MANAGED_HOOK_TIMEOUT_SECONDS }
}
+161
View File
@@ -0,0 +1,161 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import type * as osModule from 'node:os'
import { join } from 'node:path'
import * as refresh from '../agent-hooks/managed-hook-script-refresh'
import { readHooksJson, writeHooksJson } from '../agent-hooks/installer-utils'
import { ClaudeHookService } from './hook-service'
import { getWindowsManagedLifecycleHook } from './hook-settings'
import { getWindowsClaudeHookEntry, getWindowsClaudeHookPayloadPath } from './windows-hook-files'
import { codebuddyHookService } from '../codebuddy/hook-service'
import { qoderHookService } from '../qoder/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
const { home } = vi.hoisted(() => ({ home: { path: '' } }))
vi.mock('node:os', async (original) => ({
...(await original<typeof osModule>()),
homedir: () => home.path
}))
vi.mock('electron', () => ({ app: { getPath: () => home.path } }))
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!
let entry: string
let payload: string
let settings: string
const service = new ClaudeHookService()
beforeEach(() => {
home.path = mkdtempSync(join(tmpdir(), 'claude-windows-files-'))
entry = join(home.path, '.orca', 'agent-hooks', 'claude-hook.cmd')
payload = getWindowsClaudeHookPayloadPath(entry)
settings = join(home.path, '.claude', 'settings.json')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
})
afterEach(() => {
Object.defineProperty(process, 'platform', platform)
vi.restoreAllMocks()
rmSync(home.path, { recursive: true, force: true })
})
describe('Windows Claude hook files', () => {
it('installs a delegating entry and a payload that answers before bounded delivery', () => {
expect(service.install().state).toBe('installed')
expect(readFileSync(entry, 'utf8')).toBe(getWindowsClaudeHookEntry())
const body = readFileSync(payload, 'utf8')
expect(body.indexOf('echo {}')).toBeLessThan(body.indexOf('curl.exe'))
expect(body).toContain('--connect-timeout 0.5 --max-time 1.5')
expect(body).toContain('--data-urlencode "payload@-" >nul 2>&1\r\nexit /b 0')
expect(body).toContain('DEVIN_PROJECT_DIR')
})
it('repairs a missing payload from the surviving entry without touching settings', async () => {
service.install()
rmSync(payload)
expect(service.getStatus().state).toBe('partial')
const configBefore = readFileSync(settings, 'utf8')
await service.refreshManagedScripts()
expect(service.getStatus().state).toBe('installed')
expect(readFileSync(entry, 'utf8')).toBe(getWindowsClaudeHookEntry())
expect(readFileSync(payload, 'utf8')).toContain('/hook/claude')
expect(readFileSync(settings, 'utf8')).toBe(configBefore)
})
it.each(['entry', 'both'])('leaves a missing %s to install()', async (missing) => {
service.install()
rmSync(entry)
if (missing === 'both') {
rmSync(payload)
}
await service.refreshManagedScripts()
expect(existsSync(entry)).toBe(false)
expect(service.getStatus().state).toBe('partial')
expect(service.install().state).toBe('installed')
expect(readFileSync(entry, 'utf8')).toBe(getWindowsClaudeHookEntry())
expect(readFileSync(payload, 'utf8')).toContain('/hook/claude')
})
it('leaves the old single-file entry intact when payload publication fails', async () => {
mkdirSync(join(home.path, '.orca', 'agent-hooks'), { recursive: true })
const oldEntry = '@echo off\r\necho {}\r\nexit /b 0\r\n'
writeFileSync(entry, oldEntry)
vi.spyOn(refresh, 'restoreManagedScript').mockRejectedValueOnce(new Error('disk full'))
await expect(service.refreshManagedScripts()).rejects.toThrow('disk full')
expect(readFileSync(entry, 'utf8')).toBe(oldEntry)
expect(existsSync(payload)).toBe(false)
})
it('publishes the payload before replacing a legacy entry', async () => {
mkdirSync(join(home.path, '.orca', 'agent-hooks'), { recursive: true })
writeFileSync(entry, 'legacy payload')
const restore = refresh.restoreManagedScript
const writes: string[] = []
vi.spyOn(refresh, 'restoreManagedScript').mockImplementation(async (path, content) => {
if (path === entry) {
expect(readFileSync(payload, 'utf8')).toContain('/hook/claude')
}
writes.push(path)
await restore(path, content)
})
await service.refreshManagedScripts()
expect(writes).toEqual([payload, entry])
expect(existsSync(settings)).toBe(false)
})
it('keeps inert scripts on uninstall and never creates an entry from an orphan payload', async () => {
service.install()
service.remove()
expect(JSON.stringify(readHooksJson(settings))).not.toContain('claude-hook.cmd')
// Like every other agent's script: a session still holding the old settings keeps answering.
expect(readFileSync(entry, 'utf8')).toBe(getWindowsClaudeHookEntry())
rmSync(entry)
writeFileSync(payload, 'orphan payload')
await service.refreshManagedScripts()
expect(existsSync(entry)).toBe(false)
expect(readFileSync(payload, 'utf8')).toBe('orphan payload')
})
it('migrates old command forms while preserving user hooks', () => {
const encoded = getWindowsManagedLifecycleHook(
'C:\\Users\\%name%\\.orca\\agent-hooks\\claude-hook.cmd'
)
writeHooksJson(settings, {
hooks: {
Stop: [{ hooks: [encoded, { type: 'command', command: 'echo user-hook' }] }],
PreToolUse: [
{
hooks: [
{ type: 'command', command: 'C:/old/.orca/agent-hooks/claude-hook.cmd || echo {}' }
]
}
]
}
})
service.install()
const config = JSON.stringify(readHooksJson(settings))
expect(config).not.toContain('|| echo {}')
expect(config).not.toContain('C:/old/')
expect(config).toContain('echo user-hook')
expect(service.getStatus().state).toBe('installed')
})
it('keeps compatible agents on their existing single-file scripts', async () => {
for (const [name, compatible] of [
['qoder', qoderHookService],
['codebuddy', codebuddyHookService],
['openclaude', openClaudeHookService]
] as const) {
expect(compatible.install().state).toBe('installed')
await compatible.refreshManagedScripts()
const path = join(home.path, '.orca', 'agent-hooks', `${name}-hook.cmd`)
expect(readFileSync(path, 'utf8')).toContain(
`/hook/${name === 'openclaude' ? 'claude' : name}`
)
expect(readFileSync(path, 'utf8')).not.toContain('claude-hook-impl.cmd')
expect(existsSync(join(home.path, '.orca', 'agent-hooks', `${name}-hook-impl.cmd`))).toBe(
false
)
}
})
})
+66
View File
@@ -0,0 +1,66 @@
import { existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import { writeManagedScript } from '../agent-hooks/installer-utils'
import { restoreManagedScript, scriptStillExists } from '../agent-hooks/managed-hook-script-refresh'
import {
buildWindowsHookEnvironmentGuardLines,
buildWindowsHookStdinDrainEpilogue
} from '../agent-hooks/hook-stdin-contract'
import { WINDOWS_CLAUDE_BACKGROUND_JOB_GUARD } from './hook-script'
const PAYLOAD_FILE_NAME = 'claude-hook-impl.cmd'
export function getWindowsClaudeHookPayloadPath(entryPath: string): string {
return join(dirname(entryPath), PAYLOAD_FILE_NAME)
}
export function getWindowsClaudeHookFileStatus(
status: AgentHookInstallStatus,
entryPath: string
): AgentHookInstallStatus {
if (
status.state === 'installed' &&
(!existsSync(entryPath) || !existsSync(getWindowsClaudeHookPayloadPath(entryPath)))
) {
return { ...status, state: 'partial', detail: 'Managed Claude hook script is missing' }
}
return status
}
export function installWindowsClaudeHookFiles(entryPath: string, payload: string): void {
writeManagedScript(getWindowsClaudeHookPayloadPath(entryPath), payload)
writeManagedScript(entryPath, getWindowsClaudeHookEntry())
}
export function getWindowsClaudeHookEntry(): string {
return [
'@echo off',
// Inherited delayed expansion would eat exclamation marks in the profile path.
'setlocal DisableDelayedExpansion',
`set "ORCA_CLAUDE_HOOK_IMPL=%~dp0${PAYLOAD_FILE_NAME}"`,
'if not exist "%ORCA_CLAUDE_HOOK_IMPL%" goto :missing_impl',
// Transfer control without CALL's second expansion of percent signs in the path.
// A payload that exists but cannot start (locked, quarantined) falls through to the neutral reply.
'"%ORCA_CLAUDE_HOOK_IMPL%"',
':missing_impl',
'echo {}',
...buildWindowsHookEnvironmentGuardLines(),
WINDOWS_CLAUDE_BACKGROUND_JOB_GUARD,
...buildWindowsHookStdinDrainEpilogue(),
''
].join('\r\n')
}
export async function refreshWindowsClaudeHookFiles(
entryPath: string,
payload: string
): Promise<void> {
// A surviving entry is Orca-owned; creating a missing one stays install()'s presence-gated job.
if (!(await scriptStillExists(entryPath))) {
return
}
// Publish the payload first so a failed migration leaves the previous single-file hook intact.
await restoreManagedScript(getWindowsClaudeHookPayloadPath(entryPath), payload)
await restoreManagedScript(entryPath, getWindowsClaudeHookEntry())
}
@@ -219,7 +219,7 @@ export class CodexAccountRegistration {
target: CodexAccountSelectionTarget | undefined
): void {
// Why: quota probes against a cold per-account CODEX_HOME can take 10–25s
// (RPC + PTY fallback) and queue behind an in-flight global usage refresh.
// (RPC + HTTP fallback) and queue behind an in-flight global usage refresh.
// The refresh synchronously flips usage to "fetching" before its first await,
// so the switcher updates immediately; the probe itself must never block or
// fail the already-durable account mutation.
@@ -32,7 +32,7 @@ describe('CodexAccountService config sync', () => {
registerCodexAccountsTestHomes()
// Why: quota probes against a cold per-account CODEX_HOME can take 10–25s
// (RPC + PTY fallback) and queue behind an in-flight global usage refresh;
// (RPC + HTTP fallback) and queue behind an in-flight global usage refresh;
// account mutations must never block on — or fail because of — that probe.
describe('quota refresh decoupling', () => {
function createAccountOneSettings(): GlobalSettings {
+10
View File
@@ -565,6 +565,16 @@ describe('HeadlessEmulator', () => {
expect(snapshot.modes.sgrMouseMode).toBe(false)
expect(snapshot.rehydrateSequences).toContain('\x1b[?1002h')
expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1006h')
// Replay readers must not guess the encoding of an active tracking mode.
expect(snapshot.rehydrateSequences).toContain('\x1b[?1006l')
})
it('states no mouse encoding while nothing tracks the mouse', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })
await emulator.write('\x1b[?1002h\x1b[?1002l')
expect(emulator.getSnapshot().rehydrateSequences).not.toContain('\x1b[?1006')
})
it('tracks SGR-pixels mouse reporting as a separate active encoding', async () => {
@@ -66,7 +66,9 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record<string, number> = {
// background that the round trip does not restore to default. Verified as upstream, not a
// regression, by replaying it against the previous build
// (`build-serialize-addon-at-ref.mjs --ref origin/main`): I1 and I3 both hold.
'dsh-tui-ready-no-key': 10
'dsh-tui-ready-no-key': 10,
// Hermes banner cells restore with an extra bold bit under the jitter schedule.
'hermes-tui-ready': 2
}
// Exact resize checkpoints and full GridDiff hashes from base 6835b9b4e3ea, not this branch.
@@ -204,6 +206,12 @@ describe('serialize round trip over captured PTY transcripts', () => {
console.log(`${transcript.name} ${JSON.stringify(counts)}`)
}
expect(blocking).toEqual([])
if (SEEDS === 2 && transcript.name === 'hermes-tui-ready') {
expect(failureSignatures).toEqual([
'jitter/false/2/34:0db2561506ff28d2e83276b07bc8684541957f61e488b6f8081627e8cad80c63',
'jitter/true/2/34:0db2561506ff28d2e83276b07bc8684541957f61e488b6f8081627e8cad80c63'
])
}
if (SEEDS === 2 && transcript.name.startsWith('freebuff-')) {
expect(failureSignatures).toEqual(FREEBUFF_BASELINE[transcript.name] ?? [])
} else if (!OLD_ADDON_PATH && SEEDS === 2) {
@@ -19,7 +19,8 @@ export function buildRehydrateSequences(modes: TerminalModes): string {
}
// Why: mobile alt-screen scroll gestures need xterm's mouse mode restored
// from cold snapshots; OpenCode/OpenTUI enables scrollable panes this way.
switch (modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none') {
const trackingMode = modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none'
switch (trackingMode) {
case 'x10':
seqs.push('\x1b[?9h')
break
@@ -41,6 +42,9 @@ export function buildRehydrateSequences(modes: TerminalModes): string {
seqs.push('\x1b[?1016h')
} else if (modes.sgrMouseMode) {
seqs.push('\x1b[?1006h')
} else if (trackingMode !== 'none') {
// Why: states the default encoding, so a replay reader never has to guess it.
seqs.push('\x1b[?1006l')
}
return seqs.join('')
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude'
import { normalizeNativeChatUserText } from '../../shared/native-chat-image-transcript-markers'
const prompt = 'Summarize the failing tests.\n\nThen propose a fix for each one.'
const wrapped = `\n\n<pasted_content id="7e64">\n${prompt}\n</pasted_content id="7e64">\n`
function decode(text: string, role = 'user', array = false) {
return decodeClaudeTranscriptLine(
JSON.stringify({
type: role,
uuid: 'user',
message: { content: array ? [{ type: 'text', text }] : text }
}),
'fallback'
)!
}
describe('Claude whole-block paste envelope', () => {
it.each([false, true])('decodes host user content (array=%s)', (array) => {
expect(decode(wrapped, 'user', array).blocks).toEqual([{ type: 'text', text: prompt }])
})
it('accepts CRLF and a wrapper without ids', () => {
expect(decode(`<pasted_content>\r\n${prompt}\r\n</pasted_content>`).blocks).toEqual([
{ type: 'text', text: prompt }
])
})
it('decodes a paste whose own text quotes differently identified or bare tags', () => {
const quoted = `<pasted_content id="aa">\nx\n</pasted_content id="aa">\n</pasted_content>`
expect(
decode(`<pasted_content id="7e64">\n${quoted}\n</pasted_content id="7e64">`).blocks
).toEqual([{ type: 'text', text: quoted }])
})
it.each([
`Explain this:\n${wrapped}`,
`<pasted_content id="7e64">\n<pasted_content id="7e64">\nx\n</pasted_content id="7e64">`,
`<pasted_content>\n<pasted_content id="aa">\nx\n</pasted_content id="aa">\n</pasted_content>`,
wrapped.replace('id="7e64">\n', 'id="other">\n'),
wrapped.replace('</pasted_content id="7e64">', '</pasted_content>'),
wrapped.replace('<pasted_content id="7e64">', '<pasted_content>'),
`${wrapped}\n${wrapped}`
])('preserves prose and nonmatching envelopes', (text) => {
expect(decode(text).blocks).toEqual([{ type: 'text', text }])
})
it('leaves assistant text untouched', () => {
expect(decode(wrapped, 'assistant').blocks).toEqual([{ type: 'text', text: wrapped }])
})
it('handles a large prompt in linear passes', () => {
const text = 'line\n'.repeat(50_000)
expect(
normalizeNativeChatUserText(`<pasted_content id="a">\n${text}\n</pasted_content id="a">`)
).toBe(text.trim().replace(/\s+/g, ' '))
})
})
@@ -15,6 +15,7 @@ import {
} from '../ai-vault/session-scanner-values'
import { imageSourcePathFromText } from '../../shared/native-chat-image-transcript-markers'
import { claudeContentBlocks } from './transcript-record-blocks'
import { unwrapClaudePastedContentBlock } from '../../shared/claude-pasted-content'
import { claudeInterruptedMessageId } from './transcript-turn-markers'
const MAX_EDIT_PATCH_HUNKS = 40
@@ -124,7 +125,7 @@ export function decodeClaudeTranscriptLine(
return {
id: messageId ?? fallbackId,
role: claudeMessageRole(role, blocks),
blocks,
blocks: role === 'user' ? blocks.map(unwrapClaudePastedContentBlock) : blocks,
timestamp,
source: 'transcript'
}
+6 -10
View File
@@ -80,16 +80,12 @@ describe('Qoder Windows hook shell', () => {
it('uses the documented explicit shell without relying on Git Bash or another PowerShell hop', () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
try {
const hook = getManagedLifecycleHook(
'C:\\Users\\a b\\.orca\\agent-hooks\\qoder-hook.cmd',
{
configDirName: '.qoder',
scriptBaseName: 'qoder-hook',
usesWindowsCompatLauncher: true,
windowsHookShell: 'powershell'
},
{ gitBashAvailable: true }
)
const hook = getManagedLifecycleHook('C:\\Users\\a b\\.orca\\agent-hooks\\qoder-hook.cmd', {
configDirName: '.qoder',
scriptBaseName: 'qoder-hook',
usesWindowsCompatLauncher: true,
windowsHookShell: 'powershell'
})
expect(hook.shell).toBe('powershell')
expect(hook.command).toContain('$env:USERPROFILE')
expect(hook.command).not.toMatch(/EncodedCommand|ExecutionPolicy|\|\|/)
@@ -1,5 +1,5 @@
import { EventEmitter } from 'node:events'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { childSpawnMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({
childSpawnMock: vi.fn(),
@@ -26,10 +26,6 @@ vi.mock('./codex-auth-presence', () => ({
import { fetchCodexRateLimits } from './codex-fetcher'
function makeDisposable() {
return { dispose: vi.fn() }
}
function makeRpcChild() {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter
@@ -60,9 +56,14 @@ describe('fetchCodexRateLimits auth errors', () => {
vi.useFakeTimers()
vi.clearAllMocks()
resolveCodexCommandMock.mockReturnValue('codex')
vi.stubGlobal('fetch', vi.fn())
})
it('returns Codex RPC auth refresh errors without masking them behind PTY fallback', async () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns Codex RPC auth refresh errors without masking them behind a fallback', async () => {
const rpcChild = makeRpcChild()
const authError =
'Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.'
@@ -105,10 +106,11 @@ describe('fetchCodexRateLimits auth errors', () => {
status: 'error',
error: authError
})
expect(fetch).not.toHaveBeenCalled()
expect(ptySpawnMock).not.toHaveBeenCalled()
})
it('returns the app-server chatgpt-auth-required error without spawning the PTY probe', async () => {
it('returns the app-server chatgpt-auth-required error without falling back', async () => {
const rpcChild = makeRpcChild()
const authError = 'chatgpt authentication required to read rate limits'
@@ -150,75 +152,7 @@ describe('fetchCodexRateLimits auth errors', () => {
status: 'error',
error: authError
})
expect(fetch).not.toHaveBeenCalled()
expect(ptySpawnMock).not.toHaveBeenCalled()
})
it('preserves Codex PTY auth errors when the CLI exits before status is available', async () => {
const ptyHandlers: { onData?: (data: string) => void; onExit?: () => void } = {}
const authError =
'Error loading configuration: Your authentication session could not be refreshed automatically.'
childSpawnMock.mockImplementation(() => {
throw new Error('rpc unavailable')
})
ptySpawnMock.mockReturnValue({
onData: vi.fn((callback) => {
ptyHandlers.onData = callback
return makeDisposable()
}),
onExit: vi.fn((callback) => {
ptyHandlers.onExit = callback
return makeDisposable()
}),
write: vi.fn(),
kill: vi.fn()
})
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
ptyHandlers.onData?.(`${authError}\n`)
ptyHandlers.onExit?.()
await expect(resultPromise).resolves.toMatchObject({
provider: 'codex',
session: null,
weekly: null,
status: 'error',
error: authError
})
})
it('stops a PTY probe when Codex renders its sign-in screen', async () => {
const ptyHandlers: { onData?: (data: string) => void } = {}
const ptyWrite = vi.fn()
const ptyKill = vi.fn()
childSpawnMock.mockImplementation(() => {
throw new Error('rpc unavailable')
})
ptySpawnMock.mockReturnValue({
onData: vi.fn((callback) => {
ptyHandlers.onData = callback
return makeDisposable()
}),
onExit: vi.fn(() => makeDisposable()),
write: ptyWrite,
kill: ptyKill
})
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
ptyHandlers.onData?.('\u001b[2JSign in with ChatGPT\r\n')
await expect(resultPromise).resolves.toMatchObject({
provider: 'codex',
session: null,
weekly: null,
status: 'error',
error: 'Sign in with ChatGPT'
})
expect(ptyWrite).not.toHaveBeenCalled()
expect(ptyKill).toHaveBeenCalledOnce()
})
})
@@ -129,7 +129,7 @@ describe('fetchCodexRateLimits probe shutdown', () => {
}
})
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(19_999)
expect(rpcChild.kill).not.toHaveBeenCalled()
@@ -169,7 +169,7 @@ describe('fetchCodexRateLimits probe shutdown', () => {
})
try {
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(9_999)
expect(rpcChild.kill).not.toHaveBeenCalled()
@@ -200,12 +200,10 @@ describe('fetchCodexRateLimits probe shutdown', () => {
})
const first = fetchCodexRateLimits({
allowPtyFallback: false,
codexHomePath: '/managed/home-a'
})
await vi.advanceTimersByTimeAsync(0)
const second = fetchCodexRateLimits({
allowPtyFallback: false,
codexHomePath: '/managed/home-a'
})
await vi.advanceTimersByTimeAsync(0)
@@ -241,13 +239,11 @@ describe('fetchCodexRateLimits probe shutdown', () => {
try {
const first = fetchCodexRateLimits({
allowPtyFallback: false,
codexHomePath: '/managed/home-error'
})
await vi.advanceTimersByTimeAsync(0)
firstChild.emit('error', new Error('stdio failed'))
const second = fetchCodexRateLimits({
allowPtyFallback: false,
codexHomePath: '/managed/home-error'
})
await vi.advanceTimersByTimeAsync(0)
@@ -308,14 +304,12 @@ describe('fetchCodexRateLimits probe shutdown', () => {
try {
const first = fetchCodexRateLimits({
allowPtyFallback: false,
codexHomePath: home
})
await vi.advanceTimersByTimeAsync(0)
expect(firstChild.kill).toHaveBeenCalledWith('SIGTERM')
const second = fetchCodexRateLimits({
allowPtyFallback: false,
codexHomePath: home
})
await vi.advanceTimersByTimeAsync(4_999)
@@ -357,13 +351,11 @@ describe('fetchCodexRateLimits probe shutdown', () => {
try {
const first = fetchCodexRateLimits({
allowPtyFallback: false,
codexHomePath: '/managed/home-async-stdin-error'
})
await vi.advanceTimersByTimeAsync(0)
firstChild.stdin.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
const second = fetchCodexRateLimits({
allowPtyFallback: false,
codexHomePath: '/managed/home-async-stdin-error'
})
@@ -130,8 +130,7 @@ describe('Codex rate-limit process contract', () => {
it('starts a read-only non-interactive app-server with the managed home', async () => {
await expect(
fetchCodexRateLimits({
codexHomePath: process.env.ORCA_EXPECTED_CODEX_HOME,
allowPtyFallback: false
codexHomePath: process.env.ORCA_EXPECTED_CODEX_HOME
})
).resolves.toMatchObject({
provider: 'codex',
@@ -1,300 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { childSpawnMock, resolveCodexCommandMock, ptySpawnMock } = vi.hoisted(() => ({
childSpawnMock: vi.fn(),
resolveCodexCommandMock: vi.fn(),
ptySpawnMock: vi.fn()
}))
vi.mock('node:child_process', () => ({
spawn: childSpawnMock
}))
vi.mock('../codex-cli/command', () => ({
resolveCodexCommand: resolveCodexCommandMock
}))
vi.mock('node-pty', () => ({
spawn: ptySpawnMock
}))
// Auth gate is covered separately; these tests assume a signed-in Codex.
vi.mock('./codex-auth-presence', () => ({
probeCodexAuthPresence: vi.fn(() => 'present')
}))
import { fetchCodexRateLimits } from './codex-fetcher'
function makeDisposable() {
return { dispose: vi.fn() }
}
describe('fetchCodexRateLimits PTY settle timers', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
resolveCodexCommandMock.mockReturnValue('codex')
})
it('coalesces the PTY fallback status settle timer while output keeps streaming', async () => {
const ptyHandlers: { onData?: (data: string) => void } = {}
childSpawnMock.mockImplementation(() => {
throw new Error('rpc unavailable')
})
ptySpawnMock.mockReturnValue({
onData: vi.fn((callback) => {
ptyHandlers.onData = callback
return makeDisposable()
}),
onExit: vi.fn(() => makeDisposable()),
write: vi.fn(),
kill: vi.fn()
})
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
const onPtyData = ptyHandlers.onData
if (!onPtyData) {
throw new Error('PTY data handler was not registered')
}
onPtyData('>')
// Pending: PTY timeout + the delayed /status Enter keypress.
expect(vi.getTimerCount()).toBe(2)
onPtyData('5h limit: 17%\n')
onPtyData('Weekly limit: 23%\n')
onPtyData('still rendering\n')
// One settle timer armed — not one per data chunk.
expect(vi.getTimerCount()).toBe(3)
await vi.advanceTimersByTimeAsync(500)
await expect(resultPromise).resolves.toMatchObject({
session: { usedPercent: 17 },
weekly: { usedPercent: 23 },
status: 'ok'
})
})
it('keeps the reset text on the weekly window for weekly-only plans', async () => {
const ptyHandlers: { onData?: (data: string) => void } = {}
childSpawnMock.mockImplementation(() => {
throw new Error('rpc unavailable')
})
ptySpawnMock.mockReturnValue({
onData: vi.fn((callback) => {
ptyHandlers.onData = callback
return makeDisposable()
}),
onExit: vi.fn(() => makeDisposable()),
write: vi.fn(),
kill: vi.fn()
})
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
const onPtyData = ptyHandlers.onData
if (!onPtyData) {
throw new Error('PTY data handler was not registered')
}
onPtyData('>')
onPtyData('Weekly limit: 76%\nResets in 5d 23h\n')
await vi.advanceTimersByTimeAsync(500)
const fiveDays23h = (5 * 24 + 23) * 60 * 60 * 1000
await expect(resultPromise).resolves.toMatchObject({
session: null,
weekly: {
usedPercent: 76,
resetDescription: '5d 23h',
resetsAt: Date.now() + fiveDays23h
},
status: 'ok'
})
})
it('keeps each window reset text on its own window for dual-window plans', async () => {
const ptyHandlers: { onData?: (data: string) => void } = {}
childSpawnMock.mockImplementation(() => {
throw new Error('rpc unavailable')
})
ptySpawnMock.mockReturnValue({
onData: vi.fn((callback) => {
ptyHandlers.onData = callback
return makeDisposable()
}),
onExit: vi.fn(() => makeDisposable()),
write: vi.fn(),
kill: vi.fn()
})
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
const onPtyData = ptyHandlers.onData
if (!onPtyData) {
throw new Error('PTY data handler was not registered')
}
onPtyData('>')
onPtyData('5h limit: 17% (resets in 2h 30m)\nWeekly limit: 23% (resets in 5d 3h)\n')
await vi.advanceTimersByTimeAsync(500)
await expect(resultPromise).resolves.toMatchObject({
session: {
usedPercent: 17,
resetDescription: '2h 30m',
resetsAt: Date.now() + (2 * 60 + 30) * 60 * 1000
},
weekly: {
usedPercent: 23,
resetDescription: '5d 3h',
resetsAt: Date.now() + (5 * 24 + 3) * 60 * 60 * 1000
},
status: 'ok'
})
})
it('parses the framed codex 0.145 status panel via the /status nudge', async () => {
const ptyHandlers: { onData?: (data: string) => void } = {}
const write = vi.fn()
childSpawnMock.mockImplementation(() => {
throw new Error('rpc unavailable')
})
ptySpawnMock.mockReturnValue({
onData: vi.fn((callback) => {
ptyHandlers.onData = callback
return makeDisposable()
}),
onExit: vi.fn(() => makeDisposable()),
write,
kill: vi.fn()
})
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
const onPtyData = ptyHandlers.onData
if (!onPtyData) {
throw new Error('PTY data handler was not registered')
}
// codex ≥0.145 shows a '›' composer with placeholder text, never a bare '>' prompt.
onPtyData('›Summarize recent commits')
expect(write).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(2500)
expect(write).toHaveBeenCalledWith('/status')
await vi.advanceTimersByTimeAsync(350)
expect(write).toHaveBeenCalledWith('\r')
onPtyData(
'│ Weekly limit: \x1b[?2026h\x1b[0 q[█████████░░░░░░░░░░░] 43% left\x1b[?2026l (resets 10:21 on 28 Jul) │\n' +
'│ GPT-5.3-Codex-Spark Weekly limit: [████████████████████] 100% left (resets 17:40 on 29 Jul) │\n'
)
await vi.advanceTimersByTimeAsync(500)
const expectedReset = new Date(new Date().getFullYear(), 6, 28, 10, 21)
if (expectedReset.getTime() <= Date.now()) {
expectedReset.setFullYear(expectedReset.getFullYear() + 1)
}
await expect(resultPromise).resolves.toMatchObject({
session: null,
weekly: {
usedPercent: 57,
resetDescription: '10:21 on 28 Jul',
resetsAt: expectedReset.getTime()
},
status: 'ok'
})
})
it('never selects a model-scoped weekly row even when it renders first', async () => {
const ptyHandlers: { onData?: (data: string) => void } = {}
childSpawnMock.mockImplementation(() => {
throw new Error('rpc unavailable')
})
ptySpawnMock.mockReturnValue({
onData: vi.fn((callback) => {
ptyHandlers.onData = callback
return makeDisposable()
}),
onExit: vi.fn(() => makeDisposable()),
write: vi.fn(),
kill: vi.fn()
})
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
const onPtyData = ptyHandlers.onData
if (!onPtyData) {
throw new Error('PTY data handler was not registered')
}
onPtyData('>')
onPtyData(
'│ GPT-5.3-Codex-Spark Weekly limit: [████████████████████] 100% left (resets 17:40 on 29 Jul) │\n' +
'│ Weekly limit: [█████████░░░░░░░░░░░] 43% left (resets 10:21 on 28 Jul) │\n'
)
await vi.advanceTimersByTimeAsync(500)
await expect(resultPromise).resolves.toMatchObject({
session: null,
weekly: { usedPercent: 57, resetDescription: '10:21 on 28 Jul' },
status: 'ok'
})
})
it('re-sends Enter once when the panel does not render after the first submit', async () => {
const ptyHandlers: { onData?: (data: string) => void } = {}
const write = vi.fn()
childSpawnMock.mockImplementation(() => {
throw new Error('rpc unavailable')
})
ptySpawnMock.mockReturnValue({
onData: vi.fn((callback) => {
ptyHandlers.onData = callback
return makeDisposable()
}),
onExit: vi.fn(() => makeDisposable()),
write,
kill: vi.fn()
})
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
const onPtyData = ptyHandlers.onData
if (!onPtyData) {
throw new Error('PTY data handler was not registered')
}
onPtyData('>')
await vi.advanceTimersByTimeAsync(350)
expect(write.mock.calls.filter((call) => call[0] === '\r')).toHaveLength(1)
await vi.advanceTimersByTimeAsync(3000)
expect(write.mock.calls.filter((call) => call[0] === '\r')).toHaveLength(2)
onPtyData('Weekly limit: 76%\nResets in 5d 23h\n')
await vi.advanceTimersByTimeAsync(500)
await expect(resultPromise).resolves.toMatchObject({
session: null,
weekly: { usedPercent: 76 },
status: 'ok'
})
})
})
@@ -62,7 +62,8 @@ let stubPath: string
function runStub(stderr: string, exitCode: number): Promise<{ error: string | null }> {
process.env.ORCA_STUB_CODEX_STDERR = stderr
process.env.ORCA_STUB_CODEX_EXIT_CODE = String(exitCode)
return fetchCodexRateLimits({ allowPtyFallback: false })
// Why: a temp home with no auth.json keeps the HTTP fallback off the developer's real login.
return fetchCodexRateLimits({ codexHomePath: tempRoot })
}
describe('Codex RPC exit diagnostics', () => {
@@ -82,7 +82,7 @@ describe('codex rate-limit spawn runtime pairing', () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
const spawnEnv = childSpawnMock.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv
@@ -105,7 +105,7 @@ describe('codex rate-limit spawn runtime pairing', () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
const spawnCommand = childSpawnMock.mock.calls[0]?.[0] as string
@@ -127,7 +127,7 @@ describe('codex rate-limit spawn runtime pairing', () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
const spawnEnv = childSpawnMock.mock.calls[0]?.[2]?.env as NodeJS.ProcessEnv
+67 -184
View File
@@ -58,13 +58,8 @@ vi.mock('./codex-auth-presence', () => ({
import { fetchCodexRateLimits } from './codex-fetcher'
import { probeCodexAuthPresence } from './codex-auth-presence'
import { getActiveHiddenRateLimitPtyCount } from './hidden-pty-cleanup'
import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args'
function makeDisposable() {
return { dispose: vi.fn() }
}
function makeRpcChild() {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter
@@ -116,23 +111,24 @@ function respondToRpcRateLimitRead(
})
}
function makePtyTerm() {
let dataHandler: ((data: string) => void) | null = null
let exitHandler: (() => void) | null = null
return {
onData: vi.fn((callback: (data: string) => void) => {
dataHandler = callback
return makeDisposable()
}),
onExit: vi.fn((callback: () => void) => {
exitHandler = callback
return makeDisposable()
}),
write: vi.fn(),
kill: vi.fn(),
emitData: (data: string) => dataHandler?.(data),
emitExit: () => exitHandler?.()
}
function mockBackendUsage(): void {
readFileMock.mockResolvedValue(
JSON.stringify({ tokens: { access_token: 'access-token', account_id: 'account-id' } })
)
// A Response body reads once; usage and reset credits each fetch.
vi.mocked(fetch).mockImplementation(
async () =>
new Response(
JSON.stringify({
plan_type: 'plus',
rate_limit: {
primary_window: { used_percent: 7, limit_window_seconds: 5 * 60 * 60 },
secondary_window: { used_percent: 12, limit_window_seconds: 7 * 24 * 60 * 60 }
},
rate_limit_reset_credits: { available_count: 0, credits: [] }
})
)
)
}
describe('fetchCodexRateLimits', () => {
@@ -168,9 +164,7 @@ describe('fetchCodexRateLimits', () => {
it('does not let a quota probe steal an incomplete state-DB backfill lease', async () => {
isBackfillPendingMock.mockReturnValue(true)
await expect(
fetchCodexRateLimits({ codexHomePath: '/managed-home', allowPtyFallback: false })
).resolves.toMatchObject({
await expect(fetchCodexRateLimits({ codexHomePath: '/managed-home' })).resolves.toMatchObject({
status: 'error',
error: expect.stringContaining('session index')
})
@@ -219,38 +213,11 @@ describe('fetchCodexRateLimits', () => {
}
)
it('disposes node-pty listeners before killing the PTY fallback on timeout', async () => {
const onDataDisposable = makeDisposable()
const onExitDisposable = makeDisposable()
const killMock = vi.fn()
childSpawnMock.mockImplementation(() => {
throw new Error('rpc unavailable')
})
ptySpawnMock.mockReturnValue({
onData: vi.fn(() => onDataDisposable),
onExit: vi.fn(() => onExitDisposable),
write: vi.fn(),
kill: killMock
})
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(15_000)
await resultPromise
expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan(
killMock.mock.invocationCallOrder[0]
)
expect(onExitDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan(
killMock.mock.invocationCallOrder[0]
)
})
it('spawns the RPC rate-limit reader in a bounded non-root cwd', async () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
const spawnCwd = childSpawnMock.mock.calls[0]?.[2]?.cwd as string
@@ -262,26 +229,7 @@ describe('fetchCodexRateLimits', () => {
await resultPromise
})
it('spawns the PTY fallback in a bounded non-root cwd', async () => {
const term = makePtyTerm()
childSpawnMock.mockImplementation(() => {
throw new Error('rpc unavailable')
})
ptySpawnMock.mockReturnValue(term)
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
const spawnCwd = ptySpawnMock.mock.calls[0]?.[2]?.cwd as string
expect(spawnCwd).toContain('rate-limit-pty-cwd')
expect(spawnCwd).not.toBe('/')
expect(spawnCwd).not.toMatch(/^[A-Za-z]:\\?$/)
term.emitExit()
await resultPromise
})
it('kills the RPC child and skips PTY fallback when the fetch signal aborts', async () => {
it('kills the RPC child and skips the HTTP fallback when the fetch signal aborts', async () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
const controller = new AbortController()
@@ -297,77 +245,40 @@ describe('fetchCodexRateLimits', () => {
error: 'Rate-limit fetch aborted'
})
expect(rpcChild.kill).toHaveBeenCalledTimes(1)
expect(fetch).not.toHaveBeenCalled()
expect(ptySpawnMock).not.toHaveBeenCalled()
})
it('kills and unregisters the PTY fallback when the fetch signal aborts', async () => {
const term = makePtyTerm()
childSpawnMock.mockImplementation(() => {
throw new Error('rpc unavailable')
})
ptySpawnMock.mockReturnValue(term)
const controller = new AbortController()
const killMock = term.kill
const resultPromise = fetchCodexRateLimits({ signal: controller.signal })
await vi.advanceTimersByTimeAsync(0)
expect(getActiveHiddenRateLimitPtyCount()).toBe(1)
controller.abort()
await expect(resultPromise).resolves.toMatchObject({
provider: 'codex',
status: 'error',
error: 'Rate-limit fetch aborted'
})
expect(killMock).toHaveBeenCalledTimes(1)
expect(getActiveHiddenRateLimitPtyCount()).toBe(0)
})
it('falls back to the PTY status reader when RPC exits before returning usage', async () => {
it('reads usage over HTTP, never through the Codex TUI, when RPC exits before returning usage', async () => {
const rpcChild = makeRpcChild()
const ptyHandlers: { onData?: (data: string) => void } = {}
childSpawnMock.mockReturnValue(rpcChild)
ptySpawnMock.mockReturnValue({
onData: vi.fn((callback) => {
ptyHandlers.onData = callback
return makeDisposable()
}),
onExit: vi.fn(() => makeDisposable()),
write: vi.fn(),
kill: vi.fn()
})
mockBackendUsage()
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
rpcChild.emit('close')
await vi.advanceTimersByTimeAsync(0)
expect(ptySpawnMock).toHaveBeenCalled()
const onPtyData = ptyHandlers.onData
if (!onPtyData) {
throw new Error('PTY data handler was not registered')
}
onPtyData('>')
onPtyData('5h limit: 7%\nWeekly limit: 12%\n')
await vi.advanceTimersByTimeAsync(500)
await expect(resultPromise).resolves.toMatchObject({
provider: 'codex',
session: { usedPercent: 7 },
weekly: { usedPercent: 12 },
session: { usedPercent: 7, windowMinutes: 300 },
weekly: { usedPercent: 12, windowMinutes: 10080 },
status: 'ok',
error: null
})
expect(fetch).toHaveBeenCalledWith(
'https://chatgpt.com/backend-api/wham/usage',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer access-token' })
})
)
expect(ptySpawnMock).not.toHaveBeenCalled()
})
it('does not start the PTY fallback when disabled for background account previews', async () => {
it('keeps the RPC error when the HTTP fallback has no usage to offer', async () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
rpcChild.emit('close')
await vi.advanceTimersByTimeAsync(0)
@@ -379,6 +290,28 @@ describe('fetchCodexRateLimits', () => {
status: 'error'
})
expect(rpcChild.stdin.listenerCount('error')).toBe(0)
expect(fetch).not.toHaveBeenCalled()
expect(ptySpawnMock).not.toHaveBeenCalled()
})
it('shows the RPC exit reason when the HTTP fallback is rejected', async () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
mockBackendUsage()
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 401 }))
const resultPromise = fetchCodexRateLimits()
await vi.advanceTimersByTimeAsync(0)
rpcChild.emit('close', 1, null)
await expect(resultPromise).resolves.toMatchObject({
provider: 'codex',
session: null,
weekly: null,
status: 'error',
error: expect.stringContaining('exit code 1')
})
expect(fetch).toHaveBeenCalledTimes(1)
expect(ptySpawnMock).not.toHaveBeenCalled()
})
@@ -386,7 +319,7 @@ describe('fetchCodexRateLimits', () => {
const rpcChild = makeRpcChild()
childSpawnMock.mockReturnValue(rpcChild)
const resultPromise = fetchCodexRateLimits({ allowPtyFallback: false })
const resultPromise = fetchCodexRateLimits()
// Why: without an initialize response only the 30s boot deadline fires.
await vi.advanceTimersByTimeAsync(30_000)
@@ -810,27 +743,18 @@ describe('fetchCodexRateLimits', () => {
}
})
it('runs rate-limit PTY fallback through WSL when RPC cannot read usage', async () => {
it('does not retry HTTP after RPC fails for a WSL home, since WSL already tried HTTP first', async () => {
const originalPlatform = process.platform
const originalCodexHome = process.env.CODEX_HOME
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
process.env.CODEX_HOME = 'C:\\Users\\alice\\.codex'
const rpcChild = makeRpcChild()
const ptyHandlers: { onData?: (data: string) => void } = {}
childSpawnMock.mockReturnValue(rpcChild)
ptySpawnMock.mockReturnValue({
onData: vi.fn((callback) => {
ptyHandlers.onData = callback
return makeDisposable()
}),
onExit: vi.fn(() => makeDisposable()),
write: vi.fn(),
kill: vi.fn()
})
readFileMock.mockResolvedValue(
JSON.stringify({ tokens: { access_token: 'access-token', account_id: 'account-id' } })
)
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 503 }))
try {
const resultPromise = fetchCodexRateLimits({
@@ -838,53 +762,12 @@ describe('fetchCodexRateLimits', () => {
})
await vi.advanceTimersByTimeAsync(0)
rpcChild.emit('close')
await vi.advanceTimersByTimeAsync(0)
const [spawnFile, spawnArgs, spawnOptions] = ptySpawnMock.mock.calls[0]
expect(spawnFile).toBe('wsl.exe')
expect(spawnArgs.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--exec', 'sh', '-c'])
const shellCommand = spawnArgs.at(-1) as string
expect(shellCommand).toContain('_orca_wsl_shell=$(getent passwd')
expect(shellCommand).toContain('bash|zsh|ksh|mksh|ash) exec "$_orca_wsl_shell" -ilc')
expect(shellCommand).not.toContain('exec 3<&0')
expect(shellCommand).not.toContain('exec </dev/null')
expect(shellCommand).not.toContain('exec >/dev/null')
expect(shellCommand).not.toContain('<&3 >&4 3<&- 4>&-')
expect(shellCommand).toContain('mkdir -p "$orca_rate_limit_cwd"')
expect(shellCommand).toContain('cd "$orca_rate_limit_cwd"')
expect(shellCommand).toContain(
"export CODEX_HOME='\\''/home/alice/.local/share/orca/account/home'\\''"
)
expect(shellCommand).toContain('exec codex ')
expect(shellCommand).toContain('features.plugins=false')
expect(shellCommand).not.toContain('_orca_codex')
expect(shellCommand).not.toContain('wsl-codex-path')
expect(spawnOptions).toEqual(
expect.objectContaining({
cwd: expect.stringContaining('rate-limit-pty-cwd'),
env: expect.not.objectContaining({ CODEX_HOME: expect.anything() })
})
)
const onPtyData = ptyHandlers.onData
if (!onPtyData) {
throw new Error('PTY data handler was not registered')
}
onPtyData('>')
onPtyData('5h limit: 17%\nWeekly limit: 23%\n')
await vi.advanceTimersByTimeAsync(500)
await expect(resultPromise).resolves.toMatchObject({
session: { usedPercent: 17 },
weekly: { usedPercent: 23 },
status: 'ok'
})
await expect(resultPromise).resolves.toMatchObject({ status: 'error' })
expect(fetch).toHaveBeenCalledTimes(1)
expect(childSpawnMock).toHaveBeenCalledTimes(1)
expect(ptySpawnMock).not.toHaveBeenCalled()
} finally {
if (originalCodexHome === undefined) {
delete process.env.CODEX_HOME
} else {
process.env.CODEX_HOME = originalCodexHome
}
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
+35 -78
View File
@@ -2,10 +2,7 @@ import type { CodexRateLimitResetOutcome, ProviderRateLimits } from '../../share
import { isCodexAuthError } from '../../shared/codex-auth-errors'
import { buildWslExecArgs, buildWslLoginShellCommand } from '../../shared/wsl-login-shell-command'
import { parseWslUncPath } from '../../shared/wsl-paths'
import {
CODEX_DISABLE_PLUGINS_ARGS,
CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS
} from '../codex-cli/codex-read-only-app-server-args'
import { CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS } from '../codex-cli/codex-read-only-app-server-args'
import { resolveCodexCommand } from '../codex-cli/command'
// Why: import from the shared module, not the codex-cli re-export, so a test that
// mocks '../codex-cli/command' does not have to restate this pure helper.
@@ -16,9 +13,7 @@ import {
} from '../codex-cli/codex-home-process-lock'
import { isCodexStateDbBackfillPending } from '../codex/codex-state-db'
import { startCodexStateDbBackfillRecoveryInBackground } from '../codex/codex-state-db-backfill-recovery'
import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic'
import { spawnProcess } from '../../shared/child-process/run-process'
import { getCmdExePath } from '../win32-utils'
import { probeCodexAuthPresence } from './codex-auth-presence'
import {
fetchCodexRateLimitsViaBackend,
@@ -26,7 +21,6 @@ import {
} from './codex-backend-usage-client'
import type { CodexRateLimitFetchOptions } from './codex-rate-limit-fetch-options'
import { abortedCodexRateLimitResult } from './codex-rate-limit-fetch-result'
import { fetchCodexRateLimitsViaPty } from './codex-pty-rate-limit-probe'
import { terminateCodexProbeChild } from './codex-probe-termination'
import {
consumeCodexRateLimitResetCreditFromBackend,
@@ -47,17 +41,11 @@ const WSL_RPC_TIMEOUT_MS = 25_000
const RPC_INIT_TIMEOUT_MS = 30_000
const WSL_RPC_INIT_TIMEOUT_MS = 40_000
// Keep the PTY fallback aligned with the RPC probe: rate-limit collection does
// not need marketplace/plugin startup, and those background clones can outlive
// the short-lived probe process.
const CODEX_RATE_LIMIT_PLUGIN_ARGS = CODEX_DISABLE_PLUGINS_ARGS
export type FetchCodexRateLimitsOptions = CodexRateLimitFetchOptions
function buildWslCodexCommand(
codexHomePath: string,
args: string[],
isolateRpcStdio: boolean
args: string[]
): { command: string; args: string[] } | null {
const wslInfo = parseWslUncPath(codexHomePath)
if (process.platform !== 'win32' || !wslInfo) {
@@ -67,15 +55,17 @@ function buildWslCodexCommand(
...getHiddenRateLimitWslCwdSetupCommands(),
`export CODEX_HOME=${quoteHiddenRateLimitShellValue(wslInfo.linuxPath)}`
].join(' && ')
const execSuffix = `${args.map(quoteHiddenRateLimitShellValue).join(' ')}${
isolateRpcStdio ? ' <&3 >&4 3<&- 4>&-' : ''
}`
const execSuffix = `${args.map(quoteHiddenRateLimitShellValue).join(' ')} <&3 >&4 3<&- 4>&-`
const loginShellCommand = buildWslLoginShellCommand(
[setupCommands, `exec codex ${execSuffix}`].join(' && ')
)
const command = isolateRpcStdio
? ['exec 3<&0', 'exec 4>&1', 'exec </dev/null', 'exec >/dev/null', loginShellCommand].join('\n')
: loginShellCommand
const command = [
'exec 3<&0',
'exec 4>&1',
'exec </dev/null',
'exec >/dev/null',
loginShellCommand
].join('\n')
return {
command: 'wsl.exe',
args: buildWslExecArgs(wslInfo.distro, ['sh', '-c', command])
@@ -106,7 +96,7 @@ async function fetchViaRpc(options?: CodexRateLimitFetchOptions): Promise<Provid
}
const codexArgs = [...CODEX_SHORT_LIVED_PROBE_APP_SERVER_ARGS]
const wslCodex = options?.codexHomePath
? buildWslCodexCommand(options.codexHomePath, codexArgs, true)
? buildWslCodexCommand(options.codexHomePath, codexArgs)
: null
const codexCommand = wslCodex ? 'codex' : resolveCodexCommand()
// Why the bare CLI: spawnProcess resolves an npm `codex.cmd` shim past cmd.exe itself.
@@ -130,28 +120,6 @@ async function fetchViaRpc(options?: CodexRateLimitFetchOptions): Promise<Provid
})
}
function resolvePtyCommand(options?: CodexRateLimitFetchOptions) {
const wslCodex = options?.codexHomePath
? buildWslCodexCommand(options.codexHomePath, [...CODEX_RATE_LIMIT_PLUGIN_ARGS], false)
: null
const codexCommand = wslCodex ? 'codex' : resolveCodexCommand()
const isWin32 = process.platform === 'win32'
return {
command: wslCodex ? wslCodex.command : isWin32 ? getCmdExePath() : codexCommand,
args: wslCodex
? wslCodex.args
: isWin32
? ['/d', '/c', codexCommand, ...CODEX_RATE_LIMIT_PLUGIN_ARGS]
: [...CODEX_RATE_LIMIT_PLUGIN_ARGS],
cwd: resolveHiddenRateLimitPtyCwd(),
env: withCliRuntimeOnPath(codexCommand, {
...(wslCodex ? processEnvWithoutCodexHome() : process.env),
TERM: 'xterm-256color',
...(options?.codexHomePath && !wslCodex ? { CODEX_HOME: options.codexHomePath } : {})
})
}
}
export function consumeCodexRateLimitResetCredit(options: {
codexHomePath?: string | null
idempotencyKey: string
@@ -178,19 +146,19 @@ function codexUnavailable(error: string, status: 'error' | 'unavailable'): Provi
}
}
async function fetchWslBackend(
options: CodexRateLimitFetchOptions
async function fetchBackendUsage(
options: CodexRateLimitFetchOptions | undefined
): Promise<ProviderRateLimits | null> {
try {
const result = await fetchCodexRateLimitsViaBackend(fetchCodexUsage, options)
if (options.signal?.aborted) {
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
return result
? supplementCodexRateLimitResetCredits(result, fetchCodexResetCredits, options)
: null
} catch {
return options.signal?.aborted ? abortedCodexRateLimitResult() : null
return options?.signal?.aborted ? abortedCodexRateLimitResult() : null
}
}
@@ -218,10 +186,11 @@ export async function fetchCodexRateLimits(
)
}
if (options?.codexHomePath && parseWslUncPath(options.codexHomePath)) {
const backendResult = await fetchWslBackend(options)
const isWslHome = Boolean(options?.codexHomePath && parseWslUncPath(options.codexHomePath))
if (isWslHome) {
const backendResult = await fetchBackendUsage(options)
if (backendResult) {
return options.signal?.aborted ? abortedCodexRateLimitResult() : backendResult
return options?.signal?.aborted ? abortedCodexRateLimitResult() : backendResult
}
}
@@ -233,9 +202,12 @@ export async function fetchCodexRateLimits(
)
}
const homeLockKey = resolveCodexHomeProcessLockKey(options?.codexHomePath)
let rpcFailure: ProviderRateLimits
try {
const rpcResult = await withCodexHomeProcessLock(homeLockKey, () => fetchViaRpc(options))
const rpcResult = await withCodexHomeProcessLock(
resolveCodexHomeProcessLockKey(options?.codexHomePath),
() => fetchViaRpc(options)
)
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
@@ -243,39 +215,24 @@ export async function fetchCodexRateLimits(
const supplemented = await supplementBackendMetadata(rpcResult, options)
return options?.signal?.aborted ? abortedCodexRateLimitResult() : supplemented
}
if (isCodexAuthError(rpcResult.error) || options?.allowPtyFallback === false) {
if (isCodexAuthError(rpcResult.error)) {
return rpcResult
}
rpcFailure = rpcResult
} catch {
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
if (options?.allowPtyFallback === false) {
return codexUnavailable('RPC failed', 'error')
}
rpcFailure = codexUnavailable('RPC failed', 'error')
}
try {
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
const ptyResult = await withCodexHomeProcessLock(homeLockKey, () =>
fetchCodexRateLimitsViaPty(() => resolvePtyCommand(options), options)
)
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
const supplemented = await supplementBackendMetadata(ptyResult, options)
return options?.signal?.aborted ? abortedCodexRateLimitResult() : supplemented
} catch (error) {
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
const message = error instanceof Error ? error.message : 'Unknown error'
const isNotInstalled = message.includes('ENOENT')
return codexUnavailable(
isNotInstalled ? 'Codex CLI not found' : withMacTailscaleDnsHint(message),
isNotInstalled ? 'unavailable' : 'error'
)
if (isWslHome) {
return rpcFailure
}
// Why: read usage over HTTP, never by driving the interactive Codex TUI — keystrokes sent there can accept startup dialogs such as "Update now" (#17415).
const backendResult = await fetchBackendUsage(options)
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
return backendResult ?? rpcFailure
}
@@ -1,24 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { fetchCodexRateLimitsViaPty } from './codex-pty-rate-limit-probe'
describe('Codex PTY rate-limit probe cancellation', () => {
it('does not resolve the process command after cancellation', async () => {
const controller = new AbortController()
const resolveCommand = vi.fn(() => ({
command: 'codex',
args: [],
cwd: '.',
env: {}
}))
controller.abort()
await expect(
fetchCodexRateLimitsViaPty(resolveCommand, { signal: controller.signal })
).resolves.toMatchObject({
provider: 'codex',
status: 'error',
error: 'Rate-limit fetch aborted'
})
expect(resolveCommand).not.toHaveBeenCalled()
})
})
@@ -1,236 +0,0 @@
import type { ProviderRateLimits } from '../../shared/rate-limit-types'
import { extractCodexAuthError } from '../../shared/codex-auth-errors'
import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic'
import { cleanupHiddenRateLimitPty, registerHiddenRateLimitPty } from './hidden-pty-cleanup'
import type { CodexRateLimitFetchOptions } from './codex-rate-limit-fetch-options'
import { abortedCodexRateLimitResult } from './codex-rate-limit-fetch-result'
import {
hasCodexPtyRateLimit,
parseCodexPtyStatus,
stripCodexPtyControlSequences
} from './codex-pty-status-parser'
const PTY_TIMEOUT_MS = 15_000
const PTY_STATUS_NUDGE_MS = 2_500
const PTY_STATUS_ENTER_DELAY_MS = 350
const PTY_STATUS_ENTER_RETRY_MS = 3_000
const MAX_DIAGNOSTIC_OUTPUT_LENGTH = 100_000
export type CodexPtyRateLimitCommand = {
command: string
args: string[]
cwd: string
env: NodeJS.ProcessEnv
}
export async function fetchCodexRateLimitsViaPty(
resolveCommand: () => CodexPtyRateLimitCommand,
options?: CodexRateLimitFetchOptions
): Promise<ProviderRateLimits> {
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
const pty = await import('node-pty')
if (options?.signal?.aborted) {
return abortedCodexRateLimitResult()
}
const command = resolveCommand()
return new Promise<ProviderRateLimits>((resolve) => {
let output = ''
let resolved = false
let sentStatus = false
let settleTimer: ReturnType<typeof setTimeout> | null = null
let timeout: ReturnType<typeof setTimeout> | null = null
const term = pty.spawn(command.command, command.args, {
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: command.cwd,
env: command.env
})
const termDisposables: { dispose: () => void }[] = [registerHiddenRateLimitPty(term)]
let statusEnter: ReturnType<typeof setTimeout> | null = null
let statusNudge: ReturnType<typeof setTimeout> | null = null
function sendStatusCommand(): void {
sentStatus = true
if (statusNudge) {
clearTimeout(statusNudge)
statusNudge = null
}
term.write('/status')
statusEnter = setTimeout(() => {
statusEnter = null
term.write('\r')
statusEnter = setTimeout(() => {
statusEnter = null
if (!resolved && !settleTimer) {
term.write('\r')
}
}, PTY_STATUS_ENTER_RETRY_MS)
}, PTY_STATUS_ENTER_DELAY_MS)
}
function armStatusNudge(): void {
if (statusNudge || sentStatus || resolved) {
return
}
statusNudge = setTimeout(() => {
statusNudge = null
if (!resolved && !sentStatus) {
sendStatusCommand()
}
}, PTY_STATUS_NUDGE_MS)
}
termDisposables.push({
dispose: () => {
if (statusNudge) {
clearTimeout(statusNudge)
statusNudge = null
}
if (statusEnter) {
clearTimeout(statusEnter)
statusEnter = null
}
}
})
function clearSettleTimers(): void {
if (timeout) {
clearTimeout(timeout)
timeout = null
}
if (settleTimer) {
clearTimeout(settleTimer)
settleTimer = null
}
}
function settleAborted(): void {
if (resolved) {
return
}
resolved = true
clearSettleTimers()
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
resolve(abortedCodexRateLimitResult())
}
if (options?.signal) {
if (options.signal.aborted) {
settleAborted()
return
}
options.signal.addEventListener('abort', settleAborted, { once: true })
termDisposables.push({
dispose: () => options.signal?.removeEventListener('abort', settleAborted)
})
}
timeout = setTimeout(() => {
if (!resolved) {
resolved = true
clearSettleTimers()
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
resolve({
provider: 'codex',
session: null,
weekly: null,
updatedAt: Date.now(),
error: extractCodexAuthError(output) ?? withMacTailscaleDnsHint('PTY timeout', output),
status: 'error'
})
}
}, PTY_TIMEOUT_MS)
const onDataDisposable = term.onData((data) => {
output += data
if (output.length > MAX_DIAGNOSTIC_OUTPUT_LENGTH) {
output = output.slice(-MAX_DIAGNOSTIC_OUTPUT_LENGTH)
}
const authError = extractCodexAuthError(output)
if (authError) {
resolved = true
clearSettleTimers()
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
resolve({
provider: 'codex',
session: null,
weekly: null,
updatedAt: Date.now(),
error: authError,
status: 'error'
})
return
}
armStatusNudge()
if (!sentStatus && /[>›]\s*$/.test(data)) {
sendStatusCommand()
return
}
const probe = sentStatus && !settleTimer ? stripCodexPtyControlSequences(output) : null
if (probe !== null && hasCodexPtyRateLimit(probe)) {
settleTimer = setTimeout(() => {
settleTimer = null
if (resolved) {
return
}
resolved = true
clearSettleTimers()
cleanupHiddenRateLimitPty(term, termDisposables, { kill: true })
const clean = stripCodexPtyControlSequences(output)
const { session, weekly } = parseCodexPtyStatus(clean)
resolve({
provider: 'codex',
session,
weekly,
updatedAt: Date.now(),
error:
session || weekly
? null
: withMacTailscaleDnsHint('Failed to parse CLI output', clean),
status: session || weekly ? 'ok' : 'error'
})
}, 500)
}
})
if (onDataDisposable) {
termDisposables.push(onDataDisposable)
}
const onExitDisposable = term.onExit(() => {
cleanupHiddenRateLimitPty(term, termDisposables, { kill: false })
if (settleTimer) {
clearTimeout(settleTimer)
settleTimer = null
}
if (!resolved) {
resolved = true
if (timeout) {
clearTimeout(timeout)
}
const clean = stripCodexPtyControlSequences(output)
const { session, weekly } = parseCodexPtyStatus(clean)
resolve({
provider: 'codex',
session,
weekly,
updatedAt: Date.now(),
error:
session || weekly
? null
: (extractCodexAuthError(clean) ??
withMacTailscaleDnsHint('CLI exited before status was available', clean)),
status: session || weekly ? 'ok' : 'error'
})
}
})
if (onExitDisposable) {
termDisposables.push(onExitDisposable)
}
})
}
@@ -1,19 +0,0 @@
import { describe, expect, it } from 'vitest'
import { parseCodexPtyStatus } from './codex-pty-status-parser'
describe('Codex PTY status parser', () => {
it('uses the account row orientation and reset when a model-scoped weekly row renders first', () => {
const result = parseCodexPtyStatus(
'GPT-5.3-Codex-Spark Weekly limit: 100% left (resets in 1d 2h)\n' +
'Weekly limit: 43% left (resets in 5d 3h)\n'
)
expect(result).toMatchObject({
session: null,
weekly: {
usedPercent: 57,
resetDescription: '5d 3h'
}
})
})
})
@@ -1,62 +0,0 @@
import type { RateLimitWindow } from '../../shared/rate-limit-types'
import { extractClaudePtyResetMetadata } from './claude-pty-reset-parser'
// Why: reject model-scoped rows regardless of row order in cursor-positioned output.
const FIVE_HOUR_RE = /(?<![\w-][^\S\r\n]{0,4})5h\s+limit[^\d%\r\n]*(\d+)%(?:\s*(used|left))?/i
const WEEKLY_RE = /(?<![\w-][^\S\r\n]{0,4})weekly\s+limit[^\d%\r\n]*(\d+)%(?:\s*(used|left))?/i
const ANY_LIMIT_LABEL_RE = /(?:5h|weekly)\s+limit/i
// eslint-disable-next-line no-control-regex
const PTY_CONTROL_SEQUENCE_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g
export function stripCodexPtyControlSequences(output: string): string {
return output.replace(PTY_CONTROL_SEQUENCE_RE, '')
}
export function hasCodexPtyRateLimit(output: string): boolean {
return FIVE_HOUR_RE.test(output) || WEEKLY_RE.test(output)
}
function ptyUsedPercent(match: RegExpExecArray): number {
const pct = Number.parseInt(match[1], 10)
const oriented = match[2]?.toLowerCase() === 'left' ? 100 - pct : pct
return Math.min(100, Math.max(0, oriented))
}
export function parseCodexPtyStatus(output: string): {
session: RateLimitWindow | null
weekly: RateLimitWindow | null
} {
const fiveMatch = FIVE_HOUR_RE.exec(output)
const weeklyMatch = WEEKLY_RE.exec(output)
const lines = output.split(/\r\n|\n|\r/)
const isLimitLabel = (line: string): boolean => ANY_LIMIT_LABEL_RE.test(line)
const sessionReset = extractClaudePtyResetMetadata(
lines,
(line) => FIVE_HOUR_RE.test(line),
isLimitLabel
)
const weeklyReset = extractClaudePtyResetMetadata(
lines,
(line) => WEEKLY_RE.test(line),
isLimitLabel
)
return {
session: fiveMatch
? {
usedPercent: ptyUsedPercent(fiveMatch),
windowMinutes: 300,
resetsAt: sessionReset.resetsAt,
resetDescription: sessionReset.resetDescription
}
: null,
weekly: weeklyMatch
? {
usedPercent: ptyUsedPercent(weeklyMatch),
windowMinutes: 10080,
resetsAt: weeklyReset.resetsAt,
resetDescription: weeklyReset.resetDescription
}
: null
}
}
@@ -1,5 +1,4 @@
export type CodexRateLimitFetchOptions = {
codexHomePath?: string | null
allowPtyFallback?: boolean
signal?: AbortSignal
}
@@ -144,7 +144,6 @@ describe('RateLimitService', () => {
expect(fetchCodexRateLimits).toHaveBeenCalledWith(
expect.objectContaining({
codexHomePath: wslCodexHome,
allowPtyFallback: false,
signal: expect.any(AbortSignal)
})
)
@@ -127,7 +127,6 @@ export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResul
try {
fresh = await fetchCodexRateLimits({
codexHomePath,
allowPtyFallback: this.shouldAllowCodexPtyFallback(),
signal: controller.signal
})
} catch (error) {
@@ -172,11 +171,6 @@ export abstract class RateLimitServiceFetchTargets extends RateLimitServiceResul
return { ...stateBeforeReset, codex: scopedCodex, codexTarget: target }
}
protected shouldAllowCodexPtyFallback(): boolean {
// Why: hidden PTY fallback can crash inside ConPTY on Windows; prefer RPC-only degradation there for background quota refresh.
return process.platform !== 'win32'
}
protected shouldAllowClaudePtyFallback(
authPreparation: ClaudeRuntimeAuthPreparation | undefined
): boolean {
@@ -183,7 +183,6 @@ export abstract class RateLimitServiceFullCyclePreparation extends RateLimitServ
: (missingWslCodexHome ??
fetchCodexRateLimits({
codexHomePath,
allowPtyFallback: this.shouldAllowCodexPtyFallback(),
signal
})),
fetchGeminiRateLimits(geminiCliOAuthEnabled),
@@ -97,7 +97,7 @@ export abstract class RateLimitServiceInactiveAccounts extends RateLimitServiceP
if (accounts.length === 0) {
return
}
// Why: account switching can activate a previewed account while its RPC-only fetch is still in flight; ignore stale results.
// Why: account switching can activate a previewed account while its usage fetch is still in flight; ignore stale results.
const fetchGeneration = this.inactiveCodexAccountsGeneration
const controller = this.beginFetchCycle()
const signal = controller.signal
@@ -143,10 +143,8 @@ export abstract class RateLimitServiceInactiveAccounts extends RateLimitServiceP
this.pushToRenderer()
try {
// Why: point fetchCodexRateLimits at the managed home directly, avoiding materializing credentials into the shared runtime location.
// Why: no PTY fallback — the switcher preview shouldn't spawn hidden PTYs per account (can crash ConPTY on Windows); RPC-only is enough.
const fresh = await fetchCodexRateLimits({
codexHomePath: home.managedHomePath,
allowPtyFallback: false,
signal
})
if (
@@ -40,7 +40,6 @@ export abstract class RateLimitServiceProviderCycles extends RateLimitServiceFul
? Promise.resolve(missingWslCodexHome)
: fetchCodexRateLimits({
codexHomePath,
allowPtyFallback: this.shouldAllowCodexPtyFallback(),
signal
})
).catch((err): ProviderRateLimits => ({
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-23T21:11:11.946Z",
"platform": "linux",
"command": ["hermes", "--yolo", "--tui"],
"cols": 120,
"rows": 31,
"note": "Hermes Agent v0.21.4 on headless Linux, ready without a submitted prompt; command path, model label, tool inventory, and session marker anonymized.",
"exitCode": 0
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,128 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness'
import { createHookListenerState } from '../../shared/agent-hook-listener/listener-state'
import { normalizeHermesEvent } from '../../shared/agent-hook-listener/providers/hermes-events'
vi.mock('electron', () => ({
BrowserWindow: { fromId: vi.fn(() => null) },
webContents: { fromId: vi.fn(() => null) },
ipcMain: { on: vi.fn(), removeListener: vi.fn() },
app: { getPath: vi.fn(() => '/tmp') }
}))
const captured = readFileSync(join(__dirname, '__fixtures__', 'hermes-tui-ready.txt'), 'utf8')
/** The real payload Orca's managed Hermes plugin produces for `eventName`, wired through the
* same normalizer the hook relay uses — so a regression in the event mapping fails here too,
* not only in the provider's own unit test. */
const status = (eventName: string, payload: Record<string, unknown> = {}): string => {
const parsed = normalizeHermesEvent(createHookListenerState(), eventName, '', 'pane-1', payload)
expect(parsed, `hermes plugin event ${eventName} must normalize to a status`).not.toBeNull()
return `\x1b]9999;${JSON.stringify(parsed)}\x07`
}
const readyPane = async () =>
createTranscriptPane({
paneTitle: 'Hermes Agent',
foregroundProcess: 'hermes',
launchAgent: 'hermes',
size: { cols: 120, rows: 31 },
data: captured
})
describe('Hermes TUI readiness from a captured PTY', () => {
it('settles tui-idle on the session-boundary row a freshly launched Hermes emits', async () => {
const { runtime, handle } = await readyPane()
// What Orca's own managed plugin sends for `on_session_start`.
runtime.onPtyData(
TRANSCRIPT_PANE_PTY_ID,
status('on_session_start', { session_id: 's1' }),
Date.now()
)
const read = vi.spyOn(runtime, 'readTerminal')
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 })
).resolves.toMatchObject({ satisfied: true })
// The boundary row is tier-1 evidence, so no pane's screen is serialized to settle it.
expect(read.mock.calls.filter(([, options]) => options?.screen === true)).toEqual([])
}, 5_000)
it('settles through a leaf handle whose retained tail never showed the ready screen', async () => {
const { runtime } = await readyPane()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Access the real synced leaf and handle issuer for this route regression.
const internals = runtime as unknown as {
leaves: Map<string, { tailBuffer: string[]; tailPartialLine: string }>
issueHandle: (leaf: unknown) => string
}
const leaf = internals.leaves.values().next().value
expect(leaf).toBeDefined()
expect([...leaf!.tailBuffer, leaf!.tailPartialLine].join('\n')).not.toMatch(/\bready\b/i)
runtime.onPtyData(
TRANSCRIPT_PANE_PTY_ID,
status('on_session_start', { session_id: 's1' }),
Date.now()
)
await expect(
runtime.waitForTerminal(internals.issueHandle(leaf), {
condition: 'tui-idle',
timeoutMs: 2_000
})
).resolves.toMatchObject({ satisfied: true })
}, 5_000)
it('does not settle while a turn the user started is still running', async () => {
const { runtime, handle } = await readyPane()
runtime.onPtyData(
TRANSCRIPT_PANE_PTY_ID,
status('on_session_start', { session_id: 's1' }),
Date.now()
)
// `pre_llm_call` — the first event that means a turn actually began.
runtime.onPtyData(
TRANSCRIPT_PANE_PTY_ID,
status('pre_llm_call', { user_message: 'hi' }),
Date.now()
)
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 })
).rejects.toThrow('timeout')
}, 3_000)
it('does not settle on a turn-end done row, only on a session boundary', async () => {
const { runtime, handle } = await readyPane()
// `post_llm_call` lands `done` without the boundary flag; the #6011 rule still applies.
runtime.onPtyData(
TRANSCRIPT_PANE_PTY_ID,
status('post_llm_call', { session_id: 's1' }),
Date.now()
)
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 })
).rejects.toThrow('timeout')
}, 3_000)
it('reports a visible blocker instead of readiness when Hermes asks for approval', async () => {
const { runtime, handle } = await readyPane()
runtime.onPtyData(
TRANSCRIPT_PANE_PTY_ID,
status('on_session_start', { session_id: 's1' }),
Date.now()
)
// `pre_approval_request` supersedes the boundary row the session opened with.
runtime.onPtyData(
TRANSCRIPT_PANE_PTY_ID,
status('pre_approval_request', { command: 'rm -rf build' }),
Date.now()
)
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 550 })
).rejects.toThrow('timeout')
}, 3_000)
})
@@ -184,7 +184,11 @@ export class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntime
pty.launchAgent
)
if (payload) {
pty.lastExplicitAgentStatus = { state: payload.state, updatedAt: Date.now() }
pty.lastExplicitAgentStatus = {
state: payload.state,
updatedAt: Date.now(),
sessionBoundary: payload.sessionBoundary
}
this.emitTerminalAgentStatusEvents(ptyId, {
cleanData: '',
payloads: [payload],
+2 -1
View File
@@ -232,7 +232,8 @@ export class OrcaRuntimeWithOnPtyData extends OrcaRuntimeWithPreparePtyExecution
if (ptyRecord) {
ptyRecord.lastExplicitAgentStatus = {
state: latestAgentStatus.state,
updatedAt: Date.now()
updatedAt: Date.now(),
sessionBoundary: latestAgentStatus.sessionBoundary
}
}
}
@@ -76,7 +76,12 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & {
/** Latest first-party state from the agent's own OSC 9999 status stream — what the
* agent SAYS it is doing, as opposed to `lastAgentStatus`, which is inferred from its
* OSC title. Optional: absent until a payload lands. */
lastExplicitAgentStatus?: { state: AgentStatusState; updatedAt: number } | null
lastExplicitAgentStatus?: {
state: AgentStatusState
updatedAt: number
/** A `done` row that marks a new session owning the pane, not the end of a turn. */
sessionBoundary?: boolean
} | null
lastAgentStatusStartedAtEpochMs: number | null
lastAgentStatusRichInvalidatedAtEpochMs: number | null
lastOscTitle: string | null
+11 -2
View File
@@ -53,7 +53,11 @@ export type TuiIdleEvidenceRecord = {
lastOscTitle?: string | null
}
export type FirstPartyAgentStatus = { state: AgentStatusState; updatedAt: number } | null
export type FirstPartyAgentStatus = {
state: AgentStatusState
updatedAt: number
sessionBoundary?: boolean
} | null
/** Tier 1: an idle marker the agent put in a title itself. */
export function hasExplicitIdleTitle(
@@ -88,13 +92,18 @@ export function hasExplicitIdleTitle(
* Scoped rather than general: for agents whose hooks do report child turns, a `done` row
* can arrive mid-turn, and settling on it is exactly the #6011 class this file exists to
* prevent.
*
* The second lane is narrower and agent-agnostic: a `sessionBoundary` row does not claim a
* turn ended, it claims a NEW SESSION owns the pane and is waiting for its first input. That
* cannot arrive mid-turn by construction — the producers only set it for a startup/resume/
* reset boundary — so it carries no #6011 risk for any agent that emits it.
*/
export function hasFreshDoneFirstPartyStatus(
agent: TuiAgent | null | undefined,
status: FirstPartyAgentStatus,
staleAfterMs = AGENT_STATUS_STALE_AFTER_MS
): boolean {
if (agent !== 'dsh' || status?.state !== 'done') {
if (status?.state !== 'done' || (agent !== 'dsh' && status.sessionBoundary !== true)) {
return false
}
return Date.now() - status.updatedAt <= staleAfterMs
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import { readClipboardCopiedFilePaths } from './clipboard-copied-file-paths'
function clipboardWith(formats: Record<string, Buffer | string>) {
return {
readBuffer: (format: string): Buffer => {
const value = formats[format]
return typeof value === 'string' ? Buffer.from(value, 'utf8') : (value ?? Buffer.alloc(0))
}
}
}
function filenamesPlist(paths: string[]): string {
const entries = paths.map((path) => `<string>${path}</string>`).join('')
return `<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><array>${entries}</array></plist>`
}
describe('readClipboardCopiedFilePaths', () => {
it('lists every file Finder copied, decoding XML entities', () => {
const clipboard = clipboardWith({
NSFilenamesPboardType: filenamesPlist(['/Users/me/Q&amp;A shot.png', '/Users/me/b.pdf']),
'public.file-url': 'file:///Users/me/Q&A%20shot.png'
})
expect(readClipboardCopiedFilePaths(clipboard, 'darwin')).toEqual([
'/Users/me/Q&A shot.png',
'/Users/me/b.pdf'
])
})
it('falls back to the first file URL on macOS, but not a file-reference URL', () => {
expect(
readClipboardCopiedFilePaths(
clipboardWith({ 'public.file-url': 'file:///Users/me/my%20shot.png' }),
'darwin'
)
).toEqual(['/Users/me/my shot.png'])
expect(
readClipboardCopiedFilePaths(
clipboardWith({ 'public.file-url': 'file:///.file/id=6571367.2773272' }),
'darwin'
)
).toEqual([])
})
it('reads a Linux file manager uri-list and rejects non-file entries', () => {
expect(
readClipboardCopiedFilePaths(
clipboardWith({
'text/uri-list': '# copied\r\nfile:///home/me/a.png\r\nfile:///home/me/b%20c.txt\r\n'
}),
'linux'
)
).toEqual(['/home/me/a.png', '/home/me/b c.txt'])
expect(
readClipboardCopiedFilePaths(
clipboardWith({ 'text/uri-list': 'file:///home/me/a.png\nhttps://example.com/x' }),
'linux'
)
).toEqual([])
})
it('reads the single file Explorer copied and nothing when it copied several', () => {
const shellItems = (count: number): Buffer => {
const cida = Buffer.alloc(4 + 4 * (count + 1))
cida.writeUInt32LE(count)
return cida
}
const explorer = (count: number) =>
clipboardWith({
FileNameW: Buffer.from('C:\\Users\\me\\shot.png\0', 'utf16le'),
'Shell IDList Array': shellItems(count)
})
expect(readClipboardCopiedFilePaths(explorer(1), 'win32')).toEqual(['C:\\Users\\me\\shot.png'])
expect(readClipboardCopiedFilePaths(explorer(2), 'win32')).toEqual([])
})
it('returns nothing for plain text, oversized lists, or a failing clipboard', () => {
expect(readClipboardCopiedFilePaths(clipboardWith({}), 'darwin')).toEqual([])
const huge = filenamesPlist(['/a'.padEnd(300 * 1024, 'a')])
expect(
readClipboardCopiedFilePaths(clipboardWith({ NSFilenamesPboardType: huge }), 'darwin')
).toEqual([])
const failing = {
readBuffer: (): Buffer => {
throw new Error('format unavailable')
}
}
expect(readClipboardCopiedFilePaths(failing, 'linux')).toEqual([])
})
})
@@ -0,0 +1,89 @@
import { fileURLToPath } from 'node:url'
import { readWindowsCopiedFilePath } from './clipboard-windows-image-file'
type ClipboardFormatReader = { readBuffer: (format: string) => Buffer }
const FILE_LIST_MAX_BYTES = 256 * 1024
const XML_ENTITIES: Record<string, string> = {
amp: '&',
apos: "'",
gt: '>',
lt: '<',
quot: '"'
}
function readBoundedText(clipboard: ClipboardFormatReader, format: string): string {
const buffer = clipboard.readBuffer(format)
return buffer.byteLength <= FILE_LIST_MAX_BYTES ? buffer.toString('utf8') : ''
}
function decodeXmlText(value: string): string {
return value.replace(
/&(?:#(\d+)|#x([0-9a-fA-F]+)|(amp|apos|gt|lt|quot));/g,
(_entity, decimal: string | undefined, hex: string | undefined, name: string | undefined) =>
decimal
? String.fromCodePoint(Number(decimal))
: hex
? String.fromCodePoint(Number.parseInt(hex, 16))
: XML_ENTITIES[name ?? '']
)
}
/** macOS/Linux file URLs; any other entry means this is not a file copy. */
function filePathsFromUrls(urls: readonly string[]): string[] {
const paths: string[] = []
for (const url of urls) {
// Finder can hand out file-reference URLs (/.file/id=…), which name no file.
if (!url.startsWith('file://') || url.startsWith('file:///.file/id=')) {
return []
}
paths.push(fileURLToPath(url, { windows: false }))
}
return paths
}
function readMacCopiedFilePaths(clipboard: ClipboardFormatReader): string[] {
// Finder's legacy filenames plist lists every copied file; public.file-url holds only the first.
const plist = readBoundedText(clipboard, 'NSFilenamesPboardType')
const listed = Array.from(plist.matchAll(/<string>([^<]*)<\/string>/g), (match) =>
decodeXmlText(match[1])
)
if (listed.length > 0) {
return listed
}
const url = readBoundedText(clipboard, 'public.file-url').trim()
return url ? filePathsFromUrls([url]) : []
}
function readLinuxCopiedFilePaths(clipboard: ClipboardFormatReader): string[] {
const urls = readBoundedText(clipboard, 'text/uri-list')
.split(/\r\n|\r|\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'))
return filePathsFromUrls(urls)
}
/**
* Paths of the files a file manager copied, so a paste can tell the text that
* labels them from prompt text. A list it cannot read in full comes back empty.
*/
export function readClipboardCopiedFilePaths(
clipboard: ClipboardFormatReader,
platform: NodeJS.Platform = process.platform
): string[] {
try {
if (platform === 'darwin') {
return readMacCopiedFilePaths(clipboard)
}
if (platform === 'win32') {
const filePath = readWindowsCopiedFilePath({
fileNameW: clipboard.readBuffer('FileNameW'),
shellIdListArray: clipboard.readBuffer('Shell IDList Array')
})
return filePath ? [filePath] : []
}
return readLinuxCopiedFilePaths(clipboard)
} catch {
return []
}
}
@@ -106,6 +106,9 @@ describe('dashboard popout clipboard access', () => {
await expect(handlers.get('clipboard:saveImageAsTempFile')?.(popoutEvent)).rejects.toThrow(
'Unauthorized clipboard IPC sender'
)
expect(() => handlers.get('clipboard:readFilePaths')?.(popoutEvent)).toThrow(
'Unauthorized clipboard IPC sender'
)
expect(() =>
handlers.get('clipboard:writeFile')?.(popoutEvent, {
filePath: '/tmp/copied-file.txt',
@@ -26,6 +26,7 @@ const {
clipboardReadBufferMock,
clipboardWriteTextMock,
clipboardReadImageMock,
clipboardAvailableFormatsMock,
clipboardWriteImageMock,
clipboardWriteBufferMock,
nativeImageCreateFromBufferMock,
@@ -62,6 +63,7 @@ const {
clipboardReadBufferMock: vi.fn(),
clipboardWriteTextMock: vi.fn(),
clipboardReadImageMock: vi.fn(),
clipboardAvailableFormatsMock: vi.fn(),
clipboardWriteImageMock: vi.fn(),
clipboardWriteBufferMock: vi.fn(),
nativeImageCreateFromBufferMock: vi.fn(),
@@ -109,6 +111,7 @@ vi.mock('electron', () => ({
readBuffer: clipboardReadBufferMock,
writeText: clipboardWriteTextMock,
readImage: clipboardReadImageMock,
availableFormats: clipboardAvailableFormatsMock,
writeImage: clipboardWriteImageMock,
writeBuffer: clipboardWriteBufferMock
},
@@ -552,6 +555,8 @@ describe('registerClipboardHandlers', () => {
expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:writeFile')
expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:saveImageAsTempFile')
expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:readImageThumbnail')
expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:hasImage')
expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:readFilePaths')
})
it('does not inspect FileNameW when an empty image clipboard is read outside Windows', async () => {
@@ -874,4 +879,18 @@ describe('registerClipboardHandlers', () => {
expect(nativeImageCreateFromBufferMock).toHaveBeenCalled()
expect(clipboardWriteImageMock).not.toHaveBeenCalled()
})
it.each([
[['text/plain'], false],
[['text/plain', 'image/png'], true]
])('reports image presence for %j from the format list without decoding', (formats, expected) => {
setTrustedClipboardRendererWebContentsId(17)
clipboardAvailableFormatsMock.mockReturnValue(formats)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registering handlers never reads the store for clipboard image presence.
registerClipboardHandlers({} as never)
const probe = getRegisteredHandlers().get('clipboard:hasImage')
expect(probe?.(makeClipboardEvent())).toBe(expected)
expect(clipboardReadImageMock).not.toHaveBeenCalled()
expect(() => probe?.(makeClipboardEvent({ id: 42 }))).toThrow()
})
})
+13
View File
@@ -24,6 +24,7 @@ import {
assertClipboardImageBase64LengthWithinLimit,
assertClipboardImageByteLengthWithinLimit,
assertClipboardImageDimensionsWithinLimit,
clipboardFormatsIncludeImage,
type ClipboardImageThumbnail
} from '../../shared/clipboard-image'
import {
@@ -38,6 +39,7 @@ import {
} from './clipboard-remote-file-copy'
import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload'
import { readWindowsClipboardImageFileAsPng } from './clipboard-windows-image-file'
import { readClipboardCopiedFilePaths } from './clipboard-copied-file-paths'
import { buildClipboardImageThumbnail } from './clipboard-image-thumbnail'
import { writeClipboardTextAndVerify } from './clipboard-text-write-verify'
import { isDashboardPopoutRenderer } from './dashboard-popout-window'
@@ -96,6 +98,8 @@ export function registerClipboardHandlers(store: Store): void {
ipcMain.removeHandler('clipboard:writeFile')
ipcMain.removeHandler('clipboard:saveImageAsTempFile')
ipcMain.removeHandler('clipboard:readImageThumbnail')
ipcMain.removeHandler('clipboard:hasImage')
ipcMain.removeHandler('clipboard:readFilePaths')
void cleanupExpiredRemoteClipboardFiles()
scheduleLegacyRemoteClipboardFileCleanup()
@@ -117,6 +121,15 @@ export function registerClipboardHandlers(store: Store): void {
assertTrustedClipboardSender(event)
return buildClipboardImageThumbnail(clipboard.readImage())
})
ipcMain.handle('clipboard:hasImage', (event): boolean => {
assertTrustedClipboardSender(event)
return clipboardFormatsIncludeImage(clipboard.availableFormats())
})
// Why: a file-manager copy also carries the files' names as text, which a paste must not type.
ipcMain.handle('clipboard:readFilePaths', (event): string[] => {
assertTrustedClipboardSender(event)
return readClipboardCopiedFilePaths(clipboard)
})
// Why: terminals need to detect clipboard images to support tools like Claude
// Code that accept image input via paste. Writes the clipboard image to a
// temp file and returns the path, or null if the clipboard has no image.
@@ -13,7 +13,7 @@ type WindowsClipboardImageFileDeps = {
openFile: (filePath: string) => Promise<ClipboardImageFileHandle>
}
type WindowsClipboardImageFileFormats = {
export type WindowsClipboardFileFormats = {
fileNameW: Buffer
shellIdListArray: Buffer
}
@@ -65,7 +65,7 @@ function decodeFileNameW(value: Buffer): string | null {
if (!filePath || filePath.includes('\0') || !isFullyQualifiedWindowsPath(filePath)) {
return null
}
return IMAGE_FILE_EXTENSION_SET.has(win32.extname(filePath).toLowerCase()) ? filePath : null
return filePath
}
function hasAtMostOneShellItem(value: Buffer): boolean {
@@ -76,6 +76,14 @@ function hasAtMostOneShellItem(value: Buffer): boolean {
return value.byteLength >= 12 && value.readUInt32LE(0) === 1
}
/** The one file Explorer copied; null when it copied none or several. */
export function readWindowsCopiedFilePath({
fileNameW,
shellIdListArray
}: WindowsClipboardFileFormats): string | null {
return hasAtMostOneShellItem(shellIdListArray) ? decodeFileNameW(fileNameW) : null
}
function readPngDimensions(source: Buffer): { height: number; width: number } | null {
if (
source.byteLength < 24 ||
@@ -153,14 +161,11 @@ async function readStableFile(
}
export async function readWindowsClipboardImageFileAsPng(
{ fileNameW, shellIdListArray }: WindowsClipboardImageFileFormats,
formats: WindowsClipboardFileFormats,
{ createImageFromBuffer, openFile }: WindowsClipboardImageFileDeps
): Promise<Buffer | null> {
if (!hasAtMostOneShellItem(shellIdListArray)) {
return null
}
const filePath = decodeFileNameW(fileNameW)
if (!filePath) {
const filePath = readWindowsCopiedFilePath(formats)
if (!filePath || !IMAGE_FILE_EXTENSION_SET.has(win32.extname(filePath).toLowerCase())) {
return null
}
@@ -91,6 +91,8 @@ export const uiClipboardAndWindowControlsApi = {
connectionId?: string | null
runtimeEnvironmentId?: string | null
}): Promise<string | null> => ipcRenderer.invoke('clipboard:saveImageAsTempFile', args),
clipboardHasImage: (): Promise<boolean> => ipcRenderer.invoke('clipboard:hasImage'),
readClipboardFilePaths: (): Promise<string[]> => ipcRenderer.invoke('clipboard:readFilePaths'),
readClipboardImageThumbnail: (): Promise<ClipboardImageThumbnail | null> =>
ipcRenderer.invoke('clipboard:readImageThumbnail'),
writeClipboardText: (text: string): Promise<void> =>
+3
View File
@@ -13,6 +13,9 @@ export type UiWindowApi = {
connectionId?: string | null
runtimeEnvironmentId?: string | null
}) => Promise<string | null>
clipboardHasImage: () => Promise<boolean | null>
/** Paths of files a file manager copied; empty when there are none or the host cannot list them. */
readClipboardFilePaths: () => Promise<string[]>
readClipboardImageThumbnail: () => Promise<ClipboardImageThumbnail | null>
writeClipboardText: (text: string) => Promise<void>
writeTerminalClipboardText: (text: string) => Promise<void>
@@ -11,20 +11,4 @@ describe('collectBrowserPageIds identity', () => {
expect(collectBrowserPageIds([])).toBe(fromUndefined)
expect(fromUndefined).toEqual([])
})
it('still collects page ids, preferring pageIds over the active page', () => {
const ids = collectBrowserPageIds([
{ id: 'tab-1', pageIds: ['page-a', 'page-b'] },
{ id: 'tab-2', activePageId: 'page-c' },
{ id: 'tab-3' }
])
expect(ids).toEqual(['page-a', 'page-b', 'page-c', 'tab-3'])
})
it('falls back to the active page when pageIds is present but empty', () => {
expect(collectBrowserPageIds([{ id: 'tab-1', pageIds: [], activePageId: 'page-a' }])).toEqual([
'page-a'
])
})
})
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { isEditableKeyboardTarget } from './browser-keyboard'
// Why: the old fakes passed a single joined selector to `closest`, so any
@@ -35,25 +35,6 @@ describe('isEditableKeyboardTarget', () => {
expect(isEditableKeyboardTarget(targetInside(html))).toBe(true)
})
it('queries every editable host in one selector', () => {
const closest = vi.fn((_selector: string) => null)
isEditableKeyboardTarget({ isContentEditable: false, closest })
const selector = closest.mock.calls[0][0]
const tokens = selector.split(',').map((part) => part.trim())
expect(tokens).toEqual([
'input',
'textarea',
'select',
'[contenteditable=""]',
'[contenteditable="true"]',
'.monaco-editor',
'.diff-editor',
'.rich-markdown-editor',
'.rich-markdown-editor-shell'
])
})
it('falls back to isContentEditable when no host selector matches', () => {
expect(isEditableKeyboardTarget({ isContentEditable: true, closest: () => null })).toBe(true)
})
@@ -17,68 +17,4 @@ describe('getRemoteBrowserFrameStyle', () => {
objectPosition: 'top left'
})
})
it('keeps correctly sized frames filling the viewport', () => {
expect(
getRemoteBrowserFrameStyle({
imageWidth: 958,
imageHeight: 609,
deviceWidth: 958,
deviceHeight: 609
})
).toEqual({
width: '100%',
height: '100%',
objectFit: 'fill',
objectPosition: 'top left'
})
})
it('does not crop high-DPI frames with a uniform device scale', () => {
expect(
getRemoteBrowserFrameStyle({
imageWidth: 1998,
imageHeight: 1218,
deviceWidth: 999,
deviceHeight: 609
})
).toEqual({
width: '100%',
height: '100%',
objectFit: 'fill',
objectPosition: 'top left'
})
})
it('does not crop slightly uneven high-DPI frames after navigation', () => {
expect(
getRemoteBrowserFrameStyle({
imageWidth: 3278,
imageHeight: 2070,
deviceWidth: 999,
deviceHeight: 609
})
).toEqual({
width: '100%',
height: '100%',
objectFit: 'fill',
objectPosition: 'top left'
})
})
it('does not shrink malformed frame metadata below the viewport', () => {
expect(
getRemoteBrowserFrameStyle({
imageWidth: 10,
imageHeight: 10,
deviceWidth: 958,
deviceHeight: 609
})
).toEqual({
width: '100%',
height: '100%',
objectFit: 'fill',
objectPosition: 'top left'
})
})
})
@@ -34,20 +34,6 @@ function trimEnd(s: string): string {
return s.trimEnd()
}
function shouldSyncPropIntoEditor(
currentMarkdown: string,
propContent: string,
lastCommittedMarkdown: string
): boolean {
if (propContent === lastCommittedMarkdown) {
return false
}
if (currentMarkdown === propContent) {
return false
}
return true
}
/**
* Simulates the onCreate flow: empty-list repair then getMarkdown().
*/
@@ -134,27 +120,6 @@ describe('document soft-break round-trip', () => {
})
})
// -----------------------------------------------------------------------
// 3. Rich editor content sync must ignore its own mount-time round-trip
// differences, but still accept genuine external file changes.
// -----------------------------------------------------------------------
describe('content sync gating', () => {
it('does not re-sync on mount when only the normalized markdown differs', () => {
const disk = 'Line one\nLine two'
const normalizedMarkdown = simulateOnCreate(disk)
expect(shouldSyncPropIntoEditor(normalizedMarkdown, disk, disk)).toBe(false)
})
it('does re-sync when disk content actually changes externally', () => {
const oldDisk = 'Line one\nLine two'
const newDisk = 'Line one\nLine two\nLine three'
const normalizedCurrentMarkdown = simulateOnCreate(oldDisk)
expect(shouldSyncPropIntoEditor(normalizedCurrentMarkdown, newDisk, oldDisk)).toBe(true)
})
})
// -----------------------------------------------------------------------
// 4. Actual user edits must still be detected as dirty.
// -----------------------------------------------------------------------
@@ -44,13 +44,4 @@ describe('shouldIgnoreMonacoContentChange', () => {
})
).toBe(false)
})
it('does not ignore a user edit that happens to match the saved prop content', () => {
expect(
shouldIgnoreMonacoContentChange({
filePath: '/repo/seed.spec.ts',
isApplyingProgrammaticContent: false
})
).toBe(false)
})
})
@@ -169,7 +169,7 @@ describe('useEditorPanelFileLoadRetry — owner-not-ready bounding (#6648)', ()
expect(attemptsRef.current[file.id]).toBe(1)
})
it('stops after the budget and shows a truthful terminal message, then Retry re-arms', () => {
it('stops after the budget and shows a truthful terminal message', () => {
const file = makeFile()
const attemptsRef = { current: {} as Record<string, number> }
// The owner never hydrates: every retry re-fails with owner-not-ready.
@@ -227,10 +227,6 @@ describe('useEditorPanelFileLoadRetry — owner-not-ready bounding (#6648)', ()
)
})
expect(loadFileContent.mock.calls.length).toBe(callsAfterTerminal)
// Retry (reloadContent) clears the attempt budget for a fresh start.
delete attemptsRef.current[file.id]
expect(attemptsRef.current[file.id]).toBeUndefined()
})
it('stops immediately once the read succeeds (no terminal message)', () => {
@@ -1,5 +1,5 @@
import type { NativeChatComposerInput } from './native-chat-composer-input'
import { forwardRef, useCallback, useImperativeHandle, useState } from 'react'
import { forwardRef, useCallback, useState } from 'react'
import { useAppStore } from '../../store'
import { useNativeChatComposerInterrupt } from './use-native-chat-composer-interrupt'
import { useNativeChatContextUsageSummary } from './use-native-chat-context-usage-summary'
@@ -14,7 +14,7 @@ import { useNativeChatLaunchDraftAdoption } from './use-native-chat-launch-draft
import { NativeChatComposerField } from './NativeChatComposerField'
import type { NativeChatResolvedTarget } from './native-chat-composer-target'
import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments'
import { useNativeChatComposerPaste } from './use-native-chat-composer-paste'
import { useNativeChatComposerHandle } from './use-native-chat-composer-handle'
import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments'
import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown'
import { useNativeChatSendLifecycle } from './use-native-chat-send-lifecycle'
@@ -25,7 +25,6 @@ import { useNativeChatSessionOptionCommand } from './use-native-chat-session-opt
import { useNativeChatComposerCatalog } from './use-native-chat-composer-catalog'
import { useNativeChatPickerState } from './use-native-chat-picker-state'
import { useNativeChatPickerCommandDispatch } from './use-native-chat-picker-command-dispatch'
import { useNativeChatTypedInsertion } from './use-native-chat-typed-insertion'
import type {
NativeChatComposerHandle,
NativeChatComposerProps
@@ -61,6 +60,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
isWorking = false,
onStop,
onOptimisticSend,
optimisticSendOutcome,
onOptimisticSendCanceled,
onSlashCommand,
onSwitchToTerminal,
@@ -189,16 +189,6 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
? !hasPty || !onStop
: disabled || hasPendingAttachment || (draft.trim() === '' && imageAttachments.length === 0)
const { insertTypedText, focus } = useNativeChatTypedInsertion({
textareaRef,
caret,
draft,
setDraft,
setCaret,
setHistory,
setActiveSuggestion
})
const { attachExternalPaths, resolveAttachmentOwner } = useNativeChatExternalAttachments({
terminalTabId,
structuredWorktreeId: structuredTransport?.worktreeId,
@@ -207,26 +197,31 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
setNotice
})
const { handlePaste, pasteFromClipboard } = useNativeChatComposerPaste({
const handlePasteEvent = useNativeChatComposerHandle(ref, {
textareaRef,
caret,
draft,
setDraft,
setCaret,
setHistory,
setActiveSuggestion,
targetKey: JSON.stringify([
paneKey,
targetPtyId,
structuredTransport?.sessionId,
structuredTransport?.worktreeId,
structuredTransport?.runtimeEnvironmentId
]),
agent,
disabled,
caret,
resolveAttachmentOwner,
attachResolvedPaths,
beginPendingImageAttachment,
resolvePendingImageAttachment,
dropPendingImageAttachment,
insertTypedText,
setCaret,
setNotice
})
useImperativeHandle(
ref,
() => ({ focus, insertTypedText, handlePasteEvent: handlePaste, pasteFromClipboard }),
[focus, insertTypedText, handlePaste, pasteFromClipboard]
)
const { pickAttachment } = useNativeChatFileAttachmentActions(paneKey, attachExternalPaths)
const { toggleDictation, startHoldDictation, stopHoldDictation } =
useNativeChatDictationActions({ textareaRef, setDictationPressed })
@@ -277,6 +272,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
resolveTarget,
classifySend,
onOptimisticSend,
optimisticSendOutcome,
onSlashCommand,
sessionOptionsSurface: ptySessionOptionsSurface,
terminalTabId,
@@ -395,7 +391,7 @@ const NativeChatComposerPane = forwardRef<NativeChatComposerHandle, NativeChatCo
flushDraftAppends()
attachments.flushPendingAttachments()
}}
onPaste={handlePaste}
onPaste={handlePasteEvent}
pickerListboxId={picker.listboxId}
onChoosePickerItem={goalMode.interceptPick(completeItem)}
goalMode={goalMode}
@@ -30,7 +30,11 @@ import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
/** What a user message says under it when it did not go through, with its own Retry when the
* surface can send it again. */
export type NativeChatDeliveryNotice = { text: string; onRetry?: () => void }
export type NativeChatDeliveryNotice = {
text: string
onRetry?: () => void
onDismiss?: () => void
}
/** One message: its prose first, then a collapsible run folding all of the
* turn's tool activity. Monochrome per STYLEGUIDE: user prompts read as a
@@ -185,6 +189,11 @@ export const MessageRow = memo(function MessageRow({
{deliveryNotice ? (
<div className="flex max-w-[85%] items-center gap-2 text-[11px] text-destructive/80">
<span className="min-w-0 break-words">{deliveryNotice.text}</span>
{deliveryNotice.onDismiss ? (
<Button type="button" variant="ghost" size="xs" onClick={deliveryNotice.onDismiss}>
{translate('components.native-chat.dismissDeliveryNotice', 'Dismiss')}
</Button>
) : null}
{deliveryNotice.onRetry ? (
<Button type="button" variant="ghost" size="xs" onClick={deliveryNotice.onRetry}>
<RotateCcw className="size-3" />
@@ -4,7 +4,10 @@ import { createRef } from 'react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NativeChatPromptEditor } from './NativeChatPromptEditor'
import type { NativeChatComposerInput } from './native-chat-composer-input'
import {
insertNativeChatPastedText,
type NativeChatComposerInput
} from './native-chat-composer-input'
import { promptEditor } from './native-chat-prompt-editor.test-support'
afterEach(cleanup)
@@ -143,3 +146,28 @@ describe('native chat skill editor', () => {
expect(container.querySelector('[data-native-chat-skill]')).toBeNull()
})
})
it('replaces the selection with literal pasted text as one undoable edit', async () => {
const { input, editor } = setup('hello world')
act(() => input.setSelectionRange(6, 11))
await act(async () => input.insertText?.('안녕\n$literal'))
expect(input.value).toBe('hello 안녕\n$literal')
await act(async () => {
editor.commands.undo()
})
expect(input.value).toBe('hello world')
})
it('moves focus into the composer when a paste is routed from elsewhere', async () => {
const { input, editor } = setup('hi')
act(() => input.setSelectionRange(2, 2))
const hiddenTerminal = document.createElement('textarea')
document.body.append(hiddenTerminal)
hiddenTerminal.focus()
await act(async () => {
insertNativeChatPastedText(input, ' there')
})
expect(input.value).toBe('hi there')
expect(editor.view.dom.contains(document.activeElement)).toBe(true)
hiddenTerminal.remove()
})
@@ -134,6 +134,12 @@ export function NativeChatPromptEditor({
() =>
editor
? {
insertText: (text) => {
const content = editor.schema.nodeFromJSON(promptTextContent(text))
editor.view.dispatch(
editor.state.tr.replaceSelection(new Slice(content.content, 1, 1))
)
},
get value() {
return promptTextMap(editor.state.doc).text
},
@@ -18,16 +18,11 @@ import { shouldClearNativeChatWorkingSuppression } from './native-chat-working-s
import { resolveNativeChatTerminalTurn } from './native-chat-terminal-turn'
import { useNativeChatTerminalTurnTiming } from './use-native-chat-terminal-turn-timing'
import {
appendPendingSendCache,
launchPromptAsMessage,
pendingSendsAsMessages,
nextNativeChatPendingSendId,
prunePendingSends,
readPendingSendCache,
shouldPruneLaunchPrompt,
writePendingSendCache,
type NativeChatPendingSend
shouldPruneLaunchPrompt
} from './native-chat-pending'
import { useNativeChatPendingDelivery } from './use-native-chat-pending-delivery'
import {
appendCommandMarkerCache,
applyCommandMarkerBoundaries,
@@ -39,11 +34,8 @@ import {
deriveNativeChatStreamingText,
nativeChatStreamingMessage
} from '../../../../shared/native-chat-streaming'
import {
shouldFocusNativeChatComposerFromEditingKey,
shouldFocusNativeChatPaneFromPointerTarget,
shouldRedirectNativeChatTyping
} from './native-chat-typing-redirect'
import { shouldFocusNativeChatPaneFromPointerTarget } from './native-chat-typing-redirect'
import { routeNativeChatRootKeyToInput } from './native-chat-root-key-routing'
import {
emptyNativeChatContextMenuActions,
useNativeChatContextMenu
@@ -161,35 +153,19 @@ export function NativeChatResolvedView({
() => ({ paneKey, agent, sessionId }),
[paneKey, agent, sessionId]
)
const pendingScope = useMemo(() => ({ paneKey, agent }), [paneKey, agent])
const [pending, setPending] = useState<NativeChatPendingSend[]>(() =>
readPendingSendCache(pendingScope)
)
const delivery = useNativeChatPendingDelivery({ paneKey, agent, messages: session.messages })
const { pending, record, clear } = delivery
// Slash commands aren't chat turns, so they get a small local "Ran /clear"
// system line instead of a user bubble. Capped + cached per conversation.
const [commandMarkers, setCommandMarkers] = useState<NativeChatCommandMarker[]>(() =>
readCommandMarkerCache(commandMarkerScope)
)
// Reset the optimistic queue only when the pane/agent changes. A fresh launch
// often learns its provider session id after the first send; clearing pending
// on that transition briefly flashes the empty state before the transcript
// user turn lands.
useEffect(() => {
setPending(readPendingSendCache(pendingScope))
setWorkingInterrupted(false)
}, [pendingScope])
// Command markers are session-scoped because slash commands like /clear are
// local feedback for a specific transcript boundary.
useEffect(() => {
setCommandMarkers(readCommandMarkerCache(commandMarkerScope))
setWorkingInterrupted(false)
}, [commandMarkerScope])
// Prune echoes whose real user turn is now in the transcript.
useEffect(() => {
setPending((prev) =>
writePendingSendCache(pendingScope, prunePendingSends(prev, session.messages))
)
}, [session.messages, pendingScope])
useEffect(() => {
if (!paneLaunchPrompt || !shouldPruneLaunchPrompt(paneLaunchPrompt, session.messages)) {
return
@@ -199,29 +175,9 @@ export function NativeChatResolvedView({
const onOptimisticSend = useCallback(
(text: string, imagePaths?: string[]) => {
setWorkingInterrupted(false)
const sentAt = Date.now()
const boundary = session.messages.at(-1)
const entry: NativeChatPendingSend = {
id: nextNativeChatPendingSendId(sentAt),
text,
sentAt,
afterMessageId: boundary?.id ?? null,
afterMessageTimestamp: boundary?.timestamp ?? null,
...(imagePaths ? { imagePaths } : {})
}
setPending(appendPendingSendCache(pendingScope, entry))
return entry.id
return record(text, imagePaths)
},
[pendingScope, session.messages]
)
const onOptimisticSendCanceled = useCallback(
(pendingId: string) => {
// Why: detach/interrupt cancels the delayed Enter, so its optimistic echo
// must not come back from the pane cache as a prompt that was delivered.
const next = readPendingSendCache(pendingScope).filter((entry) => entry.id !== pendingId)
setPending(writePendingSendCache(pendingScope, next))
},
[pendingScope]
[record]
)
const onSlashCommand = useCallback(
(command: string) => {
@@ -251,6 +207,14 @@ export function NativeChatResolvedView({
paneLaunchPrompt?.failed ? launchPromptMessage?.id : null,
sessionAfterCommandBoundaries.messages
)
// Why memoized: a fresh map each render would re-render every memoized transcript row.
const deliveryNotices = useMemo(
() =>
delivery.notices.size === 0
? launchPromptDeliveryNotices
: new Map([...(launchPromptDeliveryNotices ?? []), ...delivery.notices]),
[launchPromptDeliveryNotices, delivery.notices]
)
const promptCard = useNativeChatInteractivePromptCard({
paneKey,
messages: sessionAfterCommandBoundaries.messages,
@@ -332,9 +296,9 @@ export function NativeChatResolvedView({
// Why: Stop after a submitted turn drops the delayed-write handle once it
// settles, so cancelPendingSends no longer sees the optimistic id. Clear
// the echo cache here so a cancelled prompt cannot stick as a ghost bubble.
setPending(writePendingSendCache(pendingScope, []))
clear()
interactiveSend.cancel()
}, [interactiveSend, pendingScope])
}, [interactiveSend, clear])
const { onLinkClick, linkActionRequest, closeLinkActions } = useNativeChatLinkActions(
fileLinkContext,
rootRef,
@@ -376,20 +340,7 @@ export function NativeChatResolvedView({
}
return
}
// Backspace/Delete outside an input focuses the composer (like typing)
// but inserts nothing — let the now-focused field handle the keystroke.
if (shouldFocusNativeChatComposerFromEditingKey(event)) {
composerRef.current?.focus()
return
}
if (!shouldRedirectNativeChatTyping(event)) {
return
}
if (!composerRef.current?.insertTypedText(event.key)) {
return
}
event.preventDefault()
event.stopPropagation()
routeNativeChatRootKeyToInput(event, composerRef.current, questionAnswerInputRef.current)
}}
onMouseUpCapture={contextMenu.onSelectionCapture}
onKeyUpCapture={contextMenu.onSelectionCapture}
@@ -414,7 +365,7 @@ export function NativeChatResolvedView({
awaitingInput={awaitingInput}
onLinkClick={onLinkClick}
allowFileUriLinks={fileLinkContext !== null}
deliveryNotices={launchPromptDeliveryNotices}
deliveryNotices={deliveryNotices}
/>
)}
</div>
@@ -442,7 +393,8 @@ export function NativeChatResolvedView({
isWorking={isWorking}
onStop={stopAgent}
onOptimisticSend={onOptimisticSend}
onOptimisticSendCanceled={onOptimisticSendCanceled}
onOptimisticSendCanceled={delivery.cancel}
optimisticSendOutcome={delivery}
onSlashCommand={onSlashCommand}
onSwitchToTerminal={onSwitchToTerminal}
readTerminalScreen={readTerminalScreen}
@@ -268,7 +268,8 @@ export function createStructuredSessionMocks() {
},
insertTypedText: () => true,
handlePasteEvent: mocks.handlePasteEvent,
pasteFromClipboard: mocks.pasteFromClipboard
pasteFromClipboard: mocks.pasteFromClipboard,
contains: (node: Node | null) => fieldRef.current?.contains(node) === true
}))
return <textarea ref={fieldRef} data-testid="structured-composer" />
})
@@ -167,7 +167,8 @@ vi.mock('./NativeChatComposer', () => ({
},
insertTypedText: () => true,
handlePasteEvent: mocks.handlePasteEvent,
pasteFromClipboard: mocks.pasteFromClipboard
pasteFromClipboard: mocks.pasteFromClipboard,
contains: (node: Node | null) => fieldRef.current?.contains(node) === true
}))
return <textarea ref={fieldRef} data-testid="structured-composer" />
})
@@ -0,0 +1,75 @@
/** Minimal shape shared by React's synthetic ClipboardEvent and the native DOM
* ClipboardEvent — the pane-level listener delivers the native one. */
export type ClipboardEventLike = {
clipboardData: DataTransfer | null
preventDefault: () => void
defaultPrevented: boolean
}
export function clipboardEventImageFile(event: ClipboardEventLike): File | null {
const data = event.clipboardData
if (!data) {
return null
}
const item = Array.from(data.items).find((candidate) => candidate.type.startsWith('image/'))
return item?.getAsFile() ?? null
}
function lastPathSegment(path: string): string {
return path.split(/[\\/]/).pop() ?? path
}
/** A label names its file directly (Finder), or by path or file URL (Linux file managers). */
function labelledFileName(line: string): string {
if (line.startsWith('file://')) {
try {
return lastPathSegment(decodeURIComponent(line.replace(/^file:\/\/[^/]*/, '')))
} catch {
return line
}
}
return /^(?:\/|[A-Za-z]:[\\/])/.test(line) ? lastPathSegment(line) : line
}
/**
* True when the text only labels the copied files, one line per file: a file
* manager puts that label beside the files, and it is not prompt text.
* `files` are the copied files' names or paths.
*/
export function textOnlyLabelsCopiedFiles(text: string, files: readonly string[]): boolean {
if (files.length === 0) {
return false
}
const names = files.map(lastPathSegment).sort()
const labels = text
.trim()
.split(/\r\n|\r|\n/)
.map((line) => labelledFileName(line.trim()))
.sort()
return labels.length === names.length && labels.every((label, i) => label === names[i])
}
/** The event's text/plain, unless it only labels the files being attached. */
export function clipboardEventPromptText(
event: ClipboardEventLike,
attachingFile: boolean
): string {
const text = event.clipboardData?.getData('text/plain') ?? ''
if (!attachingFile || !text) {
return text
}
const names = Array.from(event.clipboardData?.files ?? [], (file) => file.name)
return textOnlyLabelsCopiedFiles(text, names) ? '' : text
}
/** The clipboard's text for a paste with no event, and whether it only labels copied files. */
export async function readClipboardPasteText(
maxBytes: number
): Promise<{ text: string; labelsFiles: boolean }> {
const [text, filePaths] = await Promise.all([
window.api.ui.readClipboardText({ maxBytes }),
// Bookkeeping only: without the file list the text is typed as-is.
window.api.ui.readClipboardFilePaths().catch(() => [])
])
return { text, labelsFiles: text !== '' && textOnlyLabelsCopiedFiles(text, filePaths) }
}
@@ -104,8 +104,3 @@ export function commandMarkersAsMessages(
source: 'scrape' as const
}))
}
/** True when a message id was minted for a slash-command marker. */
export function isCommandMarkerId(id: string): boolean {
return id.startsWith('command:')
}
@@ -10,5 +10,19 @@ export type NativeChatComposerInput = Pick<
| 'setSelectionRange'
> & {
contains?: (node: Node | null) => boolean
insertText?: (text: string) => void
insertSkill?: (from: number, to: number, token: string) => void
}
export function insertNativeChatPastedText(
input: NativeChatComposerInput | null,
text: string
): boolean {
if (!input || input.disabled || !input.insertText) {
return false
}
// A paste routed from a hidden terminal or a transcript click must leave the caret in the composer.
input.focus()
input.insertText(text)
return true
}
@@ -52,8 +52,9 @@ function Harness(props: HarnessProps): React.JSX.Element {
},
insertTypedText: () => true,
handlePasteEvent: () => {},
pasteFromClipboard: () => {}
} as NativeChatComposerHandle
pasteFromClipboard: () => {},
contains: () => false
}
useNativeChatComposerRevealFocus({
rootRef,
composerRef,
@@ -38,6 +38,13 @@ export type NativeChatStructuredComposerTransport = {
runtimeEnvironmentId: string | null
}
export type NativeChatOptimisticSendOutcome = {
/** The host refused the write: mark the echo "Message not sent". */
reject: (pendingId: string) => void
/** The write acknowledgment was lost: hold the echo, then flag it unconfirmed. */
holdUnconfirmed: (pendingId: string) => void
}
export type NativeChatComposerProps = {
/** Tab hosting the agent; used to resolve the live ptyId + runtime settings. */
terminalTabId: string
@@ -54,6 +61,8 @@ export type NativeChatComposerProps = {
onStop?: () => void
/** Render an optimistic echo until the real transcript turn lands. */
onOptimisticSend?: (text: string, imagePaths?: string[]) => string | undefined
/** Settle an optimistic echo whose write was refused or never acknowledged. */
optimisticSendOutcome?: NativeChatOptimisticSendOutcome
/** Remove an optimistic echo when its delayed submit is canceled. */
onOptimisticSendCanceled?: (pendingId: string) => void
/** Record a dispatched slash command that does not create a chat turn. */
@@ -92,4 +101,6 @@ export type NativeChatComposerHandle = {
}) => void
/** Pastes clipboard content when no DOM paste event is available. */
pasteFromClipboard: () => void
/** Whether a node is inside the composer's own input, not merely the chat pane. */
contains: (node: Node | null) => boolean
}
@@ -0,0 +1,89 @@
// Clearing the agent's unsubmitted input line before a chat send writes its body.
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
import { AGENT_TUI_CLEAR_INPUT_MAX } from '../../../../shared/agent-tui-input-clear'
// Why: agent TUI composers treat Ctrl+U as kill-to-start-of-line. Chat sends
// start from an empty line so a prior cancelled paste cannot glue onto the next
// prompt. Not used on verified option commands — model-switch confirmation
// observes the PTY and Ctrl+U can miss confirmation markers.
//
// One Ctrl+U only ever clears ONE logical line. When the line may hold an
// injected multi-line launch draft, callers pass `clearInput` built by
// buildAgentTuiClearInputForText — see agent-tui-input-clear.ts for the measured
// 2N-1 law and the sequences that do NOT work.
export const NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT = '\x15'
/** Gap before re-reading the agent's input line to confirm a clear landed. */
export const NATIVE_CHAT_CLEAR_CONFIRM_MS = 140
export type NativeChatSendOptions = {
/** The host refused a write; nothing after it was sent. */
onWriteRejected?: () => void
/** A write's acknowledgment was lost; it may or may not have landed. */
onWriteUnconfirmed?: () => void
/** Bytes that empty the agent's input line. Defaults to a single Ctrl+U. */
clearInput?: string
/**
* Observed check that the input line is now empty.
* Supplied only for launch-draft replacement; when it reports "not cleared"
* the send widens to a maximal burst before writing the body rather than
* pasting on top of residue.
*/
confirmCleared?: () => boolean
}
type RuntimeSettings = ReturnType<typeof getSettingsForAgentTabRuntimeOwner>
export function clearUnsubmittedAgentInput(
settings: RuntimeSettings,
ptyId: string,
options?: NativeChatSendOptions
): void {
sendRuntimePtyInput(
settings,
ptyId,
options?.clearInput ?? NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
'driving'
)
}
/**
* Run `writeBody` once the input line is clear. With no `confirmCleared` the
* clear is a plain in-order write on the same byte stream, so the TUI consumes
* it before the body and the body follows immediately. With one, we pause to
* actually look at the agent's input line, and widen to a maximal burst when the
* draft is still visible — the injected line count is only a lower bound on what
* the buffer holds, since the user can type into the TUI directly.
*/
export function clearThenWrite(
settings: RuntimeSettings,
ptyId: string,
options: NativeChatSendOptions | undefined,
delay: (ms: number, fn: () => void) => void,
writeBody: () => void
): void {
clearUnsubmittedAgentInput(settings, ptyId, options)
const confirmCleared = options?.confirmCleared
if (!confirmCleared) {
writeBody()
return
}
delay(NATIVE_CHAT_CLEAR_CONFIRM_MS, () => {
let cleared = false
try {
cleared = confirmCleared()
} catch {
// An unreadable terminal is unconfirmed; the maximal clear remains safe.
}
if (!cleared) {
sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX, 'driving')
}
writeBody()
})
}
/** Extra time a send needs when it stops to confirm the clear before the body. */
export function clearConfirmDurationMs(options?: NativeChatSendOptions): number {
return options?.confirmCleared ? NATIVE_CHAT_CLEAR_CONFIRM_MS : 0
}
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { buildNativeChatRenderItems, orderNativeChatMessages } from './native-chat-message-grouping'
import { orderNativeChatMessages } from './native-chat-message-grouping'
import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming'
function msg(
@@ -42,75 +42,3 @@ describe('orderNativeChatMessages', () => {
expect(ordered.map((m) => m.id)).toEqual(['real-user', 'streaming', 'pending:abc'])
})
})
describe('buildNativeChatRenderItems', () => {
it('renders messages in order', () => {
const items = buildNativeChatRenderItems([
msg({ id: 'u', role: 'user', timestamp: 1, blocks: [{ type: 'text', text: 'hi' }] }),
msg({ id: 'a', role: 'assistant', timestamp: 2, blocks: [{ type: 'text', text: 'hello' }] })
])
expect(items.map((i) => i.id)).toEqual(['u', 'a'])
expect(items[0]?.kind).toBe('message')
})
it('pairs a tool-call with its tool-result into one step', () => {
const items = buildNativeChatRenderItems([
msg({
id: 'a',
role: 'assistant',
timestamp: 1,
blocks: [{ type: 'tool-call', name: 'Bash', input: { cmd: 'ls' } }]
}),
msg({
id: 't',
role: 'tool',
timestamp: 2,
blocks: [{ type: 'tool-result', output: 'file.txt' }]
})
])
const steps = items.filter((i) => i.kind === 'tool-step')
expect(steps).toHaveLength(1)
const step = steps[0]
if (step?.kind !== 'tool-step') {
throw new Error('expected tool-step')
}
expect(step.step.call.name).toBe('Bash')
expect(step.step.result?.output).toBe('file.txt')
})
it('leaves an unanswered tool-call in flight (result null)', () => {
const items = buildNativeChatRenderItems([
msg({
id: 'a',
role: 'assistant',
timestamp: 1,
blocks: [{ type: 'tool-call', name: 'Read', input: {} }]
})
])
const step = items.find((i) => i.kind === 'tool-step')
if (step?.kind !== 'tool-step') {
throw new Error('expected tool-step')
}
expect(step.step.result).toBeNull()
})
it('separates prose blocks from tool blocks in the same message', () => {
const items = buildNativeChatRenderItems([
msg({
id: 'a',
role: 'assistant',
timestamp: 1,
blocks: [
{ type: 'text', text: 'running it' },
{ type: 'tool-call', name: 'Bash', input: {} }
]
})
])
expect(items.map((i) => i.kind)).toEqual(['message', 'tool-step'])
const message = items[0]
if (message?.kind !== 'message') {
throw new Error('expected message')
}
expect(message.blocks).toEqual([{ type: 'text', text: 'running it' }])
})
})
@@ -1,118 +1,12 @@
// Pure grouping logic for the native chat message list. Kept out of the .tsx so
// the pairing/ordering rules are unit-testable without rendering. Two jobs:
// 1. Order messages stably (timestamp then id; null timestamps sort first as
// the shared model documents) — the assembler already sorts, but the list
// re-sorts defensively so a caller passing unordered fixtures still reads
// correctly.
// 2. Within an assistant turn, pair each tool-call block with the tool-result
// that answers it so the view can render one collapsible step instead of
// two disconnected rows.
// Stable message ordering for the native chat message list: timestamp then id,
// null timestamps first as the shared model documents.
import {
isToolCallBlock,
isToolResultBlock,
type NativeChatBlock,
type NativeChatMessage,
type NativeChatToolCallBlock,
type NativeChatToolResultBlock
} from '../../../../shared/native-chat-types'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { compareMessages } from './native-chat-session-assembler'
/** A tool-call block paired with the result that answered it, when one exists.
* `result` is null while the call is still in flight (no result yet). */
export type NativeChatToolStep = {
call: NativeChatToolCallBlock
result: NativeChatToolResultBlock | null
}
/** One renderable item in the list: either a prose/role message carrying its
* non-tool blocks, or a tool step (call + optional result). The view renders
* each variant differently. */
export type NativeChatRenderItem =
| {
kind: 'message'
id: string
message: NativeChatMessage
/** The message's blocks minus tool-call/tool-result (those become steps). */
blocks: NativeChatBlock[]
}
| {
kind: 'tool-step'
id: string
/** Role of the message the call originated from (assistant/tool). */
role: NativeChatMessage['role']
timestamp: number | null
step: NativeChatToolStep
}
/** Order messages stably: null timestamps first (model rule), then ascending
* timestamp, ties broken by id. Shares the assembler's comparator so both
* paths order identically. */
export function orderNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] {
return [...messages].sort(compareMessages)
}
/** Collect every tool-result across the whole conversation in document order so
* a call can find its answer even when the result lands in a later message (the
* common transcript shape: assistant emits the call, a following tool message
* carries the result). Results carry no originating name in our model, so they
* are handed out FIFO to calls. */
function collectToolResults(messages: NativeChatMessage[]): NativeChatToolResultBlock[] {
const results: NativeChatToolResultBlock[] = []
for (const message of messages) {
for (const block of message.blocks) {
if (isToolResultBlock(block)) {
results.push(block)
}
}
}
return results
}
/**
* Flatten ordered messages into render items, pairing tool calls with results.
* Result pairing is FIFO across the conversation: tool results in our model
* carry no back-reference to a call id, so we match the Nth call to the Nth
* result in document order — the order both providers emit them. A call with no
* remaining result renders as in-flight (`result: null`).
*/
export function buildNativeChatRenderItems(messages: NativeChatMessage[]): NativeChatRenderItem[] {
const ordered = orderNativeChatMessages(messages)
const resultQueue = collectToolResults(ordered)
let resultCursor = 0
const items: NativeChatRenderItem[] = []
for (const message of ordered) {
const nonToolBlocks: NativeChatBlock[] = []
const steps: NativeChatToolStep[] = []
for (const block of message.blocks) {
if (isToolCallBlock(block)) {
const result = resultQueue[resultCursor] ?? null
if (result) {
resultCursor += 1
}
steps.push({ call: block, result })
} else if (isToolResultBlock(block)) {
// Results are emitted as steps from the call side; skip standalone ones.
continue
} else {
nonToolBlocks.push(block)
}
}
if (nonToolBlocks.length > 0) {
items.push({ kind: 'message', id: message.id, message, blocks: nonToolBlocks })
}
for (const [index, step] of steps.entries()) {
items.push({
kind: 'tool-step',
id: `${message.id}:tool:${index}`,
role: message.role,
timestamp: message.timestamp,
step
})
}
}
return items
}
@@ -0,0 +1,69 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
const io = vi.hoisted(() => ({ write: vi.fn(), verified: vi.fn() }))
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
sendRuntimePtyInput: io.write,
sendRuntimePtyInputVerified: io.verified
}))
import {
sendNativeChatMessage,
resetNativeChatPtySendQueuesForTests
} from './native-chat-runtime-send'
import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send'
beforeEach(() => {
vi.useFakeTimers()
resetNativeChatPtySendQueuesForTests()
io.write.mockReset().mockReturnValue(true)
io.verified.mockReset().mockResolvedValue(true)
})
afterEach(() => {
resetNativeChatPtySendQueuesForTests()
vi.useRealTimers()
})
it('observes a refused write, skips Enter, and releases the queue for the next user action', async () => {
const rejected = vi.fn()
io.verified.mockResolvedValueOnce(false)
sendNativeChatMessage(null, 'pane', 'refused', { onWriteRejected: rejected })
await vi.advanceTimersByTimeAsync(1000)
expect(rejected).toHaveBeenCalledOnce()
expect(io.verified.mock.calls.map((call) => call[2])).toEqual([
buildNativeChatPasteBytes('refused')
])
sendNativeChatMessage(null, 'pane', 'next', { onWriteRejected: rejected })
await vi.advanceTimersByTimeAsync(1000)
expect(io.verified.mock.calls.map((call) => call[2])).toEqual([
buildNativeChatPasteBytes('refused'),
buildNativeChatPasteBytes('next'),
NATIVE_CHAT_SUBMIT
])
})
it('reports a lost acknowledgment once as unconfirmed, never as rejection, and still submits', async () => {
const rejected = vi.fn()
const unconfirmed = vi.fn()
io.verified.mockRejectedValueOnce(new Error('lost acknowledgment'))
io.verified.mockRejectedValueOnce(new Error('lost acknowledgment'))
sendNativeChatMessage(null, 'pane', 'uncertain', {
onWriteRejected: rejected,
onWriteUnconfirmed: unconfirmed
})
await vi.advanceTimersByTimeAsync(120000)
expect(rejected).not.toHaveBeenCalled()
expect(unconfirmed).toHaveBeenCalledOnce()
expect(io.verified.mock.calls.map((call) => call[2])).toEqual([
buildNativeChatPasteBytes('uncertain'),
NATIVE_CHAT_SUBMIT
])
})
it('serializes rapid sends through their acknowledged Enter and preserves the paste delay', async () => {
const rejected = vi.fn()
sendNativeChatMessage(null, 'pane', 'one', { onWriteRejected: rejected })
sendNativeChatMessage(null, 'pane', 'two', { onWriteRejected: rejected })
await vi.advanceTimersByTimeAsync(499)
expect(io.verified).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(501)
expect(io.verified.mock.calls.map((call) => call[2])).toEqual([
buildNativeChatPasteBytes('one'),
NATIVE_CHAT_SUBMIT,
buildNativeChatPasteBytes('two'),
NATIVE_CHAT_SUBMIT
])
})
@@ -0,0 +1,69 @@
import { sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection'
import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
import { enqueueNativeChatPtySend } from './native-chat-pty-send-queue'
import {
clearConfirmDurationMs,
clearThenWrite,
clearUnsubmittedAgentInput,
type NativeChatSendOptions
} from './native-chat-input-clear'
/** Observe write refusals without mistaking transport success for provider acceptance. */
export function sendNativeChatObservedWrites(
settings: ReturnType<typeof getSettingsForAgentTabRuntimeOwner>,
ptyId: string,
writes: readonly { data: string; delayBeforeMs: number }[],
options: NativeChatSendOptions
) {
return enqueueNativeChatPtySend(
ptyId,
writes.reduce((total, write) => total + write.delayBeforeMs, 0) +
clearConfirmDurationMs(options),
({ isCancelled, delay, markSubmitted }) => {
let reportedUnconfirmed = false
const writeAt = (index: number): void => {
if (isCancelled()) {
return
}
const write = writes[index]
if (!write) {
markSubmitted()
return
}
const send = (): void => {
if (isCancelled()) {
return
}
void sendRuntimePtyInputVerified(settings, ptyId, write.data, 'driving')
.then((accepted) => {
if (isCancelled()) {
return
}
if (!accepted) {
options.onWriteRejected?.()
markSubmitted()
return
}
writeAt(index + 1)
})
// A lost acknowledgment is not a refusal: never re-send these bytes, but still submit
// a body that may have landed, as the unobserved path does.
.catch(() => {
if (!reportedUnconfirmed) {
reportedUnconfirmed = true
options.onWriteUnconfirmed?.()
}
writeAt(index + 1)
})
}
if (write.delayBeforeMs > 0) {
delay(write.delayBeforeMs, send)
} else {
send()
}
}
clearThenWrite(settings, ptyId, options, delay, () => writeAt(0))
},
{ onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId, options) }
)
}
@@ -0,0 +1,416 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from '@testing-library/react'
import { useRef, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { APP_MENU_PASTE_EVENT } from '@/lib/app-menu-paste'
import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge'
import type { NativeChatComposerHandle } from './NativeChatComposer'
import { NativeChatPaneCover } from '../terminal-pane/NativeChatPaneCover'
import { registerTerminalPanePasteListeners } from '../terminal-pane/terminal-pane-paste-listeners'
import type { TerminalPaneCloseController } from '../terminal-pane/use-terminal-pane-close-actions'
import type { TerminalPanePasteExecution } from '../terminal-pane/terminal-pane-paste-execution'
import {
pasteTerminalPaneMenuClipboard,
type TerminalPaneMenuPasteContext
} from '../terminal-pane/terminal-pane-menu-paste'
import type { ManagedPane } from '@/lib/pane-manager/pane-manager'
const mocks = vi.hoisted(() => ({
readClipboardText: vi.fn(),
terminalClipboard: vi.fn(),
error: vi.fn()
}))
vi.mock('sonner', () => ({ toast: { error: mocks.error } }))
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
vi.mock('../terminal-pane/terminal-clipboard-paste', () => ({
pasteTerminalClipboard: mocks.terminalClipboard
}))
vi.mock('../terminal-pane/terminal-clipboard-event-paste', () => ({
isClipboardEventPasteRequired: () => false,
firesNativePasteEvent: () => true,
getClipboardEventText: (event: ClipboardEvent) => event.clipboardData?.getData('text/plain') ?? ''
}))
const REFUSAL = "Can't paste — this chat isn't accepting input right now."
let dispose: (() => void) | undefined
beforeEach(() => {
mocks.readClipboardText.mockResolvedValue('menu text')
mocks.terminalClipboard.mockResolvedValue({ status: 'empty' })
Object.defineProperty(window, 'api', {
configurable: true,
value: { ui: { readClipboardText: mocks.readClipboardText } }
})
})
afterEach(() => {
cleanup()
dispose?.()
dispose = undefined
document.body.replaceChildren()
vi.clearAllMocks()
})
type FakePane = Pick<ManagedPane, 'id' | 'container' | 'terminal'> & {
helper: HTMLTextAreaElement
}
function terminalPane(id: number): FakePane {
const container = document.createElement('div')
container.className = 'pane'
container.dataset.leafId = `leaf-${id}`
const element = document.createElement('div')
element.className = 'xterm'
const helper = document.createElement('textarea')
helper.className = 'xterm-helper-textarea'
element.append(helper)
container.append(element)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: cover code reads only element and focus.
const terminal = { element, focus: () => helper.focus() } as unknown as ManagedPane['terminal']
return { id, container, terminal, helper }
}
function composerHandle(): NativeChatComposerHandle & { element: HTMLElement } {
const element = document.createElement('div')
element.contentEditable = 'true'
// Production marks the composer field box and the pane-wide drop surface alike.
element.dataset.composerScopeKey = 'pane'
return {
element,
focus: vi.fn(() => {
element.focus()
return true
}),
insertTypedText: vi.fn(() => true),
handlePasteEvent: vi.fn(),
pasteFromClipboard: vi.fn(),
contains: (node) => element.contains(node)
}
}
type ChatOptions = {
composer?: NativeChatComposerHandle & { element: HTMLElement }
answer?: HTMLInputElement
answerRef?: { current: HTMLInputElement | null }
}
function ChatRoot({
composer,
answer,
answerRef: sharedAnswerRef
}: ChatOptions): React.JSX.Element {
const rootRef = useRef<HTMLDivElement>(null)
const composerRef = useRef<NativeChatComposerHandle | null>(composer ?? null)
const ownAnswerRef = useRef<HTMLInputElement | null>(answer ?? null)
const answerRef = sharedAnswerRef ?? ownAnswerRef
useNativeChatPasteBridge({ rootRef, composerRef, questionAnswerInputRef: answerRef })
return (
// Mirrors NativeChatPaneFileDropSurface, which publishes the composer scope pane-wide.
<div data-composer-scope-key="pane" data-native-file-drop-target="composer">
<div
ref={(node) => {
rootRef.current = node
if (node && composer && !node.contains(composer.element)) {
node.append(composer.element)
}
if (node && answer && !node.contains(answer)) {
node.append(answer)
}
}}
data-native-chat-root="true"
tabIndex={-1}
>
<p data-testid="transcript">transcript</p>
<input data-testid="search" />
</div>
</div>
)
}
function Cover({
pane,
covered,
children
}: {
pane: FakePane
covered: boolean
children?: ReactNode
}): React.JSX.Element | null {
return covered
? createPortal(
<NativeChatPaneCover pane={pane}>{children}</NativeChatPaneCover>,
pane.container
)
: null
}
function fixture(options: { platform?: NodeJS.Platform } = {}) {
const container = document.createElement('div')
const pane = terminalPane(1)
const sibling = terminalPane(2)
container.append(pane.container, sibling.container)
const outside = document.createElement('input')
document.body.append(container, outside)
const pasteFromClipboard = vi.fn()
const executePanePasteText = vi.fn()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the listeners use only these controller fields.
const controller = {
forceBracketedMultilineTextPaste: false,
keybindings: {},
worktreeId: 'workspace',
managerRef: { current: { getActivePane: () => pane, getPanes: () => [pane, sibling] } },
setTerminalError: vi.fn()
} as unknown as TerminalPaneCloseController
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the two execution callbacks read by the listener.
const execution = {
pasteFromClipboard,
executePanePasteText
} as unknown as TerminalPanePasteExecution
dispose = registerTerminalPanePasteListeners({
container,
controller,
execution,
isMac: options.platform === 'darwin',
shortcutPlatform: options.platform ?? 'win32'
})
pane.helper.focus()
const view = render(<Cover pane={pane} covered={false} />)
const cover = (chat: ReactNode = null): void =>
view.rerender(
<Cover pane={pane} covered>
{chat}
</Cover>
)
const uncover = (): void => view.rerender(<Cover pane={pane} covered={false} />)
const shell = (): HTMLElement => {
const element = pane.container.querySelector<HTMLElement>('.native-chat-pane-shell')
if (!element) {
throw new Error('cover not mounted')
}
return element
}
return { pane, sibling, outside, pasteFromClipboard, cover, uncover, shell }
}
function paste(target: Element, text = 'event text'): ClipboardEvent {
const data = new DataTransfer()
data.setData('text/plain', text)
const event = new ClipboardEvent('paste', {
clipboardData: data,
bubbles: true,
cancelable: true
})
target.dispatchEvent(event)
return event
}
function menuPane(pane: FakePane): ManagedPane {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a covered pane's menu paste reads only container and terminal.
return pane as unknown as ManagedPane
}
function menuContext(): TerminalPaneMenuPasteContext {
return {
managerRef: { current: null },
paneTransportsRef: { current: new Map() },
tabId: 'tab',
worktreeId: 'workspace',
forceBracketedMultilineTextPaste: false,
onPasteError: vi.fn()
}
}
describe('a chat cover owns focus over its terminal', () => {
it('moves focus off the covered xterm and keeps it off', () => {
const f = fixture()
expect(document.activeElement).toBe(f.pane.helper)
f.cover()
expect(document.activeElement).toBe(f.shell())
expect(f.pane.terminal.element?.inert).toBe(true)
// Any of the unguarded terminal.focus() paths (focus-follows-mouse, pane menus) now no-ops.
f.pane.terminal.focus()
expect(document.activeElement).toBe(f.shell())
})
it('leaves a split sibling focusable', () => {
const f = fixture()
f.cover()
expect(f.sibling.terminal.element?.inert).toBe(false)
f.sibling.terminal.focus()
expect(document.activeElement).toBe(f.sibling.helper)
})
it('does not pull focus from elsewhere when a chat opens', () => {
const f = fixture()
f.outside.focus()
f.cover()
expect(document.activeElement).toBe(f.outside)
expect(f.pane.terminal.element?.inert).toBe(true)
})
it('returns focus to the terminal when the chat that held it goes away', () => {
const f = fixture()
f.cover()
f.uncover()
expect(f.pane.terminal.element?.inert).toBe(false)
expect(document.activeElement).toBe(f.pane.helper)
})
it.each(['another control', 'nothing'] as const)(
'does not pull focus into the terminal when %s had focus',
(holder) => {
const f = fixture()
f.cover()
if (holder === 'another control') {
f.outside.focus()
} else {
f.shell().blur()
}
const focusedBefore = document.activeElement
f.uncover()
expect(f.pane.terminal.element?.inert).toBe(false)
expect(document.activeElement).toBe(focusedBefore)
}
)
})
describe('paste inside a chat cover never reaches the terminal', () => {
it.each(['darwin', 'win32', 'linux'] as const)(
'refuses a %s paste before the chat has mounted any input',
(platform) => {
const f = fixture({ platform })
f.cover()
f.shell().dispatchEvent(
new KeyboardEvent('keydown', {
key: 'v',
metaKey: platform === 'darwin',
ctrlKey: platform !== 'darwin',
bubbles: true,
cancelable: true
})
)
expect(paste(f.shell()).defaultPrevented).toBe(true)
expect(f.pasteFromClipboard).not.toHaveBeenCalled()
expect(mocks.error).toHaveBeenCalledExactlyOnceWith(REFUSAL)
}
)
it('leaves the app-menu paste to the cover instead of the hidden terminal', () => {
const f = fixture({ platform: 'darwin' })
f.cover()
const event = new Event(APP_MENU_PASTE_EVENT, { cancelable: true })
window.dispatchEvent(event)
expect(event.defaultPrevented).toBe(false)
expect(mocks.terminalClipboard).not.toHaveBeenCalled()
})
it('refuses when the chat shows no input, such as an approval card', () => {
const f = fixture()
f.cover(<ChatRoot />)
const transcript = f.pane.container.querySelector('[data-testid="transcript"]')!
expect(paste(transcript).defaultPrevented).toBe(true)
expect(mocks.error).toHaveBeenCalledExactlyOnceWith(REFUSAL)
expect(f.pasteFromClipboard).not.toHaveBeenCalled()
})
it('hands a paste on the cover itself to the composer once the chat mounts', () => {
const f = fixture()
const composer = composerHandle()
f.cover(<ChatRoot composer={composer} />)
paste(f.shell())
expect(composer.pasteFromClipboard).toHaveBeenCalledTimes(1)
expect(mocks.error).not.toHaveBeenCalled()
expect(f.pasteFromClipboard).not.toHaveBeenCalled()
})
it('delivers transcript paste with its payload to the composer', () => {
const f = fixture()
const composer = composerHandle()
f.cover(<ChatRoot composer={composer} />)
const transcript = f.pane.container.querySelector('[data-testid="transcript"]')!
const event = paste(transcript, '원문')
expect(composer.handlePasteEvent).toHaveBeenCalledExactlyOnceWith(event)
expect(mocks.readClipboardText).not.toHaveBeenCalled()
expect(mocks.error).not.toHaveBeenCalled()
})
it('keeps native paste in a text field inside the chat despite the pane-wide scope marker', () => {
const f = fixture()
const composer = composerHandle()
f.cover(<ChatRoot composer={composer} />)
const search = f.pane.container.querySelector<HTMLInputElement>('[data-testid="search"]')!
expect(paste(search).defaultPrevented).toBe(false)
search.focus()
window.dispatchEvent(new Event(APP_MENU_PASTE_EVENT, { cancelable: true }))
expect(composer.handlePasteEvent).not.toHaveBeenCalled()
expect(composer.pasteFromClipboard).not.toHaveBeenCalled()
expect(mocks.error).not.toHaveBeenCalled()
})
it('claims the app-menu paste while the composer input has focus', () => {
const f = fixture({ platform: 'darwin' })
const composer = composerHandle()
f.cover(<ChatRoot composer={composer} />)
composer.element.focus()
window.dispatchEvent(new Event(APP_MENU_PASTE_EVENT, { cancelable: true }))
expect(composer.pasteFromClipboard).toHaveBeenCalledTimes(1)
expect(mocks.terminalClipboard).not.toHaveBeenCalled()
})
it('hands transcript paste to the question answer when the composer is absent', async () => {
const f = fixture()
const answer = document.createElement('input')
f.cover(<ChatRoot answer={answer} />)
const transcript = f.pane.container.querySelector('[data-testid="transcript"]')!
await act(async () => {
paste(transcript, 'answer')
})
expect(answer.value).toBe('answer')
expect(mocks.error).not.toHaveBeenCalled()
})
it('keeps terminal-only paste unchanged', () => {
const f = fixture()
paste(f.pane.helper)
expect(f.pasteFromClipboard).toHaveBeenCalledExactlyOnceWith(f.pane, 'paste-event')
window.dispatchEvent(new Event(APP_MENU_PASTE_EVENT, { cancelable: true }))
expect(mocks.terminalClipboard).toHaveBeenCalledTimes(1)
})
})
describe('terminal context-menu paste on a covered pane', () => {
it('routes by its named pane to that pane’s chat', async () => {
const f = fixture()
const composer = composerHandle()
f.cover(<ChatRoot composer={composer} />)
await pasteTerminalPaneMenuClipboard(menuContext(), menuPane(f.pane), 'context-menu')
expect(composer.pasteFromClipboard).toHaveBeenCalledTimes(1)
expect(mocks.terminalClipboard).not.toHaveBeenCalled()
})
it('refuses instead of pasting into the terminal when the chat has no input', async () => {
const f = fixture()
f.cover()
await pasteTerminalPaneMenuClipboard(menuContext(), menuPane(f.pane), 'right-click')
expect(mocks.error).toHaveBeenCalledExactlyOnceWith(REFUSAL)
expect(mocks.terminalClipboard).not.toHaveBeenCalled()
})
it('does not put a late read into a replacement question input', async () => {
const f = fixture()
const answer = document.createElement('input')
const answerRef = { current: answer }
f.cover(<ChatRoot answer={answer} answerRef={answerRef} />)
let finish = (_text: string): void => {}
mocks.readClipboardText.mockReturnValue(
new Promise<string>((resolve) => {
finish = resolve
})
)
await pasteTerminalPaneMenuClipboard(menuContext(), menuPane(f.pane), 'context-menu')
answerRef.current = document.createElement('input')
await act(async () => finish('old answer'))
expect(answer.value).toBe('')
expect(answerRef.current.value).toBe('')
})
})
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { pendingSendsAsMessages, prunePendingSends } from './native-chat-pending'
const prompt = 'Summarize the failing tests.\n\nThen propose a fix for each one.'
const wrapped = `\n\n<pasted_content id="7e64">\n${prompt}\n</pasted_content id="7e64">\n`
function message(id: string, role: NativeChatMessage['role'], text: string): NativeChatMessage {
return { id, role, source: 'transcript', timestamp: null, blocks: [{ type: 'text', text }] }
}
describe('pending echoes against Claude pasted-content rows', () => {
// A new host delivers the decoded prompt; an old host still delivers the raw envelope.
it.each([
['new host', prompt],
['old host', wrapped]
])('retires the echo with %s rows and keeps a second identical send', (_host, row) => {
const history = [
message('boundary', 'assistant', 'earlier'),
message('user', 'user', row),
message('reply', 'assistant', 'answer')
]
const pending = [{ id: 'p1', text: prompt, sentAt: 999_000, afterMessageId: 'boundary' }]
expect(pendingSendsAsMessages(pending, history)).toEqual([])
expect(prunePendingSends(pending, history)).toEqual([])
expect(
prunePendingSends([...pending, { ...pending[0]!, id: 'p2', matchingOccurrence: 2 }], history)
).toHaveLength(1)
})
})
@@ -19,7 +19,6 @@ import {
applyCommandMarkerBoundaries,
clearCommandMarkerCacheForTests,
commandMarkersAsMessages,
isCommandMarkerId,
readCommandMarkerCache
} from './native-chat-command-marker'
import { stripNoiseMessages } from './native-chat-noise'
@@ -694,11 +693,6 @@ describe('commandMarkersAsMessages', () => {
const markers = commandMarkersAsMessages([{ id: 'c1', command: '/compact', sentAt: 1 }])
expect(stripNoiseMessages(markers)).toEqual(markers)
})
it('isCommandMarkerId recognizes the prefix', () => {
expect(isCommandMarkerId('command:c1')).toBe(true)
expect(isCommandMarkerId('pending:p1')).toBe(false)
})
})
describe('command marker cache', () => {

Some files were not shown because too many files have changed in this diff Show More