fix(sidebar): show Cursor rows and stop a stray "claude" title hijacking OpenCode (#12466)

* fix(sidebar): show Cursor rows and stop a stray "claude" title hijacking OpenCode

Two defects in the same title-resolution path.

**#10258** — Cursor's only native OSC title is the literal `cursor agent`, which both title trackers dropped unconditionally. A hookless Cursor pane therefore had neither a status entry nor any title carrying Cursor identity, so the worktree card showed nothing at all.

**#8940** — two owner-blind paths let an incidental `claude` token anywhere in an OpenCode session or task title outrank the pane's known owner, so the tab icon and sidebar row flipped to Claude Code.

#10258: let the literal through exactly once as identity, so a restored or mobile tab keeps its Cursor row instead of vanishing. #8940: require an *identity frame* — after stripping status decoration the title must PRESENT Claude, not merely mention it — before a Claude title may reclaim a pane from its prior identity, and make the sidebar row builder owner-aware.

> These two are in one PR because they share the `ownerAgentType` plumbing through `buildTitleDerivedAgentRow` — split apart, neither half compiles on its own.

Fixes #10258
Fixes #8940

Co-authored-by: Orca <help@stably.ai>

* test(e2e): add recordable proof for sidebar-agent-row-identity

Fails on origin/main, passes on this branch.

Test: sidebar keeps a Cursor pane visible and an OpenCode pane out of Claude Code hands

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): preserve restored Cursor identity

* test(terminal): cover restored Cursor redraw suppression

* refactor(terminal): tighten Cursor identity handling and Claude frame matching

Review follow-ups on the title-resolution path:

- pty-transport dropped a native Cursor literal that main emits whenever a
  non-Cursor title preceded it, re-introducing the #10258 blank row in the
  renderer path. The pre-filter now projects the predecessor the drain will
  actually see, and defers to the drain gate while facts are still queued.
- applyTrackedPtyTitle threaded the cursor flag through 12 sites, including
  ptyRecordChanged bookkeeping the sole caller ignores. Force the status null
  once, and the activity-gated effects fall out unchanged.
- isClaudeIdentityFrameTitle missed a multiplexer-wrapped Claude title
  ("zsh | Claude Code"), costing a genuine Claude pane its identity. Reuse
  the ' | ' segment split that agent-title-owner already had inline.
- Keep title normalization on launchAgent: it only rewrites within an
  identity group (OMP wraps Pi), so a split does not make it wrong, and
  the hook-row path normalizes the same way.
- Drop the tab.ptyId tracker fallback, which read a pty that the pane
  identity check had just rejected.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-08-09 01:32:34 -07:00 committed by GitHub
parent ea8881a3c7
commit 970696a008
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 856 additions and 90 deletions

View File

@ -9087,6 +9087,103 @@ describe('OrcaRuntimeService', () => {
expect(runtime.getPtyOutputSequence('pty-1')).toBe(0)
})
it('emits live Cursor identity without storing it as liveness evidence', async () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
runtime.onPtyData('pty-1', '\x1b]0;Cursor Agent\x07', 100)
expect(batches.flatMap((batch) => batch.facts)).toEqual([
{ kind: 'title', normalizedTitle: 'Cursor Agent', rawTitle: 'Cursor Agent' }
])
expect((await runtime.listTerminals()).terminals[0].title).not.toBe('Cursor Agent')
expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toMatchObject({
facts: [{ kind: 'title', normalizedTitle: 'Cursor Agent', rawTitle: 'Cursor Agent' }]
})
})
it('keeps live Cursor identity in mobile titles without making it agent liveness', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: TEST_WORKTREE_ID,
title: 'Terminal 1',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: TEST_WORKTREE_ID,
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId: 'pty-1'
}
],
mobileSessionTabs: [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'epoch-cursor',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: 'tab-1::pane:1',
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: 'tab-1::pane:1',
parentTabId: 'tab-1',
leafId: 'pane:1',
ptyId: 'pty-1',
title: 'Terminal 1',
isActive: true
}
]
}
]
})
runtime.onPtyData('pty-1', '\x1b]0;Cursor Agent\x07', 100)
const terminal = (await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs[0]
expect(terminal).toMatchObject({ type: 'terminal', title: 'Cursor Agent' })
expect(terminal).not.toHaveProperty('agentStatus')
})
it('lets an explicit terminal rename override cached Cursor identity and restores it after clearing', async () => {
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
const created = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`)
runtime.onPtyData('pty-1', '\x1b]0;Cursor Agent\x07', 100)
const mobileTerminal = (
await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
).tabs.find((tab) => tab.type === 'terminal')
if (mobileTerminal?.type !== 'terminal' || !mobileTerminal.terminal) {
throw new Error('expected mobile terminal handle')
}
expect(mobileTerminal.terminal).toBe(created.handle)
await runtime.renameTerminal(mobileTerminal.terminal, 'Pinned Cursor')
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs[0]).toMatchObject(
{ type: 'terminal', title: 'Pinned Cursor' }
)
await runtime.renameTerminal(mobileTerminal.terminal, null)
expect((await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs[0]).toMatchObject(
{ type: 'terminal', title: 'Cursor Agent' }
)
})
it('confirms title-based agent exits against the foreground process', async () => {
const { runtime, batches } = createSideEffectRuntime()
syncSinglePty(runtime)
@ -9898,12 +9995,12 @@ describe('OrcaRuntimeService', () => {
expect(runtime.getTerminalSideEffectSnapshot('pty-unknown')).toBeNull()
})
it('drops the cursor-agent literal from record-fallback snapshots', () => {
it('keeps the cursor-agent literal in record-fallback snapshots only without a tracker title', () => {
const { runtime } = createSideEffectRuntime()
syncSinglePty(runtime)
runtime.onPtyData('pty-1', 'plain output\n', 100)
// Simulate a record title restored by a path that bypassed the tracker (which itself refuses to store the bare native title).
// Simulate a record title restored by a path that bypassed the tracker.
const records = (
runtime as unknown as {
ptysById: Map<string, { lastOscTitle: string | null }>
@ -9911,7 +10008,17 @@ describe('OrcaRuntimeService', () => {
).ptysById
records.get('pty-1')!.lastOscTitle = 'Cursor Agent'
expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toBeNull()
// Why: a hookless Cursor pane has no other identity to restore (#10258).
expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toMatchObject({
facts: [{ kind: 'title', normalizedTitle: 'Cursor Agent', rawTitle: 'Cursor Agent' }]
})
// A synthesized Cursor title owns the pane; the bare literal must not replay over it.
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07')
expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toMatchObject({
facts: [{ kind: 'title', normalizedTitle: '⠋ Cursor Agent', rawTitle: '⠋ Cursor Agent' }]
})
})
it('emits the chunk agentStatus events before its side-effect batch', () => {

View File

@ -27,6 +27,7 @@ import type {
import {
createTerminalTitleTracker,
stripBrailleSpinnerGlyphs,
type TerminalTitleFactMeta,
type TerminalTitleTracker
} from '../../shared/terminal-output-side-effects'
import { createCommandCodeOutputStatusDetector } from '../../shared/command-code-output-status'
@ -10210,11 +10211,14 @@ export class OrcaRuntimeService {
getTerminalSideEffectSnapshot(ptyId: string): TerminalSideEffectBatch | null {
const tracker = this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker
const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle
// Why: the cursor-agent literal drop applies to every title surface; a
// record-fallback snapshot must not replay the bare native title the
// tracker would have refused to emit live.
const rawTitle = recordTitle && !isCursorNativeAgentTitle(recordTitle) ? recordTitle : null
const normalizedTitle = tracker?.getLastNormalizedTitle() ?? null
// Why: a record-fallback snapshot must not replay the bare cursor-agent literal over a
// tracker title Orca synthesized from hooks — but with no tracker title it is the pane's
// only Cursor identity, so restored/mobile tabs keep it (#10258).
const rawTitle =
recordTitle && (normalizedTitle === null || !isCursorNativeAgentTitle(recordTitle))
? recordTitle
: null
if (normalizedTitle === null && !rawTitle) {
return null
}
@ -10248,12 +10252,37 @@ export class OrcaRuntimeService {
return null
}
private isLiveCursorNativeTitle(rawTitle: string, meta?: TerminalTitleFactMeta): boolean {
return isCursorNativeAgentTitle(rawTitle) && meta?.staleWorkingTitleClear !== true
}
/** Display fallback for identities intentionally omitted from liveness records. */
private getTrackedDisplayTitleForPty(ptyId: string): string | null {
return (
this.getTrackedRawTitleForPty(ptyId) ??
this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.getLastNormalizedTitle() ??
null
)
}
private getUnpersistedTrackedTitleForPty(ptyId: string | null): string | null {
if (!ptyId || this.getTrackedRawTitleForPty(ptyId) !== null) {
return null
}
// Why: a manual title is authoritative until explicitly cleared with null.
const pty = this.ptysById.get(ptyId)
if (pty && pty.title !== null) {
return null
}
return this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.getLastNormalizedTitle() ?? null
}
/** Why: synthetic agent title frames no longer ride pty:data, so neither
* renderer xterm nor the headless emulator observes them. Mobile-parity
* snapshot titles must prefer main's tracker over snapshot lastTitle, or
* hook-driven spinner/idle titles vanish from mobile tabs. */
private preferTrackedLastTitle<T extends { lastTitle?: string }>(ptyId: string, snapshot: T): T {
const tracked = this.getTrackedRawTitleForPty(ptyId)
const tracked = this.getTrackedDisplayTitleForPty(ptyId)
if (!tracked) {
return snapshot
}
@ -10296,8 +10325,11 @@ export class OrcaRuntimeService {
rawTitle,
...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {})
})
const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle)
if (!changed) {
const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta)
// Why: an identity-only cursor title records nothing, so on a fresh pane `changed` is
// false — but the tracker title is that pane's only Cursor identity and still has to
// fan out to the sidebar and mobile (#10258).
if (!changed && !this.isLiveCursorNativeTitle(rawTitle, meta)) {
return
}
const live = this.ptyTitleTrackersByPtyId.get(ptyId)
@ -10377,25 +10409,42 @@ export class OrcaRuntimeService {
/** Apply one observed OSC title (raw form) to the PTY and leaf records.
* Returns true when the PTY record's title or status changed. */
private applyTrackedPtyTitle(ptyId: string, rawTitle: string, normalizedTitle: string): boolean {
private applyTrackedPtyTitle(
ptyId: string,
rawTitle: string,
normalizedTitle: string,
meta?: TerminalTitleFactMeta
): boolean {
// Why: status is detected from the RAW title (mirrors the renderer tracker),
// so working/idle transitions are unaffected by normalization; the records
// store the NORMALIZED title so rotating Grok/Pi/Gemini frames collapse to
// one stable stored label (#7880) instead of churning `ps`/mobile tabs.
const agentStatus = detectAgentStatusFromTitle(rawTitle)
//
// Why the identity-only case: the bare cursor-agent literal identifies the pane without
// asserting activity, so it records NO title/status evidence — only the tracker keeps it,
// for display (#10258). Nulling the status here rather than trusting the detector keeps
// that contract local, since every activity-gated effect below is keyed on status.
const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta)
const recordedTitle = identityOnlyTitle ? null : normalizedTitle
const agentStatus = identityOnlyTitle ? null : detectAgentStatusFromTitle(rawTitle)
let ptyRecordChanged = false
const pty = this.ptysById.get(ptyId)
if (pty) {
const prevStatus = pty.lastAgentStatus
const prevTitle = pty.lastOscTitle
const observedAt = this.nextTitleObservationSequence()
pty.lastOscTitle = normalizedTitle
pty.lastOscTitleAt = observedAt
pty.lastOscTitleEpochMs = Date.now()
pty.lastOscTitle = recordedTitle
pty.lastOscTitleAt = identityOnlyTitle ? null : observedAt
pty.lastOscTitleEpochMs = identityOnlyTitle ? null : Date.now()
pty.lastAgentStatus = agentStatus
pty.lastAgentStatusObservedLive = true
this.setPtyManagementTitleFromObservedTitle(pty, normalizedTitle, observedAt)
ptyRecordChanged = prevTitle !== normalizedTitle || prevStatus !== agentStatus
if (identityOnlyTitle) {
pty.managementTitle = null
pty.managementTitleAt = null
} else {
this.setPtyManagementTitleFromObservedTitle(pty, normalizedTitle, observedAt)
}
ptyRecordChanged = prevTitle !== recordedTitle || prevStatus !== agentStatus
if (agentStatus === 'idle' && prevStatus !== 'idle') {
this.resolvePtyTuiIdleWaiters(pty, ptyId)
}
@ -10428,10 +10477,10 @@ export class OrcaRuntimeService {
// daemon-hosted terminals (no renderer pushing pane titles) had no
// way to clear a stale 'working' status after the agent exited and
// the shell took over the title — the stuck-spinner bug in #1437.
leaf.lastOscTitle = normalizedTitle
leaf.lastOscTitleAt = this.nextTitleObservationSequence()
const prevStatus = leaf.lastAgentStatus
const prevObservedLive = leaf.lastAgentStatusObservedLive
leaf.lastOscTitle = recordedTitle
leaf.lastOscTitleAt = identityOnlyTitle ? null : this.nextTitleObservationSequence()
// Why: when a new OSC title doesn't classify as an agent state (e.g.
// bare shell title after the agent exits), clear lastAgentStatus so
// it is no longer sticky. Tui-idle waiters that needed the previous
@ -30335,6 +30384,12 @@ export class OrcaRuntimeService {
const hookAgentStatus = tab.agentStatus
? this.getHookAgentRowForPane(getHookRowsForPane(paneKey))
: null
// Why not tab.ptyId: findPtyForMobileTerminalTab already rejected it when it returned
// null, because persisted ids can collide with an unrelated pane after restart — reading
// that pane's tracker would publish its title here, ahead of every other source.
const trackerOnlyTitle = this.getUnpersistedTrackedTitleForPty(
liveLeafPtyId ?? pty?.ptyId ?? null
)
const leafTitle = leaf
? getLatestAgentCandidateTitle(
{ title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt },
@ -30362,7 +30417,7 @@ export class OrcaRuntimeService {
pty?.foregroundAgent ??
null
const title = normalizeCompatibleAgentTitleForOwner(
leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title,
trackerOnlyTitle ?? leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title,
ownerAgent
)
const liveTitleEvidence = leafTitle ?? ptyTitle
@ -30532,6 +30587,9 @@ export class OrcaRuntimeService {
? { providerSession: hookRow.providerSession }
: {}
const leaf = this.leaves.get(this.getLeafKey(tab.parentTabId, tab.leafId)) ?? null
const trackerOnlyTitle = this.getUnpersistedTrackedTitleForPty(
pty?.ptyId ?? leaf?.ptyId ?? null
)
const ptyTitle = pty
? getLatestAgentCandidateTitle(
{ title: pty.title, updatedAt: pty.titleUpdatedAt },
@ -30572,7 +30630,7 @@ export class OrcaRuntimeService {
pty?.foregroundAgent ??
null
const terminalTitle = normalizeCompatibleAgentTitleForOwner(
(pty ? getLatestPtyTitle(pty) : null) ?? tab.title,
trackerOnlyTitle ?? (pty ? getLatestPtyTitle(pty) : null) ?? tab.title,
ownerAgent
)
// Why: OSC 9999 hook payload carries real state/prompt/agent; without preferring it, hook-only transitions never surfaced (#7970).

View File

@ -271,4 +271,97 @@ describe('buildTitleDerivedAgentRows', () => {
expect(rows).toHaveLength(0)
})
// #10258: Cursor's native title is deliberately status-less, which used to hide the pane.
it('adds an idle Cursor row for the bare native cursor-agent title', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { launchAgent: 'cursor', title: 'Cursor Agent' })],
entries: [],
retained: [],
runtimePaneTitlesByTabId: { 'tab-1': { 1: 'Cursor Agent' } },
ptyIdsByTabId: { 'tab-1': ['pty-cursor'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
now: 2000
})
expect(rows.map((row) => [row.agentType, row.state, row.entry.lastAssistantMessage])).toEqual([
['cursor', 'idle', 'Idle']
])
})
it('keeps the Cursor row running while a synthesized spinner title is painted', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { launchAgent: 'cursor' })],
entries: [],
retained: [],
runtimePaneTitlesByTabId: { 'tab-1': { 1: '⠋ Cursor Agent' } },
ptyIdsByTabId: { 'tab-1': ['pty-cursor'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
now: 2000
})
expect(rows.map((row) => [row.agentType, row.state])).toEqual([['cursor', 'working']])
})
// #8940: an OpenCode pane's own task text must not hand the row to Claude Code.
it('keeps an OpenCode-launched pane OpenCode across its own status frames', () => {
const frames: [string, string][] = [
['OC | ⠋ ask claude about this', 'working'],
['⠋ OpenCode', 'working'],
['⠋ use Claude Sonnet', 'working'],
['⠋ claude 스타일로 리팩터', 'working'],
['. Compare Opencode Vs Orca', 'working'],
['OpenCode ready', 'idle']
]
for (const [title, state] of frames) {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { launchAgent: 'opencode' })],
entries: [],
retained: [],
runtimePaneTitlesByTabId: { 'tab-1': { 1: title } },
ptyIdsByTabId: { 'tab-1': ['pty-opencode'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
now: 2000
})
expect(rows.map((row) => [row.agentType, row.state, row.entry.prompt])).toEqual([
['opencode', state, 'OpenCode']
])
}
})
it('still resolves Claude from a title that presents Claude, owner or not', () => {
const rowsFor = (title: string, launchAgent?: TuiAgent) =>
buildWorktreeAgentRows({
tabs: [makeTab('tab-1', launchAgent ? { launchAgent } : {})],
entries: [],
retained: [],
runtimePaneTitlesByTabId: { 'tab-1': { 1: title } },
ptyIdsByTabId: { 'tab-1': ['pty-agent'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
now: 2000
})
expect(rowsFor('⠋ Claude Code').map((row) => row.agentType)).toEqual(['claude'])
// Pane reuse: the user exited OpenCode and ran claude in the same pane.
expect(rowsFor('✳ Claude Code', 'opencode').map((row) => row.agentType)).toEqual(['claude'])
// No owner to defend the pane: naming Claude stays the only available identity.
expect(rowsFor('⠋ use Claude Sonnet').map((row) => row.agentType)).toEqual(['claude'])
expect(rowsFor('zsh', 'opencode')).toHaveLength(0)
})
it('does not brand a split pane with the tab-scoped launch agent', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { launchAgent: 'opencode' })],
entries: [],
retained: [],
runtimePaneTitlesByTabId: { 'tab-1': { 1: '⠋ implementing the feature' } },
ptyIdsByTabId: { 'tab-1': ['pty-a', 'pty-b'] },
terminalLayoutsByTabId: { 'tab-1': makeSplitLayout() },
now: 2000
})
expect(rows).toHaveLength(0)
})
})

View File

@ -1,6 +1,6 @@
import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData'
import { formatAgentTypeLabel, isClaudeManagementTitle } from '@/lib/agent-status'
import { containsBrailleSpinner } from '../../../../shared/agent-title-core'
import { isCursorAgentTitle } from '../../../../shared/agent-title-core'
import { classifyTitleActivity, resolveTitleActivityLabel } from '@/lib/pane-agent-evidence'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import type {
@ -19,6 +19,8 @@ import {
normalizeCompatibleAgentTitleForOwner,
resolveCompatibleAgentTypeForOwner
} from '../../../../shared/agent-title-owner'
import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner'
import { isClaudeIdentityFrameTitle } from '../../../../shared/terminal-title-agent-type'
const EMPTY_RUNTIME_TITLES: Record<string, Record<number, string>> = {}
const EMPTY_LIVE_PTY_IDS: Record<string, string[]> = {}
@ -84,6 +86,7 @@ export function buildTitleDerivedAgentRows(args: {
tab,
leafId,
title,
ownerAgentType: resolveTitleDerivedPaneOwner(tab, layout, leafId),
now: args.now,
runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey
})
@ -104,6 +107,7 @@ export function buildTitleDerivedAgentRows(args: {
tab,
leafId,
title: tab.title,
ownerAgentType: resolveTitleDerivedPaneOwner(tab, layout, leafId),
now: args.now,
runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey
})
@ -125,15 +129,24 @@ function buildTitleDerivedAgentRow(args: {
tab: TerminalTab
leafId: string
title: string
ownerAgentType: AgentType | null
now: number
runtimeAgentOrchestrationByPaneKey?: Record<string, AgentStatusOrchestrationContext>
}): DashboardAgentRow | null {
// Why launchAgent, not ownerAgentType: this only rewrites a title within its own identity
// group (OMP wraps Pi and emits Pi frames), which stays correct in a split. Pane ownership
// is a separate, stricter question — it decides identity, so it uses ownerAgentType below.
const title = normalizeCompatibleAgentTitleForOwner(args.title, args.tab.launchAgent)
const isClaudeAgentsTitle = isClaudeManagementTitle(title)
// Why: `claude agents` is a live Claude Code Agent Teams surface, but the
// shared detector keeps it neutral so runtime liveness probes do not treat
// the management/list screen as active work.
const status = isClaudeAgentsTitle ? 'idle' : classifyTitleActivity(title)
// Why (cursor): the native `cursor agent` literal is deliberately status-less so a
// redraw cannot stomp hook state — but it still identifies a live pane, so the row
// reads idle instead of vanishing (#10258).
const status = isClaudeAgentsTitle
? 'idle'
: (classifyTitleActivity(title) ?? (isCursorAgentTitle(title) ? 'idle' : null))
const label = isClaudeAgentsTitle ? 'Claude Code' : resolveTitleActivityLabel(title)
if (!status || !label) {
return null
@ -143,16 +156,15 @@ function buildTitleDerivedAgentRow(args: {
}
const paneKey = makePaneKey(args.tab.id, args.leafId)
const orchestration = args.runtimeAgentOrchestrationByPaneKey?.[paneKey]
const titleAgentType = isClaudeAgentsTitle ? 'claude' : resolveTitleDerivedAgentType(title, label)
// Why: a braille spinner proves activity, not identity, so the resolver drops
// it. Hook-less agents over SSH (Codex, #8711) surface only spinner+cwd titles;
// fall back to the tab's launch identity instead of hiding the pane. Gated on
// the spinner on purpose — unlike the hook path's unconditional launchAgent
// fallback (resolveRowAgentType), this path manufactures agent-ness from a
// title alone, so a non-agent title must never become a row. Residual: a split
// pane whose own title carries a braille glyph is still attributed to launchAgent.
const agentType =
titleAgentType ?? (containsBrailleSpinner(title) ? (args.tab.launchAgent ?? null) : null)
const titleAgentType = isClaudeAgentsTitle
? 'claude'
: resolveTitleDerivedAgentType(title, label, args.ownerAgentType)
// Why: a status frame proves activity, not identity, so the resolver drops it.
// Hook-less agents over SSH (Codex, #8711; OpenCode's '. '/'* ' frames, #8940)
// surface only decorated task titles; fall back to the pane's known owner instead
// of hiding the pane. Safe because the `!status || !label` gate above already
// rejects plain shell titles — this path must never manufacture a row from one.
const agentType = titleAgentType ?? args.ownerAgentType
if (!agentType) {
return null
}
@ -184,7 +196,11 @@ function buildTitleDerivedAgentRow(args: {
}
}
export function resolveTitleDerivedAgentType(title: string, label: string): AgentType | null {
export function resolveTitleDerivedAgentType(
title: string,
label: string,
ownerAgentType?: AgentType | null
): AgentType | null {
const agentType = TITLE_AGENT_LABEL_TO_TYPE[label] ?? 'unknown'
if (agentType !== 'claude') {
return agentType
@ -192,7 +208,29 @@ export function resolveTitleDerivedAgentType(title: string, label: string): Agen
// Why: Claude's task-title spinner heuristic has no provider identity. In
// split panes it can match arbitrary terminal spinners, so sidebar rows only
// accept Claude when the title itself names Claude.
return CLAUDE_AGENT_TOKEN_RE.test(title) ? agentType : null
if (!CLAUDE_AGENT_TOKEN_RE.test(title)) {
return null
}
// Why: a "claude" word inside another agent's task text is a mention, not identity.
// Only a title that PRESENTS Claude may take a pane away from its known owner (#8940).
const owner = ownerAgentType && ownerAgentType !== 'unknown' ? ownerAgentType : null
if (owner && owner !== 'claude' && !isClaudeIdentityFrameTitle(title)) {
return null
}
return agentType
}
function resolveTitleDerivedPaneOwner(
tab: TerminalTab,
layout: TerminalLayoutSnapshot | undefined,
leafId: string
): AgentType | null {
// Why: launchAgent is tab-scoped, so it is pane ownership only while the tab has one
// leaf; applying it inside a split would let one pane brand its sibling.
if (layout?.root?.type !== 'leaf' || layout.root.leafId !== leafId) {
return null
}
return resolvePaneAgentOwner({ launchAgent: tab.launchAgent })
}
/**
@ -210,7 +248,7 @@ export function resolveAgentTypeFromTerminalTitle(
const label = resolveTitleActivityLabel(normalizedTitle)
return label
? (resolveCompatibleAgentTypeForOwner(
resolveTitleDerivedAgentType(normalizedTitle, label),
resolveTitleDerivedAgentType(normalizedTitle, label, ownerAgentType),
ownerAgentType
) ?? null)
: null

View File

@ -175,14 +175,25 @@ describe('startParkedTerminalByteWatcher', () => {
dispose()
})
it('drops the bare cursor-agent native title before it reaches the store', async () => {
// Why: a hookless Cursor pane has no other identity, so the literal reaches the store
// once (#10258); its redraw repeats must not stomp a synthesized Cursor title.
it('stores the bare cursor-agent native title once, then keeps the synthetic title', async () => {
const { dispose } = await startWatcher()
emit('\x1b]0;Cursor Agent\x07')
emit('\x1b]0;Cursor Agent\x07')
emit('\x1b]0;⠋ Cursor Agent\x07')
emit('\x1b]0;Cursor Agent\x07')
flushSideEffects()
expect(mockStoreState.setRuntimePaneTitle).not.toHaveBeenCalled()
expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled()
expect(mockStoreState.setRuntimePaneTitle.mock.calls).toEqual([
[TAB_ID, PANE_ID, 'Cursor Agent'],
[TAB_ID, PANE_ID, '⠋ Cursor Agent']
])
expect(mockStoreState.updateTabTitle.mock.calls).toEqual([
[TAB_ID, 'Cursor Agent'],
[TAB_ID, '⠋ Cursor Agent']
])
dispose()
})

View File

@ -801,7 +801,8 @@ describe('createIpcPtyTransport', () => {
await vi.runOnlyPendingTimersAsync()
expect(vi.getTimerCount()).toBe(0)
expect(onTitleChange).not.toHaveBeenCalled()
// Why: the literal is the pane's identity once (#10258); the redraw repeats stay ignored.
expect(onTitleChange.mock.calls).toEqual([['Cursor Agent', 'Cursor Agent']])
} finally {
vi.useRealTimers()
}

View File

@ -4,7 +4,9 @@ import {
clearWorkingIndicators,
createAgentStatusTracker,
normalizeTerminalTitle,
extractAllOscTitles
extractAllOscTitles,
isCursorNativeAgentTitle,
shouldSuppressCursorNativeTitle
} from '../../../../shared/agent-detection'
import {
isTerminalInputTooLargeWithDeferredMeasurement,
@ -112,27 +114,27 @@ type PendingPtySideEffect = {
suppressAttentionEvents: boolean
}
function isIgnoredCursorNativeTitle(title: string): boolean {
return title.trim().toLowerCase() === 'cursor agent'
}
function removeIgnoredCursorNativeTitles(titles: string[]): boolean {
// Why: mirrors main's applyObservedTitle — the literal survives whenever the title before it
// is not already Cursor-owned, which is how a pane re-establishes Cursor identity after the
// shell prompt repaints the title (#10258). Only the redraw repeats are dropped, so they cost
// neither an allocation nor a drain slot; `processObservedTitles` re-checks and stays
// authoritative, so this must never drop a title that gate would have emitted.
function removeSuppressedCursorNativeTitles(
titles: string[],
precedingTitle: string | null
): boolean {
let writeIndex = 0
let removed = false
for (let readIndex = 0; readIndex < titles.length; readIndex += 1) {
const title = titles[readIndex]
if (isIgnoredCursorNativeTitle(title)) {
removed = true
let previousTitle = precedingTitle
for (const title of titles) {
if (isCursorNativeAgentTitle(title) && shouldSuppressCursorNativeTitle(previousTitle)) {
continue
}
if (writeIndex !== readIndex) {
titles[writeIndex] = title
}
previousTitle = normalizeTerminalTitle(title)
titles[writeIndex] = title
writeIndex += 1
}
if (removed) {
titles.length = writeIndex
}
const removed = writeIndex < titles.length
titles.length = writeIndex
return removed
}
@ -176,6 +178,10 @@ export function createPtyOutputProcessor({
retained: () => pendingSideEffects.length
}
const disposePendingSideEffectGauge = registerPtySideEffectPendingGauge(pendingSideEffectGauge)
const initialAgentStatusTitle =
initialAgentTitle !== undefined && !isCursorNativeAgentTitle(initialAgentTitle)
? initialAgentTitle
: undefined
const agentTracker =
onAgentBecameIdle || onAgentBecameWorking || onAgentExited
? createAgentStatusTracker(
@ -184,7 +190,7 @@ export function createPtyOutputProcessor({
},
onAgentBecameWorking,
onAgentExited,
initialAgentTitle
initialAgentStatusTitle
)
: null
@ -288,7 +294,13 @@ export function createPtyOutputProcessor({
const scannedForTitles = Boolean(onTitleChange && data.includes('\x1b]'))
const titles = scannedForTitles ? extractAllOscTitles(data) : []
// Why: Cursor emits this ignored title every redraw; keep one queue fact instead of an allocation and drain slot per frame.
const ignoredCursorNativeTitle = removeIgnoredCursorNativeTitles(titles)
// Why the drained check: `lastEmittedTitle` only advances on drain, so while facts are
// still queued it is not the predecessor the drain will see — leave that call to the gate.
const drained = pendingSideEffectIndex >= pendingSideEffects.length
const ignoredCursorNativeTitle = removeSuppressedCursorNativeTitles(
titles,
drained ? lastEmittedTitle : null
)
const deliveredPayloads =
onAgentStatus && !suppressAttentionEvents && payloads.length > 0 ? payloads : []
const containsBell = Boolean(
@ -436,6 +448,14 @@ export function createPtyOutputProcessor({
if (titles.length > 0) {
clearStaleTitleTimer()
for (const title of titles) {
if (isCursorNativeAgentTitle(title)) {
// Why: identity for a hookless Cursor pane (#10258), never activity — the literal's
// null status would read as an agent exit, and a repeat must not stomp hook state.
if (!shouldSuppressCursorNativeTitle(lastEmittedTitle)) {
applyObservedTerminalTitle(title, true)
}
continue
}
applyObservedTerminalTitle(title, suppressAgentTracker)
}
} else if (titleScanEffect === 'ignored-cursor-native') {

View File

@ -28,16 +28,20 @@ type TitleFactEvent =
type TitleFactPath = {
events: TitleFactEvent[]
feed: (chunk: string) => void
flush: () => void
}
function createRendererPath(): TitleFactPath {
// Why deferDrain: flushing per chunk hides schedule-vs-drain divergence, because the renderer
// decides what to queue against a `lastEmittedTitle` that only later chunks advance.
function createRendererPath(initialTitle?: string, deferDrain = false): TitleFactPath {
const events: TitleFactEvent[] = []
const processor = createPtyOutputProcessor({
onTitleChange: (normalized, raw) => events.push({ kind: 'title', normalized, raw }),
onAgentBecameWorking: () => events.push({ kind: 'became-working' }),
onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }),
onAgentExited: () => events.push({ kind: 'agent-exited' }),
onBell: () => events.push({ kind: 'bell' })
onBell: () => events.push({ kind: 'bell' }),
initialAgentTitle: initialTitle
})
const callbacks = { onData: () => {} }
return {
@ -47,28 +51,36 @@ function createRendererPath(): TitleFactPath {
// Why: the renderer defers side effects behind a setTimeout(0) drain to
// protect xterm paint. Flush synchronously so both paths observe each
// chunk at the same fake-timer instant.
processor.flushPendingSideEffects()
}
if (!deferDrain) {
processor.flushPendingSideEffects()
}
},
flush: () => processor.flushPendingSideEffects()
}
}
function createMainPath(): TitleFactPath {
function createMainPath(initialTitle?: string): TitleFactPath {
const events: TitleFactEvent[] = []
// Why: mirrors OrcaRuntimeService.onPtyData — the per-PTY OSC 9999
// processor strips status payloads before the title tracker sees the chunk.
const processAgentStatusChunk = createAgentStatusOscProcessor()
const tracker = createTerminalTitleTracker({
onTitle: (normalized, raw) => events.push({ kind: 'title', normalized, raw }),
onAgentBecameWorking: () => events.push({ kind: 'became-working' }),
onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }),
onAgentExited: () => events.push({ kind: 'agent-exited' }),
onBell: () => events.push({ kind: 'bell' })
})
const tracker = createTerminalTitleTracker(
{
onTitle: (normalized, raw) => events.push({ kind: 'title', normalized, raw }),
onAgentBecameWorking: () => events.push({ kind: 'became-working' }),
onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }),
onAgentExited: () => events.push({ kind: 'agent-exited' }),
onBell: () => events.push({ kind: 'bell' })
},
initialTitle !== undefined ? { initialTitle } : undefined
)
return {
events,
feed(chunk: string): void {
tracker.handleChunk(processAgentStatusChunk(chunk).cleanData)
}
},
// Why: main applies every title inline, so there is nothing to defer.
flush: () => {}
}
}
@ -115,6 +127,63 @@ describe('main title tracker parity with the renderer transport processor', () =
expect(paths.main.events).toContainEqual({ kind: 'became-idle', title: 'Codex done' })
})
// Why: #10258 — a hookless Cursor pane's only identity is the bare literal, so both
// paths must emit it once (never as an agent state) before the repeats are dropped.
it('emits the bare cursor-agent native title once in both paths', () => {
feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`)
feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`)
expect(paths.main.events).toEqual(paths.renderer.events)
expect(paths.main.events).toEqual([
{ kind: 'title', normalized: 'Cursor Agent', raw: 'Cursor Agent' }
])
})
it('lets a synthesized Cursor title follow the native literal in both paths', () => {
feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`)
feedBoth(paths, `${ESC}]0;⠋ Cursor Agent${BEL}`)
feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`)
expect(paths.main.events).toEqual(paths.renderer.events)
expect(paths.main.events.map((event) => event.kind)).toEqual([
'title',
'title',
'became-working'
])
})
it('keeps a restored native Cursor title identity-only before synthesized work', () => {
const restoredPaths = {
renderer: createRendererPath('Cursor Agent'),
main: createMainPath('Cursor Agent')
}
feedBoth(restoredPaths, `${ESC}]0;Cursor Agent${BEL}`)
expect(restoredPaths.main.events).toEqual([])
feedBoth(restoredPaths, `${ESC}]0;⠋ Cursor Agent${BEL}`)
expect(restoredPaths.main.events).toEqual(restoredPaths.renderer.events)
expect(restoredPaths.main.events).toEqual([
{ kind: 'title', normalized: '⠋ Cursor Agent', raw: '⠋ Cursor Agent' },
{ kind: 'became-working' }
])
})
it('keeps native/synthesized Cursor title order inside one coalesced chunk', () => {
feedBoth(
paths,
`${ESC}]0;Cursor Agent${BEL}${ESC}]0;⠋ Cursor Agent${BEL}${ESC}]0;Cursor Agent${BEL}`
)
expect(paths.main.events).toEqual(paths.renderer.events)
expect(paths.main.events).toEqual([
{ kind: 'title', normalized: 'Cursor Agent', raw: 'Cursor Agent' },
{ kind: 'title', normalized: '⠋ Cursor Agent', raw: '⠋ Cursor Agent' },
{ kind: 'became-working' }
])
})
it('drops the bare cursor-agent native title in both paths', () => {
feedBoth(paths, `${ESC}]0;⠋ Cursor Agent${BEL}`)
feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`)
@ -124,6 +193,41 @@ describe('main title tracker parity with the renderer transport processor', () =
expect(titles).toEqual([{ kind: 'title', normalized: '⠋ Cursor Agent', raw: '⠋ Cursor Agent' }])
})
// Why: once the shell prompt repaints the title, the literal is again the pane's only Cursor
// identity — dropping it there would leave a re-entered cursor-agent with no row (#10258).
it('re-emits the native literal after a non-Cursor title took the pane', () => {
feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`)
feedBoth(paths, `${ESC}]0;zsh${BEL}${ESC}]0;Cursor Agent${BEL}`)
expect(paths.main.events).toEqual(paths.renderer.events)
expect(paths.main.events.filter((event) => event.kind === 'title')).toEqual([
{ kind: 'title', normalized: 'Cursor Agent', raw: 'Cursor Agent' },
{ kind: 'title', normalized: 'zsh', raw: 'zsh' },
{ kind: 'title', normalized: 'Cursor Agent', raw: 'Cursor Agent' }
])
})
// Why one flush: the renderer prunes titles when the chunk is scheduled but only advances its
// emitted-title memory on drain, so chunks that drain together must not decide against a
// predecessor that is already stale.
it('re-emits the native literal across chunks that drain together', () => {
const deferred = {
renderer: createRendererPath(undefined, true),
main: createMainPath()
}
feedBoth(deferred, `${ESC}]0;Cursor Agent${BEL}`)
// Why drain here: it leaves the renderer's emitted-title memory on the literal, which is
// exactly the stale predecessor the next two chunks must not be judged against.
deferred.renderer.flush()
feedBoth(deferred, `${ESC}]0;zsh${BEL}`)
feedBoth(deferred, `${ESC}]0;Cursor Agent${BEL}`)
deferred.renderer.flush()
expect(deferred.main.events).toEqual(deferred.renderer.events)
expect(deferred.main.events.filter((event) => event.kind === 'title')).toHaveLength(3)
})
it('clears a stale working title after the 3s timeout in both paths', () => {
feedBoth(paths, `${ESC}]0;. Claude working${BEL}`)
feedBoth(paths, 'output with no title\r\n')

View File

@ -272,6 +272,40 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('opencode')
})
// Why: #8940 — an OpenCode session whose task text mentions Claude flipped the tab icon
// to Claude Code as soon as its hook row went stale (restart, mobile, between turns).
it('keeps an OpenCode tab OpenCode when its task title merely mentions Claude', () => {
for (const title of [
'OC | ⠋ ask claude about this',
'⠋ OpenCode',
'⠋ use Claude Sonnet',
'⠋ claude 스타일로 리팩터',
'OpenCode ready'
]) {
for (const hasObservedAgentSignal of [true, false]) {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal,
isRemote: false,
title,
hookAgent: null,
launchAgent: 'opencode'
})
).toBe('opencode')
}
}
// Real pane reuse: the title PRESENTS Claude, so it still reclaims the pane.
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: false,
title: '✳ Claude Code',
hookAgent: null,
launchAgent: 'opencode'
})
).toBe('claude')
})
it('does not let an explicit title override launch identity before any activity is observed', () => {
expect(
resolveTabAgentFromSignals({

View File

@ -12,7 +12,10 @@ import {
resolveSiblingRetainedTabAgent,
resolveSiblingTabAgent
} from './tab-agent'
import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type'
import {
isClaudeIdentityFrameTitle,
resolveExplicitTerminalTitleAgentType
} from '../../../shared/terminal-title-agent-type'
import { resolveCompatibleAgentTypeForOwner } from '../../../shared/agent-title-owner'
import { isOpenCodeNativeTitle } from '../../../shared/opencode-terminal-title'
import { resolvePaneAgentOwner } from '../../../shared/pane-agent-owner'
@ -115,11 +118,16 @@ export function resolveTabAgentFromSignals(args: {
)
const priorIdentity = idleFocusedIdentity ?? launchAgent
const nativeOpenCodeTitle = explicitTitleAgent === 'opencode' && isOpenCodeNativeTitle(args.title)
// Why: a "claude" token in another agent's task text is a mention, not identity, so it must
// not take a pane from its known owner — only a title that PRESENTS Claude may (#8940).
const titleClaimsIdentity =
explicitTitleAgent !== 'claude' || isClaudeIdentityFrameTitle(args.title)
// Why: native OpenCode titles can reclaim stale launch intent before any observed hook signal.
const titleReclaimsReusedPane =
priorIdentity !== null &&
explicitTitleAgent !== null &&
explicitTitleAgent !== priorIdentity &&
titleClaimsIdentity &&
(args.hasObservedAgentSignal || hasCompletedHook || nativeOpenCodeTitle)
// Why: native OpenCode titles lack a provider generation and cannot displace durable ownership.
const titleAgent =

View File

@ -15,6 +15,7 @@ export {
isCursorNativeAgentTitle,
isGeminiTerminalTitle,
isPiTerminalTitle,
shouldSuppressCursorNativeTitle,
STRONG_IDLE_KEYWORDS_RE,
STRONG_WORKING_KEYWORDS_RE
} from './agent-title-core'

View File

@ -130,3 +130,10 @@ export function isCursorAgentTitle(title: string | null | undefined): boolean {
// treat the controlled synthetic Cursor spinner title as Cursor identity.
return /^[\u2800-\u28ff] Cursor Agent$/u.test(trimmed)
}
// Why: cursor-agent re-emits its bare native title every redraw, which would stomp
// Orca's hook-synthesized spinner state, but only once a Cursor-owned title already
// owns the pane. A hookless Cursor pane still needs the literal once, for identity.
export function shouldSuppressCursorNativeTitle(lastEmittedTitle: string | null): boolean {
return lastEmittedTitle !== null && isCursorAgentTitle(lastEmittedTitle)
}

View File

@ -6,6 +6,7 @@ import {
type SyntheticAgentTitleProfile
} from './synthetic-agent-title'
import { isLegacyPiCompatibleTitle } from './pi-compatible-synthetic-title'
import { getWrapperTitleSegments } from './terminal-title-wrapper-segments'
type TitleProfileMatch = {
profile: SyntheticAgentTitleProfile
@ -36,18 +37,8 @@ function getProfileForTitleLabel(label: string | null): TitleLabelProfileMatch |
* Resolves the synthetic title profile matching a given terminal title.
*/
function getProfileForTitle(title: string): TitleProfileMatch | null {
// Multiplexers/session wrappers prefix dynamic titles with ` | `, so inspect
// each suffix to preserve the inner compatible agent identity.
const candidates = [title]
let wrapperSeparatorIndex = title.indexOf(' | ')
while (wrapperSeparatorIndex >= 0) {
const wrappedPaneTitle = title.slice(wrapperSeparatorIndex + 3).trim()
if (wrappedPaneTitle && !candidates.includes(wrappedPaneTitle)) {
candidates.push(wrappedPaneTitle)
}
wrapperSeparatorIndex = title.indexOf(' | ', wrapperSeparatorIndex + 3)
}
// Why each segment: a wrapper prefix must not hide the inner compatible agent identity.
const candidates = getWrapperTitleSegments(title)
let fallback: TitleProfileMatch | null = null
for (const candidate of candidates) {
const labelProfile = getProfileForTitleLabel(getAgentLabel(candidate))

View File

@ -11,7 +11,8 @@ import {
detectAgentStatusFromTitle,
extractAllOscTitles,
isCursorNativeAgentTitle,
normalizeTerminalTitle
normalizeTerminalTitle,
shouldSuppressCursorNativeTitle
} from './agent-detection'
import { createBellDetector } from './terminal-bell-detector'
import {
@ -137,6 +138,10 @@ export function createTerminalTitleTracker(
let staleTitleTimer: ReturnType<typeof setTimeout> | null = null
// Why: flags the stale-timer clear so its idle callback carries timer provenance, not a genuine task-complete.
let applyingStaleWorkingTitleClear = false
const initialAgentStatusTitle =
options.initialTitle !== undefined && !isCursorNativeAgentTitle(options.initialTitle)
? options.initialTitle
: undefined
const agentTracker =
onAgentBecameIdle || onAgentBecameWorking || onAgentExited
? createAgentStatusTracker(
@ -148,7 +153,7 @@ export function createTerminalTitleTracker(
},
onAgentBecameWorking,
onAgentExited,
options.initialTitle
initialAgentStatusTitle
)
: null
@ -162,6 +167,13 @@ export function createTerminalTitleTracker(
function applyObservedTitle(rawTitle: string): void {
// Why: cursor-agent re-emits its bare native title mid-turn; passing it through would stomp Orca's synthesized spinner state.
if (isCursorNativeAgentTitle(rawTitle)) {
if (shouldSuppressCursorNativeTitle(lastEmittedTitle)) {
return
}
// Why: a hookless Cursor pane needs the literal once so it has an identity (#10258),
// but never as activity — its null status would read as an exit in the status tracker.
lastEmittedTitle = normalizeTerminalTitle(rawTitle)
onTitle?.(lastEmittedTitle, rawTitle)
return
}
lastEmittedTitle = normalizeTerminalTitle(rawTitle)
@ -258,12 +270,15 @@ export function createTerminalTitleTracker(
handleChunk,
applySyntheticTitleFrame,
seedInitialTitle(rawTitle: string): void {
// Why: the cursor-agent literal drop applies to seeds too — a bare native title would stomp synthesized spinner state.
if (lastEmittedTitle !== null || !rawTitle || isCursorNativeAgentTitle(rawTitle)) {
if (lastEmittedTitle !== null || !rawTitle) {
return
}
lastEmittedTitle = normalizeTerminalTitle(rawTitle)
agentTracker?.seedTitle(rawTitle)
// Why: the cursor-agent literal seeds identity only — feeding its null status to the
// tracker would make the next real frame look like an agent exit.
if (!isCursorNativeAgentTitle(rawTitle)) {
agentTracker?.seedTitle(rawTitle)
}
},
restoreLastAgentExit(): AgentStatus | null {
return agentTracker?.restoreLastExit() ?? null

View File

@ -3,6 +3,7 @@ import { getAgentLabel as getSharedAgentLabel } from './agent-title-identity'
import { isOpenCodeNativeTitle } from './opencode-terminal-title'
import {
isClaudeAgent,
isClaudeIdentityFrameTitle,
isGrokRotatingWorkingTitle,
resolveExplicitTerminalTitleAgentType,
resolveTerminalTitleAgentType
@ -90,6 +91,37 @@ describe('resolveExplicitTerminalTitleAgentType', () => {
expect(resolveExplicitTerminalTitleAgentType('. Claude Code compare Opencode')).toBe('claude')
})
// Why (#8940): only a title that PRESENTS Claude may take a pane from its known owner —
// a "claude" token inside another agent's task text is a mention, not identity.
it('separates Claude identity frames from an incidental claude token', () => {
for (const title of [
'Claude Code',
'✳ Claude Code',
'⠋ Claude Code',
'claude',
'. claude',
'Claude Code ready',
'Claude - action required',
// A multiplexer prefix must not read as task text and cost Claude its identity.
'zsh | ⠋ Claude Code',
'tmux | dev | Claude Code ready'
]) {
expect(isClaudeIdentityFrameTitle(title)).toBe(true)
}
for (const title of [
'⠋ use Claude Sonnet',
'OC | ⠋ ask claude about this',
'⠋ port the claude prompt',
'. ship it with claude',
'⠋ claude 스타일로 리팩터',
'. Claude Code compare Opencode',
'✳ investigating startup',
'✳'
]) {
expect(isClaudeIdentityFrameTitle(title)).toBe(false)
}
})
it('returns null for plain shell and unknown titles', () => {
expect(resolveExplicitTerminalTitleAgentType('Terminal 1')).toBeNull()
expect(resolveExplicitTerminalTitleAgentType('zsh')).toBeNull()

View File

@ -5,7 +5,9 @@ import {
titleHasAgentName
} from './agent-name-token-match'
import { isCursorAgentTitle } from './agent-title-core'
import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration'
import { isOpenCodeNativeTitle } from './opencode-terminal-title'
import { getWrapperTitleSegments } from './terminal-title-wrapper-segments'
import {
getPiCompatibleSyntheticAgentLabel,
isLegacyPiCompatibleTitle
@ -241,6 +243,26 @@ function hasGenericClaudeStatusPrefix(title: string): boolean {
)
}
// Claude's own name plus, at most, one of its status words — never free-form task text.
const CLAUDE_IDENTITY_FRAME_RE =
/^claude(?: code)?(?:\s+(?:ready|idle|done|working|thinking|running))?(?:\s*-\s*action required)?$/
/**
* Whether a title PRESENTS Claude rather than merely mentioning it, once its leading
* status decoration is stripped. A "claude" token inside free-form task text is a mention,
* so it must not take a pane away from its known owner (#8940) owner-blind consumers
* keep using `resolveExplicitTerminalTitleAgentType`, whose token match is looser.
*/
export function isClaudeIdentityFrameTitle(title: string): boolean {
// Why segments: a multiplexer prefix (`zsh | ⠋ Claude Code`) would otherwise read as task
// text and cost a genuine Claude pane its identity.
return getWrapperTitleSegments(title).some((segment) =>
CLAUDE_IDENTITY_FRAME_RE.test(
stripLeadingAgentTitleDecorationOrEmpty(segment).trim().toLowerCase()
)
)
}
function isGenericClaudeStatusClaim(title: string, titleAgent: TuiAgent | null): boolean {
return (
titleAgent === 'claude' &&

View File

@ -0,0 +1,20 @@
const WRAPPER_SEPARATOR = ' | '
/**
* A wrapped terminal title split into the texts an identity check should consider, whole
* title first and innermost pane title last. Multiplexers and session wrappers prefix the
* pane's own dynamic title (`zsh | ⠋ Claude Code`), so a check anchored to the start of the
* string would miss the agent that actually owns the pane.
*/
export function getWrapperTitleSegments(title: string): string[] {
const segments = [title]
let separatorIndex = title.indexOf(WRAPPER_SEPARATOR)
while (separatorIndex >= 0) {
const wrapped = title.slice(separatorIndex + WRAPPER_SEPARATOR.length).trim()
if (wrapped && !segments.includes(wrapped)) {
segments.push(wrapped)
}
separatorIndex = title.indexOf(WRAPPER_SEPARATOR, separatorIndex + WRAPPER_SEPARATOR.length)
}
return segments
}

View File

@ -0,0 +1,204 @@
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
waitForActivePanePtyId,
waitForActiveTerminalManager,
waitForTerminalOutput
} from './helpers/terminal'
import { runNodeScriptInTerminal } from './helpers/run-node-script-in-terminal'
import { waitForRestoredTerminalInputReady } from './helpers/restored-terminal-input-readiness'
import { worktreeRow } from './worktree-row-locators'
/**
* Sidebar agent-row identity, driven by real OSC titles on real PTYs.
*
* #10258 Cursor's only native OSC title is the literal `cursor agent`; it was
* dropped unconditionally, so a hookless Cursor pane produced no sidebar row.
* #8940 an incidental `claude` token inside an OpenCode task title outranked
* the pane's known owner, flipping the row label + identity icon to Claude Code.
*/
// The literal Cursor emits on every redraw — the pane's ONLY identity signal.
const CURSOR_NATIVE_OSC_TITLE = 'Cursor Agent'
// An OpenCode task title that merely MENTIONS claude (see #8940).
const OPENCODE_TASK_OSC_TITLE = '⠋ use Claude Sonnet'
/** Printed banner that proves the emitter ran; the OSC title trails it in the same chunk. */
const PANE_HOLD_MARKER = 'agent pane holding'
// Why: the emitter must never exit — a returning shell prompt would repaint its own
// cwd title over the agent title, which a real TUI holding the pane never allows.
// Why one write: a later title-less chunk arms the stale-title probe, which strips
// the working frame the row depends on.
function oscTitleHolderScript(escapedTitle: string): string {
return [
`process.stdout.write('${PANE_HOLD_MARKER}\\r\\n\\u001b]0;${escapedTitle}\\u0007')`,
'setInterval(() => {}, 1e9)',
''
].join('\n')
}
async function useFullAgentActivityRows(page: Page): Promise<void> {
await page.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const state = store.getState()
// Why: 'full' renders every agent row with its label inline, so the proof
// reads off the rendered sidebar instead of a collapsed summary pill.
state.setAgentActivityDisplayMode('full')
if (!state.worktreeCardProperties.includes('inline-agents')) {
state.toggleWorktreeCardProperty('inline-agents')
}
})
}
function paneTitles(page: Page, tabId: string): Promise<string[]> {
return page.evaluate((tabId) => {
const byPane = window.__store?.getState().runtimePaneTitlesByTabId?.[tabId] ?? {}
return Object.values(byPane).filter((title): title is string => typeof title === 'string')
}, tabId)
}
/**
* Opens a terminal tab launched as `launchAgent`, exactly like the tab-bar quick
* launch, and proves the shell round-trips a command before returning a cold
* PTY silently swallows the emitter command otherwise.
*/
async function openAgentTab(
page: Page,
worktreeId: string,
launchAgent: 'cursor' | 'opencode'
): Promise<{ tabId: string; ptyId: string }> {
const tabId = await page.evaluate(
({ worktreeId, launchAgent }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const state = store.getState()
const tab = state.createTab(worktreeId, undefined, undefined, {
launchAgent
})
state.setActiveTab(tab.id)
state.setActiveTabType('terminal')
return tab.id
},
{ worktreeId, launchAgent }
)
await waitForActiveTerminalManager(page)
// Why the raised budget: the first tab of a cold app can bind its PTY well past
// the helper's 15s default on a loaded machine.
const ptyId = await waitForActivePanePtyId(page, 45_000)
expect(
await waitForRestoredTerminalInputReady(page, ptyId, 20_000),
`the shell in the ${launchAgent} tab never echoed a probe command`
).toBe(true)
return { tabId, ptyId }
}
/**
* Identity of each rendered sidebar agent row, read off the row's identity icon
* tooltip (the first titled span in the row) i.e. the glyph the user sees.
* Read in one evaluate so a sidebar re-render cannot split the snapshot.
*/
function sidebarAgentRowIdentities(page: Page, agentListSelector: string): Promise<string[]> {
return page.evaluate((selector) => {
const list = document.querySelector(selector)
return list
? [...list.children]
.map((row) => row.querySelector('span[title]')?.getAttribute('title') ?? '')
.filter((identity) => identity.length > 0)
.sort()
: []
}, agentListSelector)
}
/**
* Waits for the rendered row identities to hold still, then returns them, so the
* claim below is asserted against one settled snapshot with a readable diff.
*/
async function settledSidebarAgentRowIdentities(
page: Page,
agentListSelector: string
): Promise<string[]> {
const requiredStableReads = 4
let previousKey = ''
let stableReads = 0
let settled: string[] = []
await expect
.poll(
async () => {
settled = await sidebarAgentRowIdentities(page, agentListSelector)
const key = JSON.stringify(settled)
stableReads = key === previousKey && settled.length > 0 ? stableReads + 1 : 0
previousKey = key
return stableReads >= requiredStableReads
},
{
timeout: 20_000,
intervals: [200],
message: 'the sidebar agent rows never settled on a stable set'
}
)
.toBe(true)
return settled
}
test('sidebar keeps a Cursor pane visible and an OpenCode pane out of Claude Code hands', async ({
orcaPage
}) => {
await waitForSessionReady(orcaPage)
const worktreeId = await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await useFullAgentActivityRows(orcaPage)
const openCode = await openAgentTab(orcaPage, worktreeId, 'opencode')
const openCodeScript = await runNodeScriptInTerminal(
orcaPage,
openCode.ptyId,
// ⠋ is the braille spinner frame OpenCode paints ahead of its task text.
oscTitleHolderScript('\\u280b use Claude Sonnet')
)
await waitForTerminalOutput(orcaPage, PANE_HOLD_MARKER, 15_000)
// Precondition, not the claim under test: this title is filtered on neither
// branch, so a failure here means the PTY never emitted it.
await expect
.poll(() => paneTitles(orcaPage, openCode.tabId), {
timeout: 15_000,
message: 'the OpenCode task title never reached the renderer'
})
.toContain(OPENCODE_TASK_OSC_TITLE)
const cursor = await openAgentTab(orcaPage, worktreeId, 'cursor')
const cursorScript = await runNodeScriptInTerminal(
orcaPage,
cursor.ptyId,
oscTitleHolderScript(CURSOR_NATIVE_OSC_TITLE)
)
// Settle gate: the emitter has run, so the literal has been offered to the title
// pipeline — kept as Cursor identity on the fix, dropped on main.
await waitForTerminalOutput(orcaPage, PANE_HOLD_MARKER, 15_000)
// Only the active worktree's card has agents, so this resolves to one list.
const agentListSelector = `[data-worktree-sidebar] [aria-label="Agents"]`
const agentList = worktreeRow(orcaPage, worktreeId).locator('[aria-label="Agents"]')
await expect(agentList.locator('> div').first()).toBeVisible()
// #10258: the Cursor pane gets a row at all. #8940: the OpenCode pane stays OpenCode.
expect(await settledSidebarAgentRowIdentities(orcaPage, agentListSelector)).toEqual([
'Cursor',
'OpenCode'
])
// Both panes are on the card: the Cursor row exists at all (#10258) next to the
// OpenCode row still labelled by its own task text (#8940).
await expect(agentList.locator('> div')).toHaveCount(2)
await expect(agentList).toContainText('Cursor')
await expect(agentList).toContainText('use Claude Sonnet')
openCodeScript.cleanup()
cursorScript.cleanup()
})