Fix typed OMP remote title flicker (#6954)

This commit is contained in:
Dvitash 2026-07-03 03:26:37 -04:00 committed by GitHub
parent f1bcb77392
commit 0ff2c09002
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1108 additions and 28 deletions

View File

@ -12262,6 +12262,303 @@ describe('OrcaRuntimeService', () => {
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
})
it('normalizes Pi-compatible mobile session status to OMP for an unknown-launch foreground omp PTY', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' })
const getForegroundProcess = vi.fn(async () => 'omp')
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess
})
const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'typed-omp-tab',
leafId: HEADLESS_LEAF_ID,
title: 'Terminal'
})
runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123)
await new Promise<void>((resolve) => setImmediate(resolve))
const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(getForegroundProcess).toHaveBeenCalledWith('pty-typed-omp')
expect(result.tabs[0]).toEqual(
expect.objectContaining({
type: 'terminal',
title: 'OMP ready',
agentStatus: expect.objectContaining({
state: 'done',
agentType: 'omp',
terminalHandle: terminal.handle,
terminalTitle: 'OMP ready'
})
})
)
expect(result.tabs[0]).not.toHaveProperty('launchAgent')
})
it('waits for unknown-launch foreground owner before publishing Pi-compatible mobile status', async () => {
const foregroundProcess = deferred<string | null>()
const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' })
const getForegroundProcess = vi.fn(() => foregroundProcess.promise)
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess
})
const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'typed-omp-tab',
leafId: HEADLESS_LEAF_ID,
title: 'Terminal'
})
const events: RuntimeMobileSessionTabsResult[] = []
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123)
await new Promise<void>((resolve) => setImmediate(resolve))
expect(getForegroundProcess).toHaveBeenCalledWith('pty-typed-omp')
expect(events).toHaveLength(0)
foregroundProcess.resolve('omp')
await new Promise<void>((resolve) => setImmediate(resolve))
expect(events).toEqual([
expect.objectContaining({
tabs: [
expect.objectContaining({
type: 'terminal',
title: 'OMP ready',
agentStatus: expect.objectContaining({
state: 'done',
agentType: 'omp',
terminalHandle: terminal.handle,
terminalTitle: 'OMP ready'
})
})
]
})
])
unsubscribe()
})
it('keeps same-status Pi-compatible title changes queued behind the foreground owner probe', async () => {
const foregroundProcess = deferred<string | null>()
const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' })
const getForegroundProcess = vi.fn(() => foregroundProcess.promise)
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess
})
const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'typed-omp-tab',
leafId: HEADLESS_LEAF_ID,
title: 'Terminal'
})
const events: RuntimeMobileSessionTabsResult[] = []
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123)
await new Promise<void>((resolve) => setImmediate(resolve))
runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi idle\x07', 124)
await new Promise<void>((resolve) => setImmediate(resolve))
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
expect(events).toHaveLength(0)
foregroundProcess.resolve('omp')
await new Promise<void>((resolve) => setImmediate(resolve))
expect(events).toEqual([
expect.objectContaining({
tabs: [
expect.objectContaining({
type: 'terminal',
title: 'OMP ready',
agentStatus: expect.objectContaining({
state: 'done',
agentType: 'omp',
terminalHandle: terminal.handle,
terminalTitle: 'OMP ready'
})
})
]
})
])
unsubscribe()
})
it('coalesces same-status title frames behind one post-title foreground probe', async () => {
const staleForegroundProcess = deferred<string | null>()
const freshForegroundProcess = deferred<string | null>()
const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' })
const getForegroundProcess = vi
.fn()
.mockReturnValueOnce(staleForegroundProcess.promise)
.mockReturnValueOnce(freshForegroundProcess.promise)
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess
})
const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'typed-omp-tab',
leafId: HEADLESS_LEAF_ID,
title: 'Terminal'
})
const events: RuntimeMobileSessionTabsResult[] = []
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123)
await new Promise<void>((resolve) => setImmediate(resolve))
runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi idle\x07', 124)
runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi done\x07', 125)
await new Promise<void>((resolve) => setImmediate(resolve))
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
expect(events).toHaveLength(0)
staleForegroundProcess.resolve(null)
await new Promise<void>((resolve) => setImmediate(resolve))
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
expect(events).toHaveLength(0)
freshForegroundProcess.resolve('omp')
await new Promise<void>((resolve) => setImmediate(resolve))
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
expect(events).toEqual([
expect.objectContaining({
tabs: [
expect.objectContaining({
type: 'terminal',
title: 'OMP ready',
agentStatus: expect.objectContaining({
state: 'done',
agentType: 'omp',
terminalHandle: terminal.handle,
terminalTitle: 'OMP ready'
})
})
]
})
])
unsubscribe()
})
it('starts a post-title foreground probe when an older pending probe finds no owner', async () => {
const staleForegroundProcess = deferred<string | null>()
const freshForegroundProcess = deferred<string | null>()
const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' })
const getForegroundProcess = vi
.fn()
.mockReturnValueOnce(staleForegroundProcess.promise)
.mockReturnValueOnce(freshForegroundProcess.promise)
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess
})
const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'typed-omp-tab',
leafId: HEADLESS_LEAF_ID,
title: 'Terminal'
})
const events: RuntimeMobileSessionTabsResult[] = []
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
;(
runtime as unknown as {
refreshPtyForegroundAgentFromController: (ptyId: string) => Promise<boolean>
}
).refreshPtyForegroundAgentFromController('pty-typed-omp')
runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123)
await new Promise<void>((resolve) => setImmediate(resolve))
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
expect(events).toHaveLength(0)
staleForegroundProcess.resolve(null)
await new Promise<void>((resolve) => setImmediate(resolve))
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
expect(events).toHaveLength(0)
freshForegroundProcess.resolve('omp')
await new Promise<void>((resolve) => setImmediate(resolve))
expect(events).toEqual([
expect.objectContaining({
tabs: [
expect.objectContaining({
type: 'terminal',
title: 'OMP ready',
agentStatus: expect.objectContaining({
state: 'done',
agentType: 'omp',
terminalHandle: terminal.handle,
terminalTitle: 'OMP ready'
})
})
]
})
])
unsubscribe()
})
it('keeps Pi-compatible mobile session status as Pi for an unknown-launch foreground pi PTY', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-pi' })
const getForegroundProcess = vi.fn(async () => 'pi')
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess
})
const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
tabId: 'typed-pi-tab',
leafId: HEADLESS_LEAF_ID,
title: 'Terminal'
})
runtime.onPtyData('pty-typed-pi', '\x1b]0;Pi ready\x07', 123)
await new Promise<void>((resolve) => setImmediate(resolve))
const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(getForegroundProcess).toHaveBeenCalledWith('pty-typed-pi')
expect(result.tabs[0]).toEqual(
expect.objectContaining({
type: 'terminal',
title: 'Pi ready',
agentStatus: expect.objectContaining({
state: 'done',
agentType: 'pi',
terminalHandle: terminal.handle,
terminalTitle: 'Pi ready'
})
})
)
expect(result.tabs[0]).not.toHaveProperty('launchAgent')
})
it('keeps renderer-vetted mobile agent status for custom-titled terminals', async () => {
const runtime = new OrcaRuntimeService(store)
const leafId = '11111111-1111-4111-8111-111111111111'

View File

@ -20,6 +20,7 @@ import {
type AgentStatusEntry
} from '../../shared/agent-status-types'
import {
hasCompatibleAgentTitleIdentity,
normalizeCompatibleAgentStatusEntryForOwner,
normalizeCompatibleAgentTitleForOwner
} from '../../shared/agent-title-owner'
@ -942,6 +943,12 @@ type RuntimePtyWorktreeRecord = {
waitBlockedAt: number | null
}
type PtyForegroundAgentRefresh = {
promise: Promise<boolean>
startedAfterTitleObservation: number
requestedAfterTitleObservation: number
}
function copySleepingAgentLaunchConfig(
config: SleepingAgentLaunchConfig
): SleepingAgentLaunchConfig {
@ -1925,7 +1932,8 @@ export class OrcaRuntimeService {
private resolvedWorktreeGeneration = 0
private cloneInFlightByPath = new Map<string, Promise<void>>()
private agentDetector: AgentDetector | null = null
private ptyForegroundAgentRefreshes = new Map<string, Promise<void>>()
private ptyForegroundAgentRefreshes = new Map<string, PtyForegroundAgentRefresh>()
private ptyDelayedForegroundSnapshotTitleObservations = new Map<string, number>()
private _orchestrationDb: OrchestrationDb | null = null
private messageWaitersByHandle = new Map<string, Set<MessageWaiter>>()
// Why: mobile clients subscribe to terminal output via terminal.subscribe.
@ -3253,7 +3261,7 @@ export class OrcaRuntimeService {
/**
* Publishes a PTY-backed terminal tab snapshot to the synced mobile session,
* normalizing Pi-compatible titles based on launch ownership.
* normalizing Pi-compatible titles based on launch or foreground ownership.
*/
private publishPtyBackedMobileSessionTerminal(
worktreeId: string,
@ -3269,9 +3277,10 @@ export class OrcaRuntimeService {
}
): void {
const existing = this.mobileSessionTabsByWorktree.get(worktreeId)
const ownerAgent = pty.launchAgent ?? pty.foregroundAgent
const title = normalizeCompatibleAgentTitleForOwner(
args.title ?? getLatestPtyTitle(pty) ?? 'Terminal',
pty.launchAgent
ownerAgent
)
const existingTab = existing?.tabs.find(
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
@ -5194,11 +5203,25 @@ export class OrcaRuntimeService {
if (agentStatus === 'idle' && prevStatus !== 'idle') {
this.resolvePtyTuiIdleWaiters(pty, ptyId)
}
const shouldDelayMobileSnapshot =
shouldTouchPtyBackedSessionTabs &&
this.shouldDelayPtyBackedMobileSnapshotForForegroundAgent(pty, oscTitle)
let foregroundRefresh: Promise<boolean> | undefined
// Why: gate on an actual status transition — braille spinner frames
// mutate the title every tick, so probing per-title-change would stream
// a foreground query per frame during active work.
if (prevStatus !== pty.lastAgentStatus) {
this.refreshPtyForegroundAgent(ptyId)
foregroundRefresh = this.refreshPtyForegroundAgentFromController(ptyId, {
afterTitleObservation: observedAt
})
} else if (shouldDelayMobileSnapshot) {
// Why: same-status compatible title changes can arrive before the
// foreground owner probe settles; publishing them would flicker.
foregroundRefresh = this.getPendingForegroundAgentRefreshForTitle(ptyId, observedAt)
}
if (foregroundRefresh && shouldDelayMobileSnapshot) {
shouldTouchPtyBackedSessionTabs = false
this.delayPtyBackedMobileSnapshotForForegroundAgent(ptyId, observedAt, foregroundRefresh)
}
}
}
@ -8718,6 +8741,15 @@ export class OrcaRuntimeService {
}
}
private shouldDelayPtyBackedMobileSnapshotForForegroundAgent(
pty: RuntimePtyWorktreeRecord,
title: string
): boolean {
return (
!pty.launchAgent && pty.foregroundAgent === null && hasCompatibleAgentTitleIdentity(title)
)
}
/**
* Schedules an asynchronous query to check which agent process is currently
* running in the foreground of a PTY.
@ -8726,19 +8758,75 @@ export class OrcaRuntimeService {
void this.refreshPtyForegroundAgentFromController(ptyId)
}
private getPendingForegroundAgentRefreshForTitle(
ptyId: string,
titleObservedAt: number
): Promise<boolean> | undefined {
if (!this.ptyForegroundAgentRefreshes.has(ptyId)) {
return undefined
}
return this.refreshPtyForegroundAgentFromController(ptyId, {
afterTitleObservation: titleObservedAt
})
}
private delayPtyBackedMobileSnapshotForForegroundAgent(
ptyId: string,
titleObservedAt: number,
foregroundRefresh: Promise<boolean>
): void {
this.ptyDelayedForegroundSnapshotTitleObservations.set(ptyId, titleObservedAt)
void foregroundRefresh.then((foregroundAgentChanged) => {
if (this.ptyDelayedForegroundSnapshotTitleObservations.get(ptyId) !== titleObservedAt) {
return
}
this.ptyDelayedForegroundSnapshotTitleObservations.delete(ptyId)
if (!foregroundAgentChanged) {
this.touchMobileSessionSnapshotsForPty(ptyId)
}
})
}
/**
* Deduplicates and manages in-flight foreground agent refresh queries
* for a specific PTY.
*/
private refreshPtyForegroundAgentFromController(ptyId: string): Promise<void> {
private refreshPtyForegroundAgentFromController(
ptyId: string,
options: { afterTitleObservation?: number } = {}
): Promise<boolean> {
const startedAfterTitleObservation = options.afterTitleObservation ?? 0
const pendingRefresh = this.ptyForegroundAgentRefreshes.get(ptyId)
if (pendingRefresh) {
return pendingRefresh
pendingRefresh.requestedAfterTitleObservation = Math.max(
pendingRefresh.requestedAfterTitleObservation,
startedAfterTitleObservation
)
return pendingRefresh.promise
}
const refresh = this.loadPtyForegroundAgentFromController(ptyId).finally(() => {
this.ptyForegroundAgentRefreshes.delete(ptyId)
const entry: PtyForegroundAgentRefresh = {
promise: Promise.resolve(false),
startedAfterTitleObservation,
requestedAfterTitleObservation: startedAfterTitleObservation
}
const refresh = (async (): Promise<boolean> => {
while (true) {
entry.startedAfterTitleObservation = entry.requestedAfterTitleObservation
const foregroundAgentChanged = await this.loadPtyForegroundAgentFromController(ptyId)
if (
foregroundAgentChanged ||
entry.requestedAfterTitleObservation <= entry.startedAfterTitleObservation
) {
return foregroundAgentChanged
}
}
})().finally(() => {
if (this.ptyForegroundAgentRefreshes.get(ptyId) === entry) {
this.ptyForegroundAgentRefreshes.delete(ptyId)
}
})
this.ptyForegroundAgentRefreshes.set(ptyId, refresh)
entry.promise = refresh
this.ptyForegroundAgentRefreshes.set(ptyId, entry)
return refresh
}
@ -8746,34 +8834,35 @@ export class OrcaRuntimeService {
* Queries the PTY controller for the active foreground process, identifies if it
* is a recognized agent, and updates the PTY's foreground agent state if changed.
*/
private async loadPtyForegroundAgentFromController(ptyId: string): Promise<void> {
private async loadPtyForegroundAgentFromController(ptyId: string): Promise<boolean> {
if (!this.ptyController) {
return
return false
}
const pty = this.ptysById.get(ptyId)
if (!pty?.connected) {
return
return false
}
// Why: foregroundAgent is only consulted as the owner fallback when
// launchAgent is unknown, so a known launchAgent makes the relay
// getForegroundProcess round-trip pure waste (covers all launched agents).
if (pty.launchAgent) {
return
return false
}
let foregroundProcess: string | null
try {
foregroundProcess = await this.ptyController.getForegroundProcess(ptyId)
} catch {
return
return false
}
const foregroundAgent = foregroundProcess
? (recognizeAgentProcess(foregroundProcess)?.agent ?? null)
: null
if (pty.foregroundAgent === foregroundAgent) {
return
return false
}
pty.foregroundAgent = foregroundAgent
this.touchMobileSessionSnapshotsForPty(ptyId)
return true
}
private getFreshExplicitAgentStatusForHandle(handle: string): {

View File

@ -230,6 +230,9 @@ vi.mock('@/lib/agent-status', async (importOriginal) => {
if (/Codex( working)?/.test(title)) {
return /working/.test(title) ? 'working' : 'idle'
}
if (/^\s*(?:[\u2800-\u28ff]\s+)?(?:Pi|OMP)(?: ready| idle)?\s*$/i.test(title)) {
return /[\u2800-\u28ff]/u.test(title) ? 'working' : 'idle'
}
return null
})
}
@ -726,15 +729,18 @@ describe('connectPanePty', () => {
markWorktreeUnread: vi.fn(),
observeTerminalGitHubPullRequestLink: vi.fn(),
recordTerminalInput: vi.fn(),
setAgentStatus: vi.fn((paneKey: string, payload: Record<string, unknown>) => {
mockStoreState.agentStatusByPaneKey[paneKey] = {
...payload,
paneKey,
updatedAt: Date.now(),
stateStartedAt: Date.now(),
stateHistory: []
setAgentStatus: vi.fn(
(paneKey: string, payload: Record<string, unknown>, terminalTitle?: string | null) => {
mockStoreState.agentStatusByPaneKey[paneKey] = {
...payload,
paneKey,
...(terminalTitle ? { terminalTitle } : {}),
updatedAt: Date.now(),
stateStartedAt: Date.now(),
stateHistory: []
}
}
}),
),
removeAgentStatus: vi.fn(),
dropAgentStatus: vi.fn(),
markTerminalTabUnread: vi.fn(),
@ -924,6 +930,451 @@ describe('connectPanePty', () => {
)
})
it('normalizes Pi-compatible remote runtime status to OMP after typed omp command', async () => {
const { connectPanePty } = await import('./pty-connection')
enableActiveRuntimeEnvironment()
const paneKey = makePaneKey('tab-1', LEAF_1)
const pane = createPane(1)
const transport = createMockTransport('remote:web-env-1@@pty-omp')
transportFactoryQueue.push(transport)
const manager = createManager(1, 1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
sendTerminalInputThroughPane(pane, 'omp\r')
await flushAsyncTicks()
const onTitleChange = createdTransportOptions[0]?.onTitleChange as
| ((title: string, rawTitle: string) => void)
| undefined
const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as
| ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void)
| undefined
if (!onTitleChange || !onAgentStatus) {
throw new Error('missing remote PTY callbacks')
}
onTitleChange('Pi ready', 'Pi ready')
onAgentStatus({
state: 'done',
prompt: '',
agentType: 'pi'
})
expect(transport.sendInput).toHaveBeenCalledWith('omp\r')
expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready')
expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'OMP ready')
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({
state: 'done',
agentType: 'omp',
terminalTitle: 'OMP ready'
})
})
it('normalizes after shell word deletion edits a typed command to omp', async () => {
const { connectPanePty } = await import('./pty-connection')
enableActiveRuntimeEnvironment()
const paneKey = makePaneKey('tab-1', LEAF_1)
const pane = createPane(1)
const transport = createMockTransport('remote:web-env-1@@pty-omp-edited')
transportFactoryQueue.push(transport)
const manager = createManager(1, 1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
sendTerminalInputThroughPane(pane, 'pi \x17omp\r')
await flushAsyncTicks()
const onTitleChange = createdTransportOptions[0]?.onTitleChange as
| ((title: string, rawTitle: string) => void)
| undefined
const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as
| ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void)
| undefined
if (!onTitleChange || !onAgentStatus) {
throw new Error('missing remote PTY callbacks')
}
onTitleChange('Pi ready', 'Pi ready')
onAgentStatus({
state: 'done',
prompt: '',
agentType: 'pi'
})
expect(transport.sendInput).toHaveBeenCalledWith('pi \x17omp\r')
expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready')
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({
state: 'done',
agentType: 'omp',
terminalTitle: 'OMP ready'
})
})
it('keeps Pi-compatible remote runtime status as Pi after typed pi command', async () => {
const { connectPanePty } = await import('./pty-connection')
enableActiveRuntimeEnvironment()
const paneKey = makePaneKey('tab-1', LEAF_1)
const pane = createPane(1)
const transport = createMockTransport('remote:web-env-1@@pty-pi')
transportFactoryQueue.push(transport)
const manager = createManager(1, 1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
sendTerminalInputThroughPane(pane, 'pi\r')
await flushAsyncTicks()
const onTitleChange = createdTransportOptions[0]?.onTitleChange as
| ((title: string, rawTitle: string) => void)
| undefined
const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as
| ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void)
| undefined
if (!onTitleChange || !onAgentStatus) {
throw new Error('missing remote PTY callbacks')
}
onTitleChange('Pi ready', 'Pi ready')
onAgentStatus({
state: 'done',
prompt: '',
agentType: 'pi'
})
expect(transport.sendInput).toHaveBeenCalledWith('pi\r')
expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready')
expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Pi ready')
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({
state: 'done',
agentType: 'pi',
terminalTitle: 'Pi ready'
})
})
it('does not infer shell ownership from prompts typed inside an existing Pi session', async () => {
const { connectPanePty } = await import('./pty-connection')
enableActiveRuntimeEnvironment()
const paneKey = makePaneKey('tab-1', LEAF_1)
const now = Date.now()
mockStoreState.agentStatusByPaneKey[paneKey] = {
state: 'done',
prompt: '',
agentType: 'pi',
paneKey,
terminalTitle: 'Pi ready',
updatedAt: now,
stateStartedAt: now,
stateHistory: []
}
mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: 'Pi ready' } }
const pane = createPane(1)
const transport = createMockTransport('remote:web-env-1@@pty-pi-prompt')
transportFactoryQueue.push(transport)
const manager = createManager(1, 1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
sendTerminalInputThroughPane(pane, 'omp\r')
await flushAsyncTicks()
const onTitleChange = createdTransportOptions[0]?.onTitleChange as
| ((title: string, rawTitle: string) => void)
| undefined
const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as
| ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void)
| undefined
if (!onTitleChange || !onAgentStatus) {
throw new Error('missing remote PTY callbacks')
}
onTitleChange('Pi ready', 'Pi ready')
onAgentStatus({
state: 'done',
prompt: '',
agentType: 'pi'
})
expect(transport.sendInput).toHaveBeenCalledWith('omp\r')
expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready')
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({
state: 'done',
agentType: 'pi',
terminalTitle: 'Pi ready'
})
})
it('does not infer shell ownership from prompts typed in a title-only Pi session', async () => {
const { connectPanePty } = await import('./pty-connection')
enableActiveRuntimeEnvironment()
const paneKey = makePaneKey('tab-1', LEAF_1)
mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: 'Pi ready' } }
const pane = createPane(1)
const transport = createMockTransport('remote:web-env-1@@pty-pi-title-only')
transportFactoryQueue.push(transport)
const manager = createManager(1, 1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
sendTerminalInputThroughPane(pane, 'omp\r')
await flushAsyncTicks()
const onTitleChange = createdTransportOptions[0]?.onTitleChange as
| ((title: string, rawTitle: string) => void)
| undefined
const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as
| ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void)
| undefined
if (!onTitleChange || !onAgentStatus) {
throw new Error('missing remote PTY callbacks')
}
onTitleChange('Pi ready', 'Pi ready')
onAgentStatus({
state: 'done',
prompt: '',
agentType: 'pi'
})
expect(transport.sendInput).toHaveBeenCalledWith('omp\r')
expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Pi ready')
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({
state: 'done',
agentType: 'pi',
terminalTitle: 'Pi ready'
})
})
it('lets a new typed omp command override a stale retained done status', async () => {
const { connectPanePty } = await import('./pty-connection')
enableActiveRuntimeEnvironment()
const paneKey = makePaneKey('tab-1', LEAF_1)
const now = Date.now()
mockStoreState.agentStatusByPaneKey[paneKey] = {
state: 'done',
prompt: '',
agentType: 'pi',
paneKey,
terminalTitle: 'Pi ready',
updatedAt: now,
stateStartedAt: now,
stateHistory: []
}
const pane = createPane(1)
const transport = createMockTransport('remote:web-env-1@@pty-stale-done')
transportFactoryQueue.push(transport)
const manager = createManager(1, 1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
sendTerminalInputThroughPane(pane, 'omp\r')
await flushAsyncTicks()
const onTitleChange = createdTransportOptions[0]?.onTitleChange as
| ((title: string, rawTitle: string) => void)
| undefined
const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as
| ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void)
| undefined
if (!onTitleChange || !onAgentStatus) {
throw new Error('missing remote PTY callbacks')
}
onTitleChange('Pi ready', 'Pi ready')
onAgentStatus({
state: 'done',
prompt: '',
agentType: 'pi'
})
expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'OMP ready')
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({
state: 'done',
agentType: 'omp',
terminalTitle: 'OMP ready'
})
})
it('tracks cursor edits when inferring a typed omp command', async () => {
const { connectPanePty } = await import('./pty-connection')
enableActiveRuntimeEnvironment()
const paneKey = makePaneKey('tab-1', LEAF_1)
const pane = createPane(1)
const transport = createMockTransport('remote:web-env-1@@pty-cursor-edit')
transportFactoryQueue.push(transport)
const manager = createManager(1, 1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
sendTerminalInputThroughPane(pane, 'op\x1b[Dm\r')
await flushAsyncTicks()
const onTitleChange = createdTransportOptions[0]?.onTitleChange as
| ((title: string, rawTitle: string) => void)
| undefined
const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as
| ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void)
| undefined
if (!onTitleChange || !onAgentStatus) {
throw new Error('missing remote PTY callbacks')
}
onTitleChange('Pi ready', 'Pi ready')
onAgentStatus({
state: 'done',
prompt: '',
agentType: 'pi'
})
expect(transport.sendInput).toHaveBeenCalledWith('op\x1b[Dm\r')
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({
state: 'done',
agentType: 'omp',
terminalTitle: 'OMP ready'
})
})
it('tracks delete-key cursor edits when inferring a typed omp command', async () => {
const { connectPanePty } = await import('./pty-connection')
enableActiveRuntimeEnvironment()
const paneKey = makePaneKey('tab-1', LEAF_1)
const pane = createPane(1)
const transport = createMockTransport('remote:web-env-1@@pty-delete-edit')
transportFactoryQueue.push(transport)
const manager = createManager(1, 1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
sendTerminalInputThroughPane(pane, 'ommp\x1b[D\x1b[D\x1b[3~\r')
await flushAsyncTicks()
const onTitleChange = createdTransportOptions[0]?.onTitleChange as
| ((title: string, rawTitle: string) => void)
| undefined
const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as
| ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void)
| undefined
if (!onTitleChange || !onAgentStatus) {
throw new Error('missing remote PTY callbacks')
}
onTitleChange('Pi ready', 'Pi ready')
onAgentStatus({
state: 'done',
prompt: '',
agentType: 'pi'
})
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({
state: 'done',
agentType: 'omp',
terminalTitle: 'OMP ready'
})
})
it('skips manual agent inference for large paste chunks', async () => {
const { connectPanePty } = await import('./pty-connection')
enableActiveRuntimeEnvironment()
const paneKey = makePaneKey('tab-1', LEAF_1)
const pane = createPane(1)
const transport = createMockTransport('remote:web-env-1@@pty-large-paste')
transportFactoryQueue.push(transport)
const manager = createManager(1, 1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
sendTerminalInputThroughPane(pane, `${'x'.repeat(4097)}omp\r`)
await flushAsyncTicks()
const onTitleChange = createdTransportOptions[0]?.onTitleChange as
| ((title: string, rawTitle: string) => void)
| undefined
const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as
| ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void)
| undefined
if (!onTitleChange || !onAgentStatus) {
throw new Error('missing remote PTY callbacks')
}
onTitleChange('Pi ready', 'Pi ready')
onAgentStatus({
state: 'done',
prompt: '',
agentType: 'pi'
})
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({
state: 'done',
agentType: 'pi',
terminalTitle: 'Pi ready'
})
})
it('resumes manual agent inference when large paste input is cancelled', async () => {
const { connectPanePty } = await import('./pty-connection')
enableActiveRuntimeEnvironment()
const paneKey = makePaneKey('tab-1', LEAF_1)
const pane = createPane(1)
const transport = createMockTransport('remote:web-env-1@@pty-cancelled-large-paste')
transportFactoryQueue.push(transport)
const manager = createManager(1, 1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
sendTerminalInputThroughPane(pane, 'x'.repeat(4097))
sendTerminalInputThroughPane(pane, '\x03')
sendTerminalInputThroughPane(pane, 'omp\r')
await flushAsyncTicks()
const onTitleChange = createdTransportOptions[0]?.onTitleChange as
| ((title: string, rawTitle: string) => void)
| undefined
const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as
| ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void)
| undefined
if (!onTitleChange || !onAgentStatus) {
throw new Error('missing remote PTY callbacks')
}
onTitleChange('Pi ready', 'Pi ready')
onAgentStatus({
state: 'done',
prompt: '',
agentType: 'pi'
})
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({
state: 'done',
agentType: 'omp',
terminalTitle: 'OMP ready'
})
})
it('preserves typed shell ownership through same-chunk command-finished side effects', async () => {
const { connectPanePty } = await import('./pty-connection')
enableActiveRuntimeEnvironment()
const paneKey = makePaneKey('tab-1', LEAF_1)
const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null }
const pane = createPane(1)
const transport = createMockTransport('remote:web-env-1@@pty-command-finished')
transport.connect.mockImplementation(
async ({ callbacks }: { callbacks?: ConnectCallbacks }) => {
dataCallbackRef.current = callbacks?.onData ?? null
return 'remote:web-env-1@@pty-command-finished'
}
)
transportFactoryQueue.push(transport)
const manager = createManager(1, 1)
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
sendTerminalInputThroughPane(pane, 'omp\r')
await flushAsyncTicks()
const onTitleChange = createdTransportOptions[0]?.onTitleChange as
| ((title: string, rawTitle: string) => void)
| undefined
const onAgentStatus = createdTransportOptions[0]?.onAgentStatus as
| ((payload: { state: 'done'; prompt: string; agentType: 'pi' }) => void)
| undefined
const dataCallback = dataCallbackRef.current
if (!dataCallback || !onTitleChange || !onAgentStatus) {
throw new Error('missing remote PTY callbacks')
}
dataCallback('\x1b]133;D;0\x07')
onTitleChange('Pi ready', 'Pi ready')
onAgentStatus({
state: 'done',
prompt: '',
agentType: 'pi'
})
expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({
state: 'done',
agentType: 'omp',
terminalTitle: 'OMP ready'
})
})
it('queues visible bulk output off the synchronous xterm write path', async () => {
const { connectPanePty } = await import('./pty-connection')
const pane = createPane(1)

View File

@ -89,7 +89,11 @@ import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stabl
import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event'
import { e2eConfig } from '@/lib/e2e-config'
import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types'
import {
AGENT_STATUS_STALE_AFTER_MS,
type AgentStatusEntry,
type AgentType
} from '../../../../shared/agent-status-types'
import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id'
import {
createAgentInterruptInference,
@ -156,6 +160,10 @@ import {
normalizeCompatibleAgentTitleForOwner,
resolveCompatibleAgentTypeForOwner
} from '../../../../shared/agent-title-owner'
import {
isExpectedAgentProcess,
recognizeAgentProcessFromCommandLine
} from '../../../../shared/agent-process-recognition'
import type { SetupSplitDirection, TuiAgent } from '../../../../shared/types'
import { isWslUncPath } from '../../../../shared/wsl-paths'
import { TUI_AGENT_CONFIG } from '../../../../shared/tui-agent-config'
@ -165,7 +173,6 @@ import {
beginAgentStartupDeliveryAttempt,
releaseAgentStartupDeliveryAttempt
} from '@/lib/agent-startup-delayed-delivery'
import { isExpectedAgentProcess } from '../../../../shared/agent-process-recognition'
const pendingSpawnByPaneKey = new Map<string, Promise<string | null>>()
const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
@ -176,6 +183,7 @@ const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1500
const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000
const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500
const SSH_SHELL_READY_STARTUP_FALLBACK_MS = 1500
const MANUAL_AGENT_COMMAND_MAX_CHARS = 4096
const STARTUP_DRAFT_PASTE_QUIET_MS = 1500
const STARTUP_DRAFT_PASTE_TIMEOUT_MS = 8000
const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000
@ -1238,12 +1246,219 @@ export function connectPanePty(
}
/**
* Resolves the authoritative owner agent type for this pane, checking tab launch,
* pane startup, and store state configuration.
* pane startup, typed command ownership, and store state configuration.
*
* Why: launch ownership wins so Pi-compatible live titles/hooks can't repaint an
* OMP-owned pane back to Pi; the stored status agentType is only the last-resort
* fallback because it can itself be a Pi-compatible frame.
* OMP-owned pane back to Pi; command ownership covers manually typed `omp`
* in generic terminals where launch metadata does not exist.
*/
let commandInferredPaneAgent: TuiAgent | null = null
let pendingShellCommandLine = ''
let pendingShellCommandCursor = 0
let commandInferredPaneAgentGeneration = 0
let shellCommandInferenceSuspendedUntilCommandEnd = false
const resetPendingShellCommandLine = (): void => {
pendingShellCommandLine = ''
pendingShellCommandCursor = 0
}
const rememberCommandInferredPaneAgent = (): void => {
const commandLine = pendingShellCommandLine.trim()
resetPendingShellCommandLine()
const nextAgent = commandLine
? (recognizeAgentProcessFromCommandLine(commandLine)?.agent ?? null)
: null
commandInferredPaneAgent = nextAgent
commandInferredPaneAgentGeneration += 1
}
const clearCommandInferredPaneAgent = (): void => {
commandInferredPaneAgent = null
resetPendingShellCommandLine()
commandInferredPaneAgentGeneration += 1
}
const clearCommandInferredPaneAgentAfterPtySideEffects = (): void => {
const generation = commandInferredPaneAgentGeneration
resetPendingShellCommandLine()
queueMicrotask(() => {
setTimeout(() => {
if (commandInferredPaneAgentGeneration === generation) {
clearCommandInferredPaneAgent()
}
}, 0)
})
}
const appendPendingShellCommandInput = (text: string): void => {
const available = MANUAL_AGENT_COMMAND_MAX_CHARS - pendingShellCommandLine.length
if (available <= 0) {
shellCommandInferenceSuspendedUntilCommandEnd = true
return
}
const inserted = text.slice(0, available)
pendingShellCommandLine =
pendingShellCommandLine.slice(0, pendingShellCommandCursor) +
inserted +
pendingShellCommandLine.slice(pendingShellCommandCursor)
pendingShellCommandCursor += inserted.length
if (inserted.length < text.length) {
shellCommandInferenceSuspendedUntilCommandEnd = true
}
}
const deletePendingShellCommandWord = (): void => {
const beforeCursor = pendingShellCommandLine.slice(0, pendingShellCommandCursor)
const afterCursor = pendingShellCommandLine.slice(pendingShellCommandCursor)
const nextBeforeCursor = beforeCursor.replace(/[^\S\r\n]*\S+[^\S\r\n]*$/, '')
pendingShellCommandLine = nextBeforeCursor + afterCursor
pendingShellCommandCursor = nextBeforeCursor.length
}
const cancelSuspendedShellCommandInference = (): void => {
if (!shellCommandInferenceSuspendedUntilCommandEnd) {
return
}
shellCommandInferenceSuspendedUntilCommandEnd = false
resetPendingShellCommandLine()
}
const deletePendingShellCommandCharacter = (): void => {
if (pendingShellCommandCursor === 0) {
return
}
pendingShellCommandLine =
pendingShellCommandLine.slice(0, pendingShellCommandCursor - 1) +
pendingShellCommandLine.slice(pendingShellCommandCursor)
pendingShellCommandCursor -= 1
}
const deletePendingShellCommandCharacterAtCursor = (): void => {
if (pendingShellCommandCursor >= pendingShellCommandLine.length) {
return
}
pendingShellCommandLine =
pendingShellCommandLine.slice(0, pendingShellCommandCursor) +
pendingShellCommandLine.slice(pendingShellCommandCursor + 1)
}
const movePendingShellCommandCursor = (delta: number): void => {
pendingShellCommandCursor = Math.min(
pendingShellCommandLine.length,
Math.max(0, pendingShellCommandCursor + delta)
)
}
const consumeShellCommandCsiSequence = (data: string, index: number): number | null => {
if (data.charCodeAt(index) !== 0x1b || data[index + 1] !== '[') {
return null
}
let cursor = index + 2
while (cursor < data.length && /[0-9;?]/.test(data[cursor]!)) {
cursor += 1
}
const final = data[cursor]
if (!final || !/[~A-Za-z]/.test(final)) {
return null
}
const params = data.slice(index + 2, cursor)
if (final === 'D') {
movePendingShellCommandCursor(-1)
} else if (final === 'C') {
movePendingShellCommandCursor(1)
} else if (final === 'H' || (final === '~' && params === '1')) {
pendingShellCommandCursor = 0
} else if (final === 'F' || (final === '~' && params === '4')) {
pendingShellCommandCursor = pendingShellCommandLine.length
} else if (final === '~' && params === '3') {
deletePendingShellCommandCharacterAtCursor()
} else if (final === '~' && (params === '200' || params === '201')) {
// Bracketed paste wrappers are terminal framing, not shell command text.
} else {
resetPendingShellCommandLine()
}
return cursor + 1
}
const getLivePaneAgentTitle = (): string | null => {
const state = useAppStore.getState()
const runtimeTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
const tabTitle = (state.tabsByWorktree[deps.worktreeId] ?? []).find(
(entry) => entry.id === deps.tabId
)?.title
return runtimeTitle ?? tabTitle ?? null
}
const hasFreshPaneAgentSurface = (): boolean => {
const state = useAppStore.getState()
const entry = state.agentStatusByPaneKey[cacheKey]
const now = Date.now()
const entryIsFresh =
entry &&
typeof entry.updatedAt === 'number' &&
now - entry.updatedAt <= AGENT_STATUS_STALE_AFTER_MS
if (entryIsFresh && entry.state !== 'done') {
return true
}
const liveTitle = getLivePaneAgentTitle()
return detectAgentStatusFromTitle(liveTitle ?? '') !== null
}
const observeAcceptedShellCommandInput = (data: string): void => {
if (commandInferredPaneAgent) {
return
}
// Why: bytes typed inside a live agent TUI are prompt text, not shell
// commands, even if they spell another agent binary name.
if (hasFreshPaneAgentSurface()) {
resetPendingShellCommandLine()
return
}
if (shellCommandInferenceSuspendedUntilCommandEnd) {
if (data.includes('\x03') || data.includes('\x15')) {
shellCommandInferenceSuspendedUntilCommandEnd = false
resetPendingShellCommandLine()
}
if (data.includes('\r') || data.includes('\n')) {
shellCommandInferenceSuspendedUntilCommandEnd = false
}
return
}
if (data.length > MANUAL_AGENT_COMMAND_MAX_CHARS) {
resetPendingShellCommandLine()
shellCommandInferenceSuspendedUntilCommandEnd = !data.includes('\r') && !data.includes('\n')
return
}
for (let index = 0; index < data.length; index += 1) {
const char = data[index]!
if (char === '\r' || char === '\n') {
shellCommandInferenceSuspendedUntilCommandEnd = false
rememberCommandInferredPaneAgent()
if (commandInferredPaneAgent) {
return
}
continue
}
if (char === '\x7f' || char === '\b') {
deletePendingShellCommandCharacter()
continue
}
if (char === '\x17') {
deletePendingShellCommandWord()
continue
}
if (char === '\x03' || char === '\x15') {
resetPendingShellCommandLine()
continue
}
if (char === '\x1b') {
const nextIndex = consumeShellCommandCsiSequence(data, index)
if (nextIndex !== null) {
index = nextIndex - 1
continue
}
resetPendingShellCommandLine()
continue
}
if (char < ' ') {
resetPendingShellCommandLine()
continue
}
if (char >= ' ') {
appendPendingShellCommandInput(char)
if (shellCommandInferenceSuspendedUntilCommandEnd) {
return
}
}
}
}
const getAuthoritativePaneAgent = (): AgentType | undefined => {
const state = useAppStore.getState()
const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find(
@ -1253,6 +1468,7 @@ export function connectPanePty(
tab?.launchAgent ??
paneStartup?.launchAgent ??
paneStartup?.initialAgentStatus?.agent ??
commandInferredPaneAgent ??
state.agentStatusByPaneKey[cacheKey]?.agentType
)
}
@ -1522,6 +1738,7 @@ export function connectPanePty(
}
const commandLifecycle = createTerminalCommandLifecycle({
onCommandFinished: () => {
clearCommandInferredPaneAgentAfterPtySideEffects()
// Why: the finished command may have moved HEAD or the index (e.g.
// `git checkout`); nudge git UI now instead of waiting for a poll.
dispatchTerminalCommandFinishedEvent(deps.worktreeId)
@ -2192,6 +2409,7 @@ export function connectPanePty(
}
}
const onAgentExited = (): void => {
clearCommandInferredPaneAgent()
// Why: when the terminal title reverts to a plain shell (e.g., "bash", "zsh"),
// the agent has exited. Clear any running cache timer so the sidebar doesn't
// show a stale countdown for a tab that no longer has an active Claude session.
@ -2461,6 +2679,11 @@ export function connectPanePty(
// excluded because those transports do not expose sendInputAccepted.
const acknowledgedIntent = intent ?? inferIntentFromExactTerminalInput(data)
if (acknowledgedIntent && transport.sendInputAccepted) {
if (acknowledgedIntent === 'ctrl-c') {
// Why: the accepted-write callback is async; let the next command be
// inferred if the user cancelled an oversized line and immediately typed.
cancelSuspendedShellCommandInference()
}
clearPendingTerminalInputIntent()
markTerminalInputSent()
const writePromise = transport
@ -2468,6 +2691,7 @@ export function connectPanePty(
.then((accepted) => {
if (accepted) {
recordAcceptedTerminalInputForHibernation()
observeAcceptedShellCommandInput(data)
observeAcceptedTerminalInput(data, acknowledgedIntent)
interruptInference.observeInputIntent(acknowledgedIntent)
observeTitleOnlyInterrupt()
@ -2482,6 +2706,7 @@ export function connectPanePty(
if (intent) {
if (transport.sendInput(data)) {
markAcceptedTerminalInputSent()
observeAcceptedShellCommandInput(data)
observeAcceptedTerminalInput(data, intent)
}
clearPendingTerminalInputIntent()
@ -2489,6 +2714,7 @@ export function connectPanePty(
}
if (transport.sendInput(data)) {
markAcceptedTerminalInputSent()
observeAcceptedShellCommandInput(data)
observeAcceptedTerminalInput(data)
observeSentTerminalInputIntent(data)
} else {

View File

@ -8,6 +8,7 @@ import {
MAX_OSC_TITLE_CHARS
} from './agent-detection'
import {
hasCompatibleAgentTitleIdentity,
normalizeCompatibleAgentStatusEntryForOwner,
normalizeCompatibleAgentTitleForOwner,
resolveCompatibleAgentTypeForOwner
@ -114,6 +115,14 @@ describe('Pi-compatible title detection', () => {
expect(normalizeCompatibleAgentTitleForOwner('\u280b Pi', 'codex')).toBe('\u280b Pi')
})
it('identifies titles whose compatible identity can be re-owned', () => {
expect(hasCompatibleAgentTitleIdentity('Pi ready')).toBe(true)
expect(hasCompatibleAgentTitleIdentity('π - tmp')).toBe(true)
expect(hasCompatibleAgentTitleIdentity('\u280b OMP')).toBe(true)
expect(hasCompatibleAgentTitleIdentity('Fix pi bugs')).toBe(false)
expect(hasCompatibleAgentTitleIdentity('\u280b Codex')).toBe(false)
})
it('normalizes Pi-compatible status identity and terminal title to the owner', () => {
const status = normalizeCompatibleAgentStatusEntryForOwner(
{

View File

@ -43,6 +43,14 @@ function getProfileForTitle(title: string): TitleProfileMatch | null {
return null
}
/**
* Why: unknown-launch remote sessions need to wait for foreground ownership
* before publishing title frames whose identity can be re-owned.
*/
export function hasCompatibleAgentTitleIdentity(title: string): boolean {
return Boolean(getProfileForTitle(title)?.profile.titleIdentityGroup)
}
/**
* Detects the agent status (working, permission, idle) from a terminal title,
* accounting for legacy Pi titles.