Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/renderer/src/lib/tab-agent-from-signals.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { titleShowsNoAgent } from '../../../shared/agent-detection'
import { collectAgentTitleEvidence } from '../../../shared/agent-title-evidence'
import {
isClaudeIdentityFrameTitle,
resolveExplicitTerminalTitleAgentType
Expand Down Expand Up @@ -58,6 +59,12 @@ export function resolveLaunchedAgentExitEvidence(args: {
return args.hasCompletedHook || (!args.isRemote && args.hasObservedAgentSignal)
}

// Why: a name only in free text (task wording) is a mention, not identity, so it can't displace a known owner (#26513).
function titleEvidenceIsStrong(title: string): boolean {
const { reason } = collectAgentTitleEvidence(title)
return reason === 'anchored' || reason === 'vendor-marker'
}

/**
* Identity-first precedence: live hook > process > title > completed > sleeping
* > launch > sibling. Same-group titles (OMP wraps Pi) are not reuse evidence.
Expand Down Expand Up @@ -128,7 +135,8 @@ export function resolveTabAgentFromSignals(args: {
explicitTitleAgent !== priorIdentity &&
!shareCompatibleTitleIdentityGroup(rawTitleAgent, priorIdentity) &&
titleClaimsIdentity &&
(args.hasObservedAgentSignal || hasCompletedHook || nativeOpenCodeTitle)
(args.hasObservedAgentSignal || hasCompletedHook || nativeOpenCodeTitle) &&
titleEvidenceIsStrong(args.title)
// Why: native OpenCode titles lack a provider generation and cannot displace durable ownership.
const titleAgent =
processProvesShell ||
Expand Down
48 changes: 48 additions & 0 deletions src/renderer/src/lib/tab-agent-title-free-text-override.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { collectAgentTitleEvidence } from '../../../shared/agent-title-evidence'
import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type'
import { resolveTabAgentFromSignals } from './tab-agent-from-signals'

const MENTION_ONLY_TITLE = '◑ Tab title showing Codex instead of Claude Code'
const GENUINE_CODEX_TITLE = 'Task - codex'

const BASE = { isRemote: false, hasObservedAgentSignal: true } as const

describe('title that only mentions another agent', () => {
it('is free-text evidence yet still parses as a codex title', () => {
expect(collectAgentTitleEvidence(MENTION_ONLY_TITLE)).toMatchObject({
agent: null,
reason: 'free-text-only'
})
expect(resolveExplicitTerminalTitleAgentType(MENTION_ONLY_TITLE)).toBe('codex')
})

it.each([
['live hook', { hookAgent: 'claude' }],
['foreground process', { hookAgent: null, processAgent: 'claude' }],
['completed hook', { hookAgent: null, focusedCompletedHookAgent: 'claude' }],
['launch intent', { hookAgent: null, launchAgent: 'claude' }]
] as const)('keeps claude from the %s', (_label, signals) => {
expect(resolveTabAgentFromSignals({ ...BASE, title: MENTION_ONLY_TITLE, ...signals })).toBe(
'claude'
)
})
})

describe('title that genuinely names another agent', () => {
it('is anchored evidence for codex', () => {
expect(collectAgentTitleEvidence(GENUINE_CODEX_TITLE)).toMatchObject({
agent: 'codex',
reason: 'anchored'
})
})

it.each([
['completed hook', { hookAgent: null, focusedCompletedHookAgent: 'claude' }],
['launch intent', { hookAgent: null, launchAgent: 'claude' }]
] as const)('still replaces a stale %s identity', (_label, signals) => {
expect(resolveTabAgentFromSignals({ ...BASE, title: GENUINE_CODEX_TITLE, ...signals })).toBe(
'codex'
)
})
})
12 changes: 5 additions & 7 deletions src/renderer/src/lib/use-tab-agent-pi-identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,11 +336,9 @@ describe('resolveTabAgentFromSignals — identity vs liveness', () => {
).toBe('pi')
})

it('does not flash the exited agent before a hookless reuse title reclaims on mount', () => {
// hasObservedAgentSignal starts false for one mount commit; a completed hook
// is itself activity evidence, so the reuse title reclaims immediately
// instead of flashing the prior agent's idle identity. (claude ran+idled,
// then a hookless codex reused the pane and emits its own title.)
it('keeps the completed identity over a free-text Codex title on mount', () => {
// A "⠋ Codex" title names Codex only in free text, so it must not replace the
// completed claude identity, neither on mount nor once activity is observed.
const onMount = resolveTabAgentFromSignals({
hasObservedAgentSignal: false,
isRemote: false,
Expand All @@ -357,7 +355,7 @@ describe('resolveTabAgentFromSignals — identity vs liveness', () => {
focusedCompletedHookAgent: 'claude',
launchAgent: undefined
})
expect(onMount).toBe('codex')
expect(afterObserved).toBe('codex')
expect(onMount).toBe('claude')
expect(afterObserved).toBe('claude')
})
})
164 changes: 5 additions & 159 deletions src/renderer/src/lib/use-tab-agent.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import { isShellProcess } from '../../../shared/agent-detection'
import { worktreeUsesRemoteConnection } from '@/store/terminals/terminal-workspace-routing'
import { hasRemoteRuntimePtyForTab } from './tab-agent-remote-pty-selector'
import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id'
Expand All @@ -12,168 +11,15 @@ import {
resolveSiblingRetainedTabAgent,
resolveSiblingTabAgent
} from './tab-agent'
import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type'
import {
isClaudeIdentityFrameTitle,
resolveExplicitTerminalTitleAgentType
} from '../../../shared/terminal-title-agent-type'
import { resolveSignalAgentForLaunchOwner } from './tab-agent-from-signals'
import { isOpenCodeNativeTitle } from '../../../shared/opencode-terminal-title'
import { resolvePaneAgentOwner } from '../../../shared/pane-agent-owner'
resolveLaunchedAgentExitEvidence,
resolveTabAgentFromSignals
} from './tab-agent-from-signals'
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import type { TerminalAgent } from '../../../shared/terminal-agent'
import type { TuiAgent } from '../../../shared/tui-agent'
import { agentTypeToIconAgent } from './agent-status'

// A shell name or the tab's neutral default title (where inferred-interrupt reset parks it); blank titles are no evidence.
function titleShowsNoAgent(title: string, defaultTitle?: string): boolean {
const trimmed = title.trim()
return trimmed.length > 0 && (isShellProcess(trimmed) || trimmed === defaultTitle?.trim())
}

/**
* Probe-free evidence a launched agent exited: title shows no agent, no live
* hook remains, and either the hook completed or observed activity vanished.
* Vanished-activity is local-only — remote rows also drop on transport blips.
*/
export function resolveLaunchedAgentExitEvidence(args: {
title: string
defaultTitle?: string
isRemote: boolean
hasObservedAgentSignal: boolean
hookAgent: TerminalAgent | null
siblingHookAgent?: TerminalAgent | null
hasCompletedHook: boolean
processAgent?: TerminalAgent | null
processShellForeground?: boolean
}): boolean {
if (args.hookAgent || args.siblingHookAgent || args.processAgent) {
return false
}
// Why: OSC 133;D (foreground back at shell) is title-independent exit evidence; local-only — remote panes have no shell-foreground producer.
if (!args.isRemote && args.processShellForeground && args.hasObservedAgentSignal) {
return true
}
if (!titleShowsNoAgent(args.title, args.defaultTitle)) {
return false
}
return args.hasCompletedHook || (!args.isRemote && args.hasObservedAgentSignal)
}

export function resolveTabAgentFromSignals(args: {
hasObservedAgentSignal: boolean
isRemote: boolean
title: string
defaultTitle?: string
hookAgent: TerminalAgent | null
siblingHookAgent?: TerminalAgent | null
focusedCompletedHookAgent?: TerminalAgent | null
siblingCompletedHookAgent?: TerminalAgent | null
processAgent?: TerminalAgent | null
processShellForeground?: boolean
sleepingSessionAgent?: TerminalAgent | null
launchAgent?: TuiAgent
}): TerminalAgent | null {
const launchAgent = args.launchAgent ?? null
// Durable focused-pane owner (launch intent → hook → session); focused-pane-scoped so a sibling can't re-own the focused title (would mislabel a Pi pane as OMP).
const owner = agentTypeToIconAgent(
resolvePaneAgentOwner({
launchAgent,
hookAgent: args.hookAgent,
completedHookAgent: args.focusedCompletedHookAgent,
sleepingSessionAgent: args.sleepingSessionAgent
})
)

// The live/idle split governs title override; siblings normalize against launch intent only.
const liveFocusedIdentity = resolveSignalAgentForLaunchOwner(args.hookAgent, owner)
const liveSiblingIdentity = resolveSignalAgentForLaunchOwner(args.siblingHookAgent, launchAgent)
// Why: OSC 133;D proves this local pane returned to shell, so the idle identity is stale; remote titles lag runtime, so keep it there.
const processProvesShell = !args.isRemote && args.processShellForeground === true
const hasCompletedHook = (args.focusedCompletedHookAgent ?? null) !== null
const noAgentTitle = titleShowsNoAgent(args.title, args.defaultTitle)
const idleIdentitySuppressed =
!args.isRemote && (noAgentTitle || processProvesShell) && hasCompletedHook
const idleFocusedIdentity = idleIdentitySuppressed
? null
: resolveSignalAgentForLaunchOwner(args.focusedCompletedHookAgent, owner)
// Why: idleIdentitySuppressed is the FOCUSED pane's exit evidence, so it must not clear a sibling's idle identity.
const idleSiblingIdentity = resolveSignalAgentForLaunchOwner(
args.siblingCompletedHookAgent,
launchAgent
)
const sleepingSessionAgent = args.sleepingSessionAgent ?? null

// Title carries identity only as a reuse override (names a DIFFERENT-group agent) or a legacy standalone id when no hook — same-group titles say nothing (OMP wraps Pi), so the record wins.
const explicitTitleAgent = resolveSignalAgentForLaunchOwner(
resolveExplicitTerminalTitleAgentType(args.title),
owner
)
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 =
processProvesShell ||
sleepingSessionAgent ||
(nativeOpenCodeTitle && idleFocusedIdentity !== null)
? null
: titleReclaimsReusedPane
? explicitTitleAgent
: priorIdentity
? null
: explicitTitleAgent

const launchedAgentExited = resolveLaunchedAgentExitEvidence({
title: args.title,
defaultTitle: args.defaultTitle,
isRemote: args.isRemote,
hasObservedAgentSignal: args.hasObservedAgentSignal,
hookAgent: liveFocusedIdentity,
siblingHookAgent: liveSiblingIdentity,
hasCompletedHook,
processAgent: args.processAgent,
processShellForeground: args.processShellForeground
})
const activeLaunchAgent = launchedAgentExited ? null : launchAgent
// Why: re-own the foreground process within its title-identity group so OMP's nested pi (shell → omp → pi) can't flip an OMP-owned tab's icon.
const processAgent = resolveSignalAgentForLaunchOwner(args.processAgent, owner)
// Identity-first precedence (see JSDoc): live hook > process > title > completed > sleeping > launch > sibling.
return (
liveFocusedIdentity ??
processAgent ??
titleAgent ??
idleFocusedIdentity ??
sleepingSessionAgent ??
activeLaunchAgent ??
liveSiblingIdentity ??
idleSiblingIdentity
)
}

/**
* Resolve which coding-harness agent a terminal tab is running, for its tab-bar
* icon. A pane's IDENTITY (separate from activity state), from the same
* already-computed state as the sidebar rows — no foreground probing.
* Identity-first precedence:
*
* 1. Live focused hook — ground truth while the agent works; never title-overridden.
* 2. Process identity — recognized foreground process (local only); re-owned within its title-identity group so OMP's nested `pi` (shell → omp → pi) can't flip the icon.
* 3. Title — only a reuse override or legacy standalone identity; native OpenCode titles cannot displace durable ownership.
* 4. Idle focused identity — the pane's completed hook or sidebar-retained completion; suppressed locally once OSC 133;D proves exit.
* 5. Sleeping session identity — current provider-session ownership.
* 6. launchAgent — bootstrap before any hook/process signal; cleared once exit evidence shows it left.
* 7. Sibling-pane identity (live, then completed/retained) — split-tab fallback.
*/
/** Resolve which coding-harness agent a terminal tab is running, for its tab-bar icon; precedence lives on resolveTabAgentFromSignals. */
export function useTabAgent(tab: TerminalTab): TerminalAgent | null {
const focusedHookAgent = useAppStore((s) =>
resolveFocusedTabAgent(s.agentStatusByPaneKey, s.terminalLayoutsByTabId[tab.id], tab.id)
Expand Down