fix(agents): surface Cline panes in worktree agent rows via a declared identity frame (STA-3906)

cline writes OSC 0 `Cline` and posts no hooks, so the pane fell out of the
sidebar entirely: `cline` is absent from AGENT_NAMES (deliberately — those names
are token-matched and too common), so getAgentLabel and detectAgentStatusFromTitle
both returned null and buildTitleDerivedAgentRow dropped the row.

Rather than add a fifth hand-copied identity regex, move the shape Claude already
used into a shared registry and declare Cline in it.
This commit is contained in:
Brennan Benson
2026-08-14 16:02:52 -07:00
parent 9bb8836bb6
commit 7bddc1187d
7 changed files with 325 additions and 22 deletions
@@ -202,6 +202,62 @@ describe('buildTitleDerivedAgentRows', () => {
}
})
// Why: cline 3.0.55 writes OSC 0 `Cline` and posts no hooks, so the title path is the
// only thing that can put the pane in the sidebar at all (STA-3906 / #13823).
it('adds an idle row for a registry-declared agent that only writes its own name', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { title: 'Cline' })],
entries: [],
retained: [],
runtimePaneTitlesByTabId: { 'tab-1': { 1: 'Cline' } },
ptyIdsByTabId: { 'tab-1': ['pty-cline'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
now: 2000
})
expect(rows.map((row) => [row.agentType, row.state, row.entry.lastAssistantMessage])).toEqual([
['cline', 'idle', 'Idle']
])
expect(rows[0]?.paneKey).toBe(makePaneKey('tab-1', LEAF_ID_1))
})
it('does not invent a Cline row from Claude task text that mentions cline', () => {
const unowned = buildWorktreeAgentRows({
tabs: [makeTab('tab-1')],
entries: [],
retained: [],
runtimePaneTitlesByTabId: { 'tab-1': { 1: '⠋ use cline for the sidebar fix' } },
ptyIdsByTabId: { 'tab-1': ['pty-claude-task'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
now: 2000
})
expect(unowned).toHaveLength(0)
const owned = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { launchAgent: 'claude' })],
entries: [],
retained: [],
runtimePaneTitlesByTabId: { 'tab-1': { 1: '⠋ use cline for the sidebar fix' } },
ptyIdsByTabId: { 'tab-1': ['pty-claude-task'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
now: 2000
})
expect(owned.map((row) => row.agentType)).toEqual(['claude'])
})
it('keeps a cline-named worktree path out of the sidebar', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { title: '~/orca/workspaces/cline-scratch' })],
entries: [],
retained: [],
runtimePaneTitlesByTabId: { 'tab-1': { 1: '~/orca/workspaces/cline-scratch' } },
ptyIdsByTabId: { 'tab-1': ['pty-shell'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
now: 2000
})
expect(rows).toHaveLength(0)
})
it('attributes a spinner-only title to the launched agent when the title has no identity', () => {
const launchAgent: TuiAgent = 'codex'
const rows = buildWorktreeAgentRows({
@@ -21,6 +21,7 @@ import {
} from '../../../../shared/agent-title-owner'
import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner'
import { isClaudeIdentityFrameTitle } from '../../../../shared/terminal-title-agent-type'
import { ALL_TUI_AGENTS, TUI_AGENT_DISPLAY_NAMES } from '../../../../shared/tui-agent-display-names'
const EMPTY_RUNTIME_TITLES: Record<string, Record<number, string>> = {}
const EMPTY_LIVE_PTY_IDS: Record<string, string[]> = {}
@@ -44,6 +45,12 @@ const TITLE_AGENT_LABEL_TO_TYPE: Record<string, AgentType> = {
OMP: 'omp'
}
// Why: labels that already equal an agent's canonical display name need no second
// table — registry-declared agents (agent-identity-frame.ts) resolve straight through.
const DISPLAY_NAME_TO_AGENT_TYPE: Record<string, AgentType> = Object.fromEntries(
ALL_TUI_AGENTS.map((agent) => [TUI_AGENT_DISPLAY_NAMES[agent], agent as AgentType])
)
const CLAUDE_AGENT_TOKEN_RE = /(?<![\w./\\-])claude(?![\w./\\-])/i
export function buildTitleDerivedAgentRows(args: {
@@ -201,7 +208,8 @@ export function resolveTitleDerivedAgentType(
label: string,
ownerAgentType?: AgentType | null
): AgentType | null {
const agentType = TITLE_AGENT_LABEL_TO_TYPE[label] ?? 'unknown'
const agentType =
TITLE_AGENT_LABEL_TO_TYPE[label] ?? DISPLAY_NAME_TO_AGENT_TYPE[label] ?? 'unknown'
if (agentType !== 'claude') {
return agentType
}
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import {
AGENT_IDENTITY_FRAMES,
buildAgentIdentityFrameRe,
isAgentIdentityFrameTitleFor,
resolveAgentIdentityFrameType
} from './agent-identity-frame'
import { getAgentLabel } from './agent-title-identity'
import { detectAgentStatusFromTitle } from './agent-title-status'
import {
isClaudeIdentityFrameTitle,
resolveExplicitTerminalTitleAgentType,
resolveTerminalTitleAgentType
} from './terminal-title-agent-type'
describe('agent identity frames', () => {
it('claims a title that is only the agent name, decorated or multiplexer-wrapped', () => {
for (const title of ['Cline', 'cline', 'cline.cmd', 'cline.exe', '⠋ Cline', 'zsh | Cline']) {
expect(resolveAgentIdentityFrameType(title)).toBe('cline')
expect(isAgentIdentityFrameTitleFor(title, 'cline')).toBe(true)
}
})
it('rejects the name inside task text, a path, or a hyphenated compound', () => {
for (const title of [
'⠋ use cline for the sidebar fix',
'port the cline prompt',
'~/cline-scratch',
'cline-rules',
'src/cline/index.ts',
'clinent'
]) {
expect(resolveAgentIdentityFrameType(title)).not.toBe('cline')
expect(isAgentIdentityFrameTitleFor(title, 'cline')).toBe(false)
}
})
it('accepts at most one status word, never free-form trailing text', () => {
expect(isAgentIdentityFrameTitleFor('Cline ready', 'cline')).toBe(true)
expect(isAgentIdentityFrameTitleFor('Cline - action required', 'cline')).toBe(true)
expect(isAgentIdentityFrameTitleFor('Cline working on the parser', 'cline')).toBe(false)
expect(isAgentIdentityFrameTitleFor('Cline ready idle', 'cline')).toBe(false)
})
it('is not declared for agents whose names are too common to anchor on', () => {
// Why: `amp`/`pi`/`cn` would claim ordinary shell titles; the registry stays observed-only.
expect(AGENT_IDENTITY_FRAMES.amp).toBeUndefined()
expect(resolveAgentIdentityFrameType('amp')).toBeNull()
expect(resolveAgentIdentityFrameType('crush')).toBeNull()
})
// Why: this is the whole point of the registry — a new runtime is data, not five call sites.
it('builds the same frame shape for any declared name', () => {
const re = buildAgentIdentityFrameRe({ names: ['widgetcli'], executableSuffix: true })
expect(re.test('widgetcli')).toBe(true)
expect(re.test('widgetcli.exe')).toBe(true)
expect(re.test('widgetcli - action required')).toBe(true)
expect(re.test('use widgetcli here')).toBe(false)
})
})
describe('Cline title visibility (STA-3906 / #13823)', () => {
// Observed against cline 3.0.55: the CLI emits OSC 0 `Cline` and never varies it.
it('resolves the live Cline title to identity, label, and an idle status', () => {
expect(getAgentLabel('Cline')).toBe('Cline')
expect(resolveTerminalTitleAgentType('Cline')).toBe('cline')
expect(resolveExplicitTerminalTitleAgentType('Cline')).toBe('cline')
expect(detectAgentStatusFromTitle('Cline')).toBe('idle')
})
it('maps decorated Cline frames to status without inventing status from mentions', () => {
expect(detectAgentStatusFromTitle('Cline ready')).toBe('idle')
expect(detectAgentStatusFromTitle('Cline working')).toBe('working')
expect(detectAgentStatusFromTitle('Cline - action required')).toBe('permission')
expect(detectAgentStatusFromTitle('⠋ Cline')).toBe('working')
for (const title of ['port the cline prompt', '~/cline-scratch', 'cline-rules']) {
expect(detectAgentStatusFromTitle(title)).toBeNull()
}
})
it('keeps Claude task text that mentions cline as Claude', () => {
expect(getAgentLabel('⠋ use cline for the sidebar fix')).toBe('Claude Code')
expect(resolveTerminalTitleAgentType('⠋ use cline for the sidebar fix')).toBe('claude')
// Why: the spinner still proves activity; the mention must not rebrand the pane.
expect(detectAgentStatusFromTitle('⠋ use cline for the sidebar fix')).toBe('working')
})
})
// Why: the Claude frame moved onto the shared builder — pin the behavior it had before.
describe('isClaudeIdentityFrameTitle after the shared-builder move', () => {
it('accepts Claude frames and rejects Claude mentions', () => {
for (const title of [
'Claude',
'claude code',
'Claude Code',
'✳ Claude Code',
'Claude ready',
'Claude Code - action required',
'zsh | ⠋ Claude Code'
]) {
expect(isClaudeIdentityFrameTitle(title)).toBe(true)
}
for (const title of [
'⠋ ask claude about the parser',
'claude-scratch',
'~/claude/worktrees',
'Claude Code review the diff'
]) {
expect(isClaudeIdentityFrameTitle(title)).toBe(false)
}
})
})
+92
View File
@@ -0,0 +1,92 @@
/**
* One shape for "this title PRESENTS agent X", shared by every consumer that
* turns an OSC title into agent identity.
*
* Why a registry and not another per-agent predicate: an identity frame is the
* agent's own name plus, at most, one status word. That shape was already
* hand-copied per agent (Claude here, Pi/OMP in pi-compatible-synthetic-title,
* Cursor's closed literal set in agent-title-core), so every new runtime meant a
* new regex threaded through five call sites. Declaring the names instead keeps
* the next runtime a one-row change.
*
* Why frames and not name tokens: a name inside free-form task text is a
* mention, not identity, and must never take a pane away from its owner
* (#8940). Anchoring the whole title is what separates the two.
*/
import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration'
import { getWrapperTitleSegments } from './terminal-title-wrapper-segments'
import type { TuiAgent } from './tui-agent'
/** The status words agents append to their own name — Orca's synthetic titles
* use the same vocabulary (synthetic-agent-title.ts), so a frame must accept both. */
const IDENTITY_STATUS_WORDS = ['ready', 'idle', 'done', 'working', 'thinking', 'running'] as const
// Why: Windows titles can surface the launcher process name (`cline.cmd`).
const EXECUTABLE_SUFFIX = String.raw`(?:\.(?:exe|cmd|bat|ps1))?`
export type AgentIdentityFrameSpec = {
/** Lowercase names the agent presents itself under, longest first. */
names: readonly string[]
/** Accept a Windows launcher extension after the name. */
executableSuffix?: boolean
}
export function buildAgentIdentityFrameRe(spec: AgentIdentityFrameSpec): RegExp {
const names = spec.names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
const suffix = spec.executableSuffix ? EXECUTABLE_SUFFIX : ''
return new RegExp(
`^(?:${names})${suffix}(?:\\s+(?:${IDENTITY_STATUS_WORDS.join('|')}))?(?:\\s*-\\s*action required)?$`
)
}
/**
* Agents identified by the title they write about themselves. Only add a runtime
* here once its CLI is observed emitting the title — an unobserved name widens
* the false-positive surface for every ordinary shell title.
*/
export const AGENT_IDENTITY_FRAMES: Partial<Record<TuiAgent, AgentIdentityFrameSpec>> = {
claude: { names: ['claude code', 'claude'] },
// Verified against cline 3.0.55: it emits OSC 0 `Cline` and never varies it,
// so Orca sees identity but no status transitions (STA-3906).
cline: { names: ['cline'], executableSuffix: true }
}
const IDENTITY_FRAME_RES = new Map<TuiAgent, RegExp>(
Object.entries(AGENT_IDENTITY_FRAMES).map(([agent, spec]) => [
agent as TuiAgent,
buildAgentIdentityFrameRe(spec)
])
)
/**
* Whether `title` presents `agent`, once multiplexer prefixes and leading status
* decoration are stripped.
*
* Why segments: a multiplexer prefix (`zsh | ⠋ Claude Code`) would otherwise read
* as task text and cost a genuine agent pane its identity.
*/
export function isAgentIdentityFrameTitleFor(
title: string | null | undefined,
agent: TuiAgent
): boolean {
const re = IDENTITY_FRAME_RES.get(agent)
if (!re || typeof title !== 'string') {
return false
}
return getWrapperTitleSegments(title).some((segment) =>
re.test(stripLeadingAgentTitleDecorationOrEmpty(segment).trim().toLowerCase())
)
}
/** The agent a title presents, or null when the title is task text, a path, or a bare shell title. */
export function resolveAgentIdentityFrameType(title: string | null | undefined): TuiAgent | null {
if (typeof title !== 'string') {
return null
}
for (const agent of IDENTITY_FRAME_RES.keys()) {
if (isAgentIdentityFrameTitleFor(title, agent)) {
return agent
}
}
return null
}
+17 -3
View File
@@ -10,8 +10,10 @@ import {
isPiAgentTitle,
titleHasAgentName
} from './agent-title-core'
import { resolveAgentIdentityFrameType } from './agent-identity-frame'
import { isOpenCodeNativeTitle } from './opencode-terminal-title'
import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-title'
import { TUI_AGENT_DISPLAY_NAMES } from './tui-agent-display-names'
/**
* Returns true when the terminal title matches Claude Code's title conventions.
@@ -32,9 +34,15 @@ export function isClaudeAgent(title: string): boolean {
return true
}
if (containsAgentSpinnerGlyph(title)) {
// Why: named non-Claude agents carry braille spinners too. Gate Cursor by its
// identity title, not the token, so a Claude title mentioning a cursor stays Claude.
return !isCursorAgentTitle(title) && !lower.includes('openclaude')
// Why: named non-Claude agents carry braille spinners too. Gate them by their
// identity frame, not a name token, so Claude task text that merely mentions
// one (`⠋ use cline for the fix`) stays Claude.
const frameAgent = resolveAgentIdentityFrameType(title)
return (
(frameAgent === null || frameAgent === 'claude') &&
!isCursorAgentTitle(title) &&
!lower.includes('openclaude')
)
}
const trimmedTitle = title.trimStart()
@@ -102,6 +110,12 @@ export function getAgentLabel(title: string): string | null {
if (titleHasAgentName(title, 'aider')) {
return 'Aider'
}
// Why: registry-declared agents identify themselves by frame only, so this must
// run before Claude's generic braille heuristic but after the token matches above.
const frameAgent = resolveAgentIdentityFrameType(title)
if (frameAgent && frameAgent !== 'claude') {
return TUI_AGENT_DISPLAY_NAMES[frameAgent]
}
// Why: `cursor` is ordinary editor vocabulary, not identity. Match Cursor's closed
// title set (mirrors @cursor routing), before `isClaudeAgent` claims the braille frame.
if (isCursorAgentTitle(title)) {
+11 -1
View File
@@ -1,3 +1,4 @@
import { resolveAgentIdentityFrameType } from './agent-identity-frame'
import {
AGY_AGENT_NAME_RE,
BRAILLE_SPINNER_RE,
@@ -196,7 +197,16 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null {
const hasHermesAgentName = HERMES_AGENT_NAME_RE.test(title)
const hasAgyAgentName = AGY_AGENT_NAME_RE.test(title)
const hasLegacyAgentName = containsLegacyAgentName(title)
if (!hasLegacyAgentName && !hasDroidAgentName && !hasHermesAgentName && !hasAgyAgentName) {
// Why: registry-declared agents are deliberately absent from AGENT_NAMES — their
// names are too common to token-match — so their whole-title frame is the evidence.
const hasIdentityFrame = resolveAgentIdentityFrameType(title) !== null
if (
!hasLegacyAgentName &&
!hasDroidAgentName &&
!hasHermesAgentName &&
!hasAgyAgentName &&
!hasIdentityFrame
) {
return null
}
if (containsAny(title, ['action required', 'permission', 'waiting'])) {
+28 -17
View File
@@ -4,14 +4,14 @@ import {
HERMES_AGENT_NAME_RE,
titleHasAgentName
} from './agent-name-token-match'
import { isAgentIdentityFrameTitleFor, resolveAgentIdentityFrameType } from './agent-identity-frame'
import { containsAgentSpinnerGlyph, 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
} from './pi-compatible-synthetic-title'
import { TUI_AGENT_DISPLAY_NAMES, ALL_TUI_AGENTS } from './tui-agent-display-names'
import type { TuiAgent } from './tui-agent'
export const CLAUDE_IDLE = '\u2733' // ✳ (eight-spoked asterisk — Claude Code idle prefix)
@@ -97,9 +97,15 @@ export function isClaudeAgent(title: string): boolean {
return true
}
if (containsAgentSpinnerGlyph(title)) {
// Why: named non-Claude agents carry braille spinners too. Gate Cursor by its
// identity title, not the token, so a Claude title mentioning a cursor stays Claude.
return !isCursorAgentTitle(title) && !lower.includes('openclaude')
// Why: named non-Claude agents carry braille spinners too. Gate them by their
// identity frame, not a name token, so Claude task text that merely mentions
// one (`⠋ use cline for the fix`) stays Claude.
const frameAgent = resolveAgentIdentityFrameType(title)
return (
(frameAgent === null || frameAgent === 'claude') &&
!isCursorAgentTitle(title) &&
!lower.includes('openclaude')
)
}
// Why: permission/action-required Claude titles can omit the usual prefixes.
// Token-match so cwd/worktree titles like "claude-scratch" do not become
@@ -184,6 +190,12 @@ export function getAgentLabel(title: string): string | null {
if (titleHasAgentName(title, 'aider')) {
return 'Aider'
}
// Why: registry-declared agents identify themselves by frame only, so this must
// run before Claude's generic braille heuristic but after the token matches above.
const frameAgent = resolveAgentIdentityFrameType(title)
if (frameAgent && frameAgent !== 'claude') {
return TUI_AGENT_DISPLAY_NAMES[frameAgent]
}
// Why: `cursor` is ordinary editor vocabulary, not identity. Match Cursor's closed
// title set (mirrors @cursor routing), before `isClaudeAgent` claims the braille frame.
if (isCursorAgentTitle(title)) {
@@ -229,6 +241,12 @@ const TITLE_LABEL_TO_AGENT: Partial<Record<string, TuiAgent>> = {
OMP: 'omp'
}
// Why: labels that already equal an agent's canonical display name need no second
// table — registry-declared agents (agent-identity-frame.ts) resolve straight through.
const DISPLAY_NAME_TO_AGENT = new Map<string, TuiAgent>(
ALL_TUI_AGENTS.map((agent) => [TUI_AGENT_DISPLAY_NAMES[agent], agent])
)
function hasGenericClaudeStatusPrefix(title: string): boolean {
return (
containsAgentSpinnerGlyph(title) ||
@@ -239,10 +257,6 @@ 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,
@@ -250,13 +264,7 @@ const CLAUDE_IDENTITY_FRAME_RE =
* 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()
)
)
return isAgentIdentityFrameTitleFor(title, 'claude')
}
function isGenericClaudeStatusClaim(title: string, titleAgent: TuiAgent | null): boolean {
@@ -269,7 +277,10 @@ function isGenericClaudeStatusClaim(title: string, titleAgent: TuiAgent | null):
export function resolveTerminalTitleAgentType(title: string): TuiAgent | null {
const label = getAgentLabel(title)
return label ? (TITLE_LABEL_TO_AGENT[label] ?? null) : null
if (!label) {
return null
}
return TITLE_LABEL_TO_AGENT[label] ?? DISPLAY_NAME_TO_AGENT.get(label) ?? null
}
/**