fix terminal output ownership gaps

This commit is contained in:
Brennan Benson
2026-08-04 15:42:38 -07:00
parent 6cb25527c7
commit 2087baf264
9 changed files with 139 additions and 19 deletions
@@ -15,13 +15,20 @@ describe('canCommandCodeOutputOwnPane', () => {
it('rejects another agent owner or foreground process', () => {
expect(canCommandCodeOutputOwnPane({ paneOwnerAgent: 'claude' })).toBe(false)
expect(canCommandCodeOutputOwnPane({ foregroundAgent: 'claude' })).toBe(false)
expect(
canCommandCodeOutputOwnPane({
paneOwnerAgent: 'unknown',
retainedPaneOwnerAgent: 'claude'
})
).toBe(false)
})
it('prefers the current foreground process over stale pane ownership', () => {
expect(
canCommandCodeOutputOwnPane({
foregroundAgent: 'command-code',
paneOwnerAgent: 'claude'
paneOwnerAgent: 'claude',
retainedPaneOwnerAgent: 'claude'
})
).toBe(true)
expect(
@@ -5,6 +5,7 @@ export function canCommandCodeOutputOwnPane(args: {
foregroundAgent?: TuiAgent | null
shellForeground?: boolean
paneOwnerAgent?: AgentType | null
retainedPaneOwnerAgent?: AgentType | null
}): boolean {
if (args.foregroundAgent) {
return args.foregroundAgent === 'command-code'
@@ -12,9 +13,9 @@ export function canCommandCodeOutputOwnPane(args: {
if (args.shellForeground) {
return false
}
return (
!args.paneOwnerAgent ||
args.paneOwnerAgent === 'unknown' ||
args.paneOwnerAgent === 'command-code'
)
const paneOwnerAgent =
args.paneOwnerAgent && args.paneOwnerAgent !== 'unknown'
? args.paneOwnerAgent
: (args.retainedPaneOwnerAgent ?? args.paneOwnerAgent)
return !paneOwnerAgent || paneOwnerAgent === 'unknown' || paneOwnerAgent === 'command-code'
}
@@ -406,7 +406,7 @@ describe('startParkedTerminalByteWatcher', () => {
it('feeds Command Code output through the parked byte detector', async () => {
const { dispose } = await startWatcher()
emit('# Command Code v0.27.2')
emit('# Command Code v0.27.2\r\n')
emit('⌘ Parsing...')
expect(commandStatusPolicy.onCommandCodeWorking).toHaveBeenCalledTimes(1)
@@ -16,6 +16,7 @@ const ROUTING = { connectionId: null }
type MockStoreState = {
tabsByWorktree: Record<string, { id: string; launchAgent?: AgentType }[]>
agentStatusByPaneKey: Record<string, AgentStatusEntry | undefined>
retainedAgentsByPaneKey: Record<string, { agentType: AgentType } | undefined>
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry>
agentLaunchConfigByPaneKey: Record<string, { identity: { agentType?: AgentType } } | undefined>
runtimePaneTitlesByTabId: Record<string, Record<number, string | undefined>>
@@ -46,6 +47,7 @@ function makeMockStoreState(): MockStoreState {
return {
tabsByWorktree: { [WORKTREE_ID]: [{ id: TAB_ID }] },
agentStatusByPaneKey: {},
retainedAgentsByPaneKey: {},
paneForegroundAgentByPaneKey: {},
agentLaunchConfigByPaneKey: {},
runtimePaneTitlesByTabId: { [TAB_ID]: { [PANE_ID]: '✳ Build feature' } },
@@ -129,6 +131,19 @@ describe('createParkedTerminalCommandStatusPolicy', () => {
policy.dispose()
})
it('rejects scrape status when retained Claude identity owns the pane', async () => {
mockStoreState.retainedAgentsByPaneKey[PANE_KEY] = { agentType: 'claude' }
mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ agentType: 'unknown' })
const policy = await createPolicy(PTY_ID_LOCAL)
policy.onCommandCodeWorking('False prompt')
policy.onCommandCodeDone('False prompt')
vi.advanceTimersByTime(DONE_SETTLE_MS)
expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
policy.dispose()
})
it('rejects scrape status when Claude launch metadata owns the pane', async () => {
mockStoreState.tabsByWorktree[WORKTREE_ID] = [{ id: TAB_ID, launchAgent: 'claude' }]
const policy = await createPolicy(PTY_ID_LOCAL)
@@ -155,6 +170,7 @@ describe('createParkedTerminalCommandStatusPolicy', () => {
it('lets a current Command Code process reclaim stale Claude ownership', async () => {
mockStoreState.tabsByWorktree[WORKTREE_ID] = [{ id: TAB_ID, launchAgent: 'claude' }]
mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ state: 'done' })
mockStoreState.retainedAgentsByPaneKey[PANE_KEY] = { agentType: 'claude' }
mockStoreState.paneForegroundAgentByPaneKey[PANE_KEY] = {
agent: 'command-code',
shellForeground: false
@@ -74,7 +74,8 @@ export function createParkedTerminalCommandStatusPolicy(options: {
return canCommandCodeOutputOwnPane({
foregroundAgent: foreground?.agent,
shellForeground: foreground?.shellForeground,
paneOwnerAgent
paneOwnerAgent,
retainedPaneOwnerAgent: state.retainedAgentsByPaneKey[paneKey]?.agentType
})
}
@@ -17,6 +17,7 @@ import { TERMINAL_PASTE_DIRECT_MAX_BYTES } from './terminal-paste-coordinator'
import { resolveWindowsShiftEnterEncodingForPane } from './terminal-windows-shift-enter'
import type * as UseNotificationDispatchModule from './use-notification-dispatch'
import { getEagerPtyBufferHandle } from './pty-dispatcher'
import type { AgentType } from '../../../../shared/agent-status-types'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { toAppSshPtyId } from '../../../../shared/ssh-pty-id'
import type { SshConnectionState } from '../../../../shared/ssh-types'
@@ -227,6 +228,7 @@ type StoreState = {
consumePendingSnapshot: ReturnType<typeof vi.fn>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
agentStatusByPaneKey: Record<string, unknown>
retainedAgentsByPaneKey: Record<string, { agentType: AgentType }>
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry>
sleepingAgentSessionsByPaneKey: Record<string, unknown>
suppressedPtyExitIds: Record<string, true>
@@ -928,6 +930,7 @@ describe('connectPanePty', () => {
consumePendingSnapshot: vi.fn(() => null),
runtimePaneTitlesByTabId: {},
agentStatusByPaneKey: {},
retainedAgentsByPaneKey: {},
paneForegroundAgentByPaneKey: {},
sleepingAgentSessionsByPaneKey: {},
suppressedPtyExitIds: {},
@@ -19044,6 +19047,45 @@ describe('connectPanePty', () => {
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toBe(claudeStatus)
})
it('rejects Command Code facts when retained Claude identity owns the pane', async () => {
enableMainAuthority()
const { connectPanePty } = await import('./pty-connection')
const handler = await import('./terminal-side-effect-facts-handler')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
vi.useFakeTimers()
const paneKey = makePaneKey('tab-1', LEAF_1)
const unknownStatus = {
paneKey,
state: 'working' as const,
prompt: '',
updatedAt: Date.now(),
stateStartedAt: Date.now(),
agentType: 'unknown' as const,
stateHistory: []
}
mockStoreState.retainedAgentsByPaneKey[paneKey] = { agentType: 'claude' }
mockStoreState.agentStatusByPaneKey[paneKey] = unknownStatus
connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never)
const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void
onPtySpawn('pty-fact-retained-false-cc')
mockStoreState.setAgentStatus.mockClear()
handler._dispatchTerminalSideEffectBatchForTest({
ptyId: 'pty-fact-retained-false-cc',
seq: 1,
facts: [
{ kind: 'command-code-working', prompt: 'False prompt' },
{ kind: 'command-code-done', prompt: 'False prompt' }
]
})
vi.advanceTimersByTime(2000)
expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled()
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toBe(unknownStatus)
})
it('keeps Command Code working when a working fact lands before the done settles', async () => {
enableMainAuthority()
const { connectPanePty } = await import('./pty-connection')
@@ -2820,11 +2820,13 @@ export function connectPanePty(
}
const canApplyCommandCodeOutputStatus = (): boolean => {
const foreground = useAppStore.getState().paneForegroundAgentByPaneKey[cacheKey]
const state = useAppStore.getState()
const foreground = state.paneForegroundAgentByPaneKey[cacheKey]
return canCommandCodeOutputOwnPane({
foregroundAgent: foreground?.agent,
shellForeground: foreground?.shellForeground,
paneOwnerAgent: getAuthoritativePaneAgent()
paneOwnerAgent: getAuthoritativePaneAgent(),
retainedPaneOwnerAgent: state.retainedAgentsByPaneKey[cacheKey]?.agentType
})
}
+43 -3
View File
@@ -29,7 +29,7 @@ describe('createCommandCodeOutputStatusDetector', () => {
})
expect(detector.observe('Thinking about unrelated shell output')).toBe(false)
expect(detector.observe('# Command Code v0.27.2')).toBe(false)
expect(detector.observe('# Command Code v0.27.2\r\n')).toBe(false)
expect(detector.observe('⌘ Parsing...')).toBe(true)
expect(onWorking).toHaveBeenCalledWith('')
@@ -43,7 +43,7 @@ describe('createCommandCodeOutputStatusDetector', () => {
})
expect(detector.observe('# Command')).toBe(false)
expect(detector.observe(' Code v0.27.2')).toBe(false)
expect(detector.observe(' Code v0.27.2\r\n')).toBe(false)
expect(detector.observe('⌘ Parsing...')).toBe(true)
expect(onWorking).toHaveBeenCalledWith('')
@@ -56,7 +56,7 @@ describe('createCommandCodeOutputStatusDetector', () => {
onWorking
})
expect(detector.observe('# C\x1b[35mommand Co\x1b[0mde v0.27.2')).toBe(false)
expect(detector.observe('# C\x1b[35mommand Co\x1b[0mde v0.27.2\r\n')).toBe(false)
expect(detector.observe('⌘ Parsing...')).toBe(true)
expect(onWorking).toHaveBeenCalledWith('')
@@ -87,6 +87,46 @@ describe('createCommandCodeOutputStatusDetector', () => {
expect(onWorking).not.toHaveBeenCalled()
})
it.each([
'# Command Code v1.2\r\n',
'# Command Code v1.2.3.4\r\n',
'# Command Code v01.2.3\r\n',
'# Command Code v1.2.3suffix\r\n',
'#\nCommand Code v1.2.3\r\n'
])('does not arm for noncanonical banner %j', (banner) => {
const onWorking = vi.fn()
const detector = createCommandCodeOutputStatusDetector({ startupCommand: null, onWorking })
expect(detector.observe(banner)).toBe(false)
expect(detector.observe('✻ Thinking...')).toBe(false)
expect(onWorking).not.toHaveBeenCalled()
})
it('does not arm from a four-part version split at a PTY chunk boundary', () => {
const onWorking = vi.fn()
const detector = createCommandCodeOutputStatusDetector({ startupCommand: null, onWorking })
expect(detector.observe('# Command Code v1.2.3')).toBe(false)
expect(detector.observe('.4\r\n')).toBe(false)
expect(detector.observe('✻ Thinking...')).toBe(false)
expect(onWorking).not.toHaveBeenCalled()
})
it.each(['# Command Code v1.2.3\r\n', '# Command Code v1.2.3-beta.1+build.9\n'])(
'arms for canonical banner %j',
(banner) => {
const onWorking = vi.fn()
const detector = createCommandCodeOutputStatusDetector({ startupCommand: null, onWorking })
expect(detector.observe(banner)).toBe(false)
expect(detector.observe('✻ Thinking...')).toBe(true)
expect(onWorking).toHaveBeenCalledWith('')
}
)
it.each([
'Pondering',
'Contemplating',
+17 -6
View File
@@ -112,7 +112,16 @@ const ACTIVE_EXECUTION_STATUS_RE = new RegExp(
`(?:^|[\\r\\n])\\s*(?:${COMMAND_CODE_STATUS_GLYPH_RE_SOURCE}\\s*)?(?:Executing:\\s+\\S|Running\\s*\\()`
)
const IDLE_PROMPT_RE = /(?:^|[\r\n])\s*[❯>]\s+Ask your question\.\.\./
const COMMAND_CODE_BANNER_RE = /(?:^|[\r\n])\s*#\s*Command Code\s+v\d+(?:\.\d+){1,3}\b/
const SEMVER_NUMBER_RE_SOURCE = '(?:0|[1-9]\\d*)'
const SEMVER_PRERELEASE_IDENTIFIER_RE_SOURCE = '(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)'
const SEMVER_BUILD_IDENTIFIER_RE_SOURCE = '[0-9A-Za-z-]+'
const COMMAND_CODE_BANNER_RE = new RegExp(
`(?:^|[\\r\\n])[ \\t]*#[ \\t]+Command Code[ \\t]+v` +
`${SEMVER_NUMBER_RE_SOURCE}\\.${SEMVER_NUMBER_RE_SOURCE}\\.${SEMVER_NUMBER_RE_SOURCE}` +
`(?:-${SEMVER_PRERELEASE_IDENTIFIER_RE_SOURCE}(?:\\.${SEMVER_PRERELEASE_IDENTIFIER_RE_SOURCE})*)?` +
`(?:\\+${SEMVER_BUILD_IDENTIFIER_RE_SOURCE}(?:\\.${SEMVER_BUILD_IDENTIFIER_RE_SOURCE})*)?` +
`(?=[ \\t]*[\\r\\n])`
)
function cleanPromptCandidate(value: string): string {
return cleanCommandCodePromptCandidate(stripTerminalControl(value))
@@ -233,17 +242,19 @@ export function createCommandCodeOutputStatusDetector(args: {
: scanRawText
if (!hasSeenCommandCodeUi) {
if (
!rawTextMayContainCommandCodeBanner(scanRawText) &&
!rawTextMayContainCommandCodeBanner(scanRawTextWithChunkBoundary)
) {
if (!rawTextMayContainCommandCodeBanner(scanRawText)) {
return false
}
const scanText = stripTerminalControl(scanRawText)
const scanTextWithChunkBoundary = stripTerminalControl(scanRawTextWithChunkBoundary)
const previousTextWithChunkBoundaryLength = previousRawText
? stripTerminalControl(`${previousRawText}\n`).length
: 0
if (
!COMMAND_CODE_BANNER_RE.test(scanText) &&
!COMMAND_CODE_BANNER_RE.test(scanTextWithChunkBoundary)
!COMMAND_CODE_BANNER_RE.test(
scanTextWithChunkBoundary.slice(previousTextWithChunkBoundaryLength)
)
) {
return false
}