Fix remote OMP terminal title thrash (#6689)

Fixes #6619: OMP-owned remote/mobile terminals no longer flicker their tab
label between "OMP" and "Pi".

OMP wraps Pi, so it emits Pi-identity OSC titles and status frames during
active work. On the host, in mirrored remote tabs, and in the title-derived
sidebar rows, those frames were stored verbatim — so an OMP-launched pane
alternated between "OMP" (launch identity) and "Pi" (live frame). The fix
introduces a shared owner-normalization helper (agent-title-owner.ts) that
rewrites Pi-compatible titles/status entries to the authoritative launch
owner, but only when the incoming and owner profiles share the same
titleIdentityGroup — so true Pi sessions, unrelated agents, and custom titles
are left untouched.

Maintainer hardening on top of the original change:
- Skip the new getForegroundProcess probe entirely when launchAgent is already
  known (it is only ever the owner fallback when launchAgent is unknown), and
  gate the onPtyData trigger on a real status transition rather than per-frame
  braille-spinner title churn — avoiding a relay round-trip per output frame on
  SSH/daemon-backed terminals.
- Make the foreground refresh fire-and-forget on the mobile listing hot path
  (listTerminals/getWorktreePs) so latency does not grow per session and a
  throwing snapshot listener cannot abort the liveness sweep.
- Added regression tests + a Why comment on the renderer owner precedence.

Verified: 829 tests pass; node/web/cli typechecks clean; oxlint clean;
reproduced the flicker against main as a negative control and confirmed the
live renderer build collapses interleaved OMP/Pi frames to a stable OMP label
with zero Pi leaks while leaving true-Pi/unrelated/custom titles unchanged.

Co-authored-by: Dvitash <dvitash3414@gmail.com>
This commit is contained in:
Dvitash 2026-06-29 01:25:15 -04:00 committed by GitHub
parent 9b275fa16f
commit 29df9a3ab5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 842 additions and 63 deletions

View File

@ -11128,6 +11128,200 @@ describe('OrcaRuntimeService', () => {
])
})
it('preserves authoritative OMP identity for Pi-compatible remote terminal snapshots', async () => {
const runtime = new OrcaRuntimeService(store)
const leafId = '11111111-1111-4111-8111-111111111111'
const hostPaneKey = `tab-1:${leafId}`
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
mobileSessionTabs: [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: `tab-1::${leafId}`,
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: `tab-1::${leafId}`,
parentTabId: 'tab-1',
leafId,
title: '\u280b Pi',
launchAgent: 'omp',
agentStatus: {
state: 'working',
prompt: 'fix parity',
updatedAt: 1_700_000_000_000,
stateStartedAt: 1_699_999_999_000,
agentType: 'pi',
paneKey: hostPaneKey,
terminalTitle: '\u280b Pi',
stateHistory: []
},
isActive: true
}
]
}
]
})
const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(result.tabs[0]).toEqual(
expect.objectContaining({
type: 'terminal',
title: '\u280b OMP',
launchAgent: 'omp',
agentStatus: expect.objectContaining({
state: 'working',
agentType: 'omp',
paneKey: hostPaneKey,
terminalTitle: '\u280b OMP'
})
})
)
})
it('derives remote OMP owner from live PTY metadata when the tab snapshot omits it', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-omp' })
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: 'omp',
launchAgent: 'omp',
title: 'OMP',
activate: true
})
const spawnCall = spawn.mock.calls[0]?.[0]
expect(spawnCall).toEqual(
expect.objectContaining({
tabId: expect.any(String),
leafId: expect.any(String)
})
)
const { tabId, leafId } = spawnCall as { tabId: string; leafId: string }
runtime.syncWindowGraph(1, {
tabs: [
{
tabId,
worktreeId: TEST_WORKTREE_ID,
title: '\u280b π - tmp',
activeLeafId: leafId,
layout: null
}
],
leaves: [
{
tabId,
worktreeId: TEST_WORKTREE_ID,
leafId,
paneRuntimeId: 1,
ptyId: 'pty-omp',
paneTitle: '\u280b π - tmp'
}
],
mobileSessionTabs: [
{
worktree: TEST_WORKTREE_ID,
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: null,
activeTabId: `${tabId}::${leafId}`,
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: `${tabId}::${leafId}`,
parentTabId: tabId,
leafId,
ptyId: 'pty-omp',
title: '\u280b π - tmp',
isActive: true
}
]
}
]
})
const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(result.tabs[0]).toEqual(
expect.objectContaining({
type: 'terminal',
title: '\u280b OMP',
launchAgent: 'omp'
})
)
})
it('skips the foreground-process probe when the PTY launch agent is already known', async () => {
// Why: foregroundAgent is only the owner fallback when launchAgent is unknown,
// so probing a launched agent (e.g. omp) would burn a relay round-trip on every
// status transition without ever changing the resolved owner.
const getForegroundProcess = vi.fn(async () => 'omp')
const runtime = new OrcaRuntimeService(store)
runtime.setPtyController({
spawn: vi.fn().mockResolvedValue({ id: 'pty-omp' }),
write: () => true,
kill: () => true,
getForegroundProcess
})
runtime.attachWindow(1)
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: 'omp',
launchAgent: 'omp',
title: 'OMP',
activate: true
})
runtime.onPtyData('pty-omp', '\x1b]0;⠋ OMP\x07working\n', 100)
runtime.onPtyData('pty-omp', '\x1b]0;OMP ready\x07idle\n', 200)
expect(getForegroundProcess).not.toHaveBeenCalled()
})
it('probes the foreground process only on a status transition for unknown launch agents', async () => {
const getForegroundProcess = vi.fn(async () => 'omp')
const runtime = createRuntime()
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess
})
syncSinglePty(runtime, 'pty-bg')
// Why: each probe dedups while in-flight, so settle it before the next frame
// to prove the gate (not the dedup) is what suppresses extra probes.
const settleProbe = (): Promise<void> => new Promise((resolve) => setImmediate(resolve))
// Two working frames (spinner churn) collapse to a single status transition.
runtime.onPtyData('pty-bg', '\x1b]0;⠋ OMP\x07alpha\n', 100)
runtime.onPtyData('pty-bg', '\x1b]0;⠊ OMP\x07bravo\n', 200)
await settleProbe()
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
// Transition to idle is a second distinct status, so it probes again.
runtime.onPtyData('pty-bg', '\x1b]0;OMP ready\x07charlie\n', 300)
await settleProbe()
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
// A repeated idle frame is not a transition, so it does not probe again.
runtime.onPtyData('pty-bg', '\x1b]0;OMP ready\x07delta\n', 400)
await settleProbe()
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
})
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

@ -17,6 +17,10 @@ import {
type AgentStatusOrchestrationContext,
type AgentStatusEntry
} from '../../shared/agent-status-types'
import {
normalizeCompatibleAgentStatusEntryForOwner,
normalizeCompatibleAgentTitleForOwner
} from '../../shared/agent-title-owner'
import {
createAgentStatusOscProcessor,
type ProcessedAgentStatusChunk
@ -905,6 +909,7 @@ type RuntimePtyWorktreeRecord = {
launchConfig: SleepingAgentLaunchConfig | null
launchToken: string | null
launchAgent: TuiAgent | null
foregroundAgent: TuiAgent | null
connected: boolean
disconnectedAt: number | null
lastExitCode: number | null
@ -1866,6 +1871,7 @@ 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 _orchestrationDb: OrchestrationDb | null = null
private messageWaitersByHandle = new Map<string, Set<MessageWaiter>>()
// Why: mobile clients subscribe to terminal output via terminal.subscribe.
@ -3176,6 +3182,10 @@ export class OrcaRuntimeService {
.filter((group) => group.tabOrder.length > 0)
}
/**
* Publishes a PTY-backed terminal tab snapshot to the synced mobile session,
* normalizing Pi-compatible titles based on launch ownership.
*/
private publishPtyBackedMobileSessionTerminal(
worktreeId: string,
pty: RuntimePtyWorktreeRecord,
@ -3189,7 +3199,10 @@ export class OrcaRuntimeService {
}
): void {
const existing = this.mobileSessionTabsByWorktree.get(worktreeId)
const title = args.title ?? getLatestPtyTitle(pty) ?? 'Terminal'
const title = normalizeCompatibleAgentTitleForOwner(
args.title ?? getLatestPtyTitle(pty) ?? 'Terminal',
pty.launchAgent
)
const existingTab = existing?.tabs.find(
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
candidate.type === 'terminal' &&
@ -3219,6 +3232,7 @@ export class OrcaRuntimeService {
leafId: args.leafId,
ptyId: pty.ptyId,
title,
...(pty.launchAgent ? { launchAgent: pty.launchAgent } : {}),
parentLayout,
isActive:
args.activate || (args.selectIfNoActiveTab !== false && existing?.activeTabId == null)
@ -4942,6 +4956,10 @@ export class OrcaRuntimeService {
this.recordPtyWorktree(ptyId, worktreeId, { connected: true, connectionId })
}
/**
* Handles incoming data from a PTY process, running agent detection,
* updating terminal tail buffers, and triggering foreground agent refreshes.
*/
onPtyData(ptyId: string, data: string, at: number): number {
const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + data.length
this.ptyOutputSequenceById.set(ptyId, outputSequence)
@ -5034,6 +5052,12 @@ export class OrcaRuntimeService {
if (agentStatus === 'idle' && prevStatus !== 'idle') {
this.resolvePtyTuiIdleWaiters(pty, ptyId)
}
// 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)
}
}
}
@ -8401,6 +8425,64 @@ export class OrcaRuntimeService {
}
}
/**
* Schedules an asynchronous query to check which agent process is currently
* running in the foreground of a PTY.
*/
private refreshPtyForegroundAgent(ptyId: string): void {
void this.refreshPtyForegroundAgentFromController(ptyId)
}
/**
* Deduplicates and manages in-flight foreground agent refresh queries
* for a specific PTY.
*/
private refreshPtyForegroundAgentFromController(ptyId: string): Promise<void> {
const pendingRefresh = this.ptyForegroundAgentRefreshes.get(ptyId)
if (pendingRefresh) {
return pendingRefresh
}
const refresh = this.loadPtyForegroundAgentFromController(ptyId).finally(() => {
this.ptyForegroundAgentRefreshes.delete(ptyId)
})
this.ptyForegroundAgentRefreshes.set(ptyId, refresh)
return refresh
}
/**
* 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> {
if (!this.ptyController) {
return
}
const pty = this.ptysById.get(ptyId)
if (!pty?.connected) {
return
}
// 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
}
let foregroundProcess: string | null
try {
foregroundProcess = await this.ptyController.getForegroundProcess(ptyId)
} catch {
return
}
const foregroundAgent = foregroundProcess
? (recognizeAgentProcess(foregroundProcess)?.agent ?? null)
: null
if (pty.foregroundAgent === foregroundAgent) {
return
}
pty.foregroundAgent = foregroundAgent
this.touchMobileSessionSnapshotsForPty(ptyId)
}
private getFreshExplicitAgentStatusForHandle(handle: string): {
status: NonNullable<RuntimeTerminalAgentStatus['status']>
updatedAt: number
@ -17270,6 +17352,7 @@ export class OrcaRuntimeService {
launchConfig: null,
launchToken: null,
launchAgent: null,
foregroundAgent: null,
connected: state.connected ?? true,
disconnectedAt: state.connected === false ? Date.now() : null,
lastExitCode: null,
@ -17356,6 +17439,10 @@ export class OrcaRuntimeService {
return this.recordPtyWorktree(ptyId, inferredWorktreeId)
}
/**
* Synchronizes PTY tracking records with the running daemon sessions,
* querying their foreground agent states.
*/
private async refreshPtyWorktreeRecordsFromController(
resolvedWorktrees: ResolvedWorktree[],
targetWorktreeId: string | null = null
@ -17385,6 +17472,10 @@ export class OrcaRuntimeService {
connected: true
})
}
// Why: fire-and-forget so this listing hot path (listTerminals/getWorktreePs)
// does not serialize a relay round-trip per session — and a throwing snapshot
// listener cannot abort the liveness sweep below.
this.refreshPtyForegroundAgent(session.id)
}
for (const pty of this.ptysById.values()) {
if (!livePtyIds.has(pty.ptyId) && !this.leafExistsForPty(pty.ptyId)) {
@ -17850,6 +17941,10 @@ export class OrcaRuntimeService {
return first ?? second
}
/**
* Transforms an internal mobile session tab snapshot into a sanitized client payload,
* resolving launch agent ownership and normalizing titles.
*/
private toMobileSessionTabsResult(
snapshot: RuntimeMobileSessionTabsSnapshot
): RuntimeMobileSessionTabsResult {
@ -17884,6 +17979,7 @@ export class OrcaRuntimeService {
const leaf = this.leaves.get(this.getLeafKey(tab.parentTabId, tab.leafId)) ?? null
const liveLeaf = leaf?.ptyId && leaf.connected ? leaf : null
const liveLeafPtyId = liveLeaf?.ptyId ?? null
const liveLeafPty = liveLeafPtyId ? (this.ptysById.get(liveLeafPtyId) ?? null) : null
const pty = liveLeaf
? null
: this.findPtyForMobileTerminalTab(snapshot.worktree, tab, {
@ -17906,9 +18002,17 @@ export class OrcaRuntimeService {
{ title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt }
)
: null
const title = leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title
const launchAgent = tab.launchAgent ?? liveLeafPty?.launchAgent ?? pty?.launchAgent ?? null
const ownerAgent = launchAgent ?? liveLeafPty?.foregroundAgent ?? pty?.foregroundAgent ?? null
const title = normalizeCompatibleAgentTitleForOwner(
leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title,
ownerAgent
)
const liveTitleEvidence = leafTitle ?? ptyTitle
const liveTitleEvidenceClassification = classifyAgentTitle(liveTitleEvidence)
const normalizedTabAgentStatus = tab.agentStatus
? normalizeCompatibleAgentStatusEntryForOwner(tab.agentStatus, ownerAgent)
: null
// Why: keep the rich hook-driven status when the agent has a live
// interactive prompt or an active tool — those are authoritative agent
// activity even if the terminal's title isn't agent-classified (e.g. it
@ -17916,31 +18020,32 @@ export class OrcaRuntimeService {
// the OSC-title-only status and never sees interactivePrompt (the question
// card never renders).
const hasLiveAgentSignal =
tab.agentStatus?.interactivePrompt != null || tab.agentStatus?.toolName != null
normalizedTabAgentStatus?.interactivePrompt != null ||
normalizedTabAgentStatus?.toolName != null
const keepFullAgentStatus =
tab.agentStatus &&
normalizedTabAgentStatus &&
(liveTitleEvidence === null ||
liveTitleEvidenceClassification === 'agent' ||
hasLiveAgentSignal)
const agentStatus = keepFullAgentStatus
? { agentStatus: tab.agentStatus }
? { agentStatus: normalizedTabAgentStatus }
: // Why: when live title evidence says the pane is idle (e.g. the Claude
// agents picker or a neutral shell title), suppress the stale "working"
// state so the client shows no spinner — but retain agent identity
// (agentType + providerSession) so native chat can still address an
// idle agent's transcript. Reset the transient state to 'done'.
tab.agentStatus?.agentType != null
normalizedTabAgentStatus?.agentType != null
? {
agentStatus: {
state: 'done' as const,
prompt: '',
updatedAt: tab.agentStatus.updatedAt,
stateStartedAt: tab.agentStatus.stateStartedAt,
paneKey: tab.agentStatus.paneKey,
updatedAt: normalizedTabAgentStatus.updatedAt,
stateStartedAt: normalizedTabAgentStatus.stateStartedAt,
paneKey: normalizedTabAgentStatus.paneKey,
stateHistory: [],
agentType: tab.agentStatus.agentType,
...(tab.agentStatus.providerSession
? { providerSession: tab.agentStatus.providerSession }
agentType: normalizedTabAgentStatus.agentType,
...(normalizedTabAgentStatus.providerSession
? { providerSession: normalizedTabAgentStatus.providerSession }
: {})
}
}
@ -17966,7 +18071,7 @@ export class OrcaRuntimeService {
title,
...(tab.ptyId ? { ptyId: tab.ptyId } : {}),
...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}),
...(tab.launchAgent ? { launchAgent: tab.launchAgent } : {}),
...(launchAgent ? { launchAgent } : {}),
...(agentStatus ?? this.buildPtyMobileAgentStatus(livePty ?? pty, tab, terminalHandle)),
...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}),
...(tab.color != null ? { color: tab.color } : {}),
@ -18017,6 +18122,10 @@ export class OrcaRuntimeService {
}
}
/**
* Generates a mobile-friendly status entry for a PTY, aligning agentType
* and titles with the active owner.
*/
private buildPtyMobileAgentStatus(
pty: RuntimePtyWorktreeRecord | null,
tab: RuntimeMobileSessionTerminalTab,
@ -18034,6 +18143,8 @@ export class OrcaRuntimeService {
return {}
}
const now = pty.lastOutputAt ?? Date.now()
const ownerAgent = tab.launchAgent ?? pty.launchAgent ?? pty.foregroundAgent ?? null
const agentType = ownerAgent ?? undefined
return {
agentStatus: {
state:
@ -18047,10 +18158,13 @@ export class OrcaRuntimeService {
stateStartedAt: now,
paneKey: this.getMobileTerminalPaneKey(tab),
...(terminalHandle ? { terminalHandle } : {}),
...(tab.launchAgent ? { agentType: tab.launchAgent } : {}),
...(agentType ? { agentType } : {}),
worktreeId: pty.worktreeId,
tabId: tab.parentTabId,
terminalTitle: getLatestPtyTitle(pty) ?? tab.title,
terminalTitle: normalizeCompatibleAgentTitleForOwner(
getLatestPtyTitle(pty) ?? tab.title,
ownerAgent
),
stateHistory: []
}
}

View File

@ -141,6 +141,38 @@ describe('buildWorktreeAgentRows', () => {
expect(rows[0].agentType).toBe('codex')
})
it('prefers an unrelated live title over the launched tab agent for unknown rows', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { launchAgent: 'omp', title: '\u280b Codex' })],
entries: [
makeEntry(PANE_KEY_1, 1000, {
agentType: undefined,
terminalTitle: '\u280b Codex'
})
],
retained: [],
now: 2000
})
expect(rows[0].agentType).toBe('codex')
})
it('normalizes live Pi-compatible rows from the launched OMP tab agent', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { launchAgent: 'omp', title: '\u280b Pi' })],
entries: [
makeEntry(PANE_KEY_1, 1000, {
agentType: 'pi',
terminalTitle: '\u280b Pi'
})
],
retained: [],
now: 2000
})
expect(rows[0].agentType).toBe('omp')
})
it('resolves retained unknown rows from the launched tab agent', () => {
const retained = makeRetained(ORPHAN_PANE_KEY, 'wt-1', 1000, {
entry: makeEntry(ORPHAN_PANE_KEY, 1000, {

View File

@ -22,6 +22,7 @@ import {
buildTitleDerivedAgentRows,
resolveAgentTypeFromTerminalTitle
} from './worktree-title-derived-agent-rows'
import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner'
function tabFromAttributedStatusEntry(entry: AgentStatusEntry): TerminalTab | null {
const parsed = parsePaneKey(entry.paneKey)
@ -40,14 +41,19 @@ function tabFromAttributedStatusEntry(entry: AgentStatusEntry): TerminalTab | nu
}
}
/**
* Resolves the sidebar row agent type, prioritizing launch agent configuration
* and normalizing compatible agent kinds.
*/
function resolveRowAgentType(entry: AgentStatusEntry, tab?: TerminalTab | null): AgentType {
if (entry.agentType && entry.agentType !== 'unknown') {
return entry.agentType
const entryAgentType = resolveCompatibleAgentTypeForOwner(entry.agentType, tab?.launchAgent)
if (entryAgentType && entryAgentType !== 'unknown') {
return entryAgentType
}
return (
resolveAgentTypeFromTerminalTitle(entry.terminalTitle ?? tab?.title, tab?.launchAgent) ??
tab?.launchAgent ??
resolveAgentTypeFromTerminalTitle(entry.terminalTitle ?? tab?.title) ??
entry.agentType ??
entryAgentType ??
'unknown'
)
}

View File

@ -69,6 +69,46 @@ describe('buildTitleDerivedAgentRows', () => {
])
})
it('normalizes Pi-compatible title-derived rows to the launched OMP owner', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { launchAgent: 'omp' })],
entries: [],
retained: [],
runtimePaneTitlesByTabId: {
'tab-1': {
1: '\u280b π: tmp'
}
},
ptyIdsByTabId: { 'tab-1': ['pty-omp'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
now: 2000
})
expect(rows.map((row) => [row.agentType, row.state, row.entry.terminalTitle])).toEqual([
['omp', 'working', '\u280b OMP']
])
})
it('keeps Pi-compatible title-derived rows as Pi for launched Pi sessions', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1', { launchAgent: 'pi' })],
entries: [],
retained: [],
runtimePaneTitlesByTabId: {
'tab-1': {
1: '\u280b Pi'
}
},
ptyIdsByTabId: { 'tab-1': ['pty-pi'] },
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
now: 2000
})
expect(rows.map((row) => [row.agentType, row.state, row.entry.terminalTitle])).toEqual([
['pi', 'working', '\u280b Pi']
])
})
it('does not add title-derived rows for panes without a live PTY', () => {
const rows = buildWorktreeAgentRows({
tabs: [makeTab('tab-1')],

View File

@ -17,6 +17,10 @@ import type {
TerminalPaneLayoutNode,
TerminalTab
} from '../../../../shared/types'
import {
normalizeCompatibleAgentTitleForOwner,
resolveCompatibleAgentTypeForOwner
} from '../../../../shared/agent-title-owner'
const EMPTY_RUNTIME_TITLES: Record<string, Record<number, string>> = {}
const EMPTY_LIVE_PTY_IDS: Record<string, string[]> = {}
@ -36,7 +40,8 @@ const TITLE_AGENT_LABEL_TO_TYPE: Record<string, AgentType> = {
Cursor: 'cursor',
Droid: 'droid',
Hermes: 'hermes',
Pi: 'pi'
Pi: 'pi',
OMP: 'omp'
}
const CLAUDE_AGENT_TOKEN_RE = /(?<![\w./\\-])claude(?![\w./\\-])/i
@ -114,6 +119,10 @@ export function buildTitleDerivedAgentRows(args: {
return rows
}
/**
* Constructs a dashboard agent row from a terminal tab's title fallback,
* normalising Pi-compatible agent names to their owner.
*/
function buildTitleDerivedAgentRow(args: {
tab: TerminalTab
leafId: string
@ -121,12 +130,13 @@ function buildTitleDerivedAgentRow(args: {
now: number
runtimeAgentOrchestrationByPaneKey?: Record<string, AgentStatusOrchestrationContext>
}): DashboardAgentRow | null {
const isClaudeAgentsTitle = isClaudeManagementTitle(args.title)
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' : detectAgentStatusFromTitle(args.title)
const label = isClaudeAgentsTitle ? 'Claude Code' : getAgentLabel(args.title)
const status = isClaudeAgentsTitle ? 'idle' : detectAgentStatusFromTitle(title)
const label = isClaudeAgentsTitle ? 'Claude Code' : getAgentLabel(title)
if (!status || !label) {
return null
}
@ -135,7 +145,7 @@ function buildTitleDerivedAgentRow(args: {
}
const paneKey = makePaneKey(args.tab.id, args.leafId)
const orchestration = args.runtimeAgentOrchestrationByPaneKey?.[paneKey]
const agentType = isClaudeAgentsTitle ? 'claude' : resolveTitleDerivedAgentType(args.title, label)
const agentType = isClaudeAgentsTitle ? 'claude' : resolveTitleDerivedAgentType(title, label)
if (!agentType) {
return null
}
@ -151,7 +161,7 @@ function buildTitleDerivedAgentRow(args: {
stateStartedAt: args.now,
stateHistory: [],
agentType,
terminalTitle: args.title,
terminalTitle: title,
lastAssistantMessage: secondary,
...(orchestration ? { orchestration } : {})
}
@ -177,14 +187,25 @@ export function resolveTitleDerivedAgentType(title: string, label: string): Agen
return CLAUDE_AGENT_TOKEN_RE.test(title) ? agentType : null
}
/**
* Determines the agent type from a terminal title, normalising Pi-compatible
* agents to their authoritative owner if specified.
*/
export function resolveAgentTypeFromTerminalTitle(
title: string | null | undefined
title: string | null | undefined,
ownerAgentType?: AgentType | null
): AgentType | null {
if (!title) {
return null
}
const label = getAgentLabel(title)
return label ? resolveTitleDerivedAgentType(title, label) : null
const normalizedTitle = normalizeCompatibleAgentTitleForOwner(title, ownerAgentType)
const label = getAgentLabel(normalizedTitle)
return label
? (resolveCompatibleAgentTypeForOwner(
resolveTitleDerivedAgentType(normalizedTitle, label),
ownerAgentType
) ?? null)
: null
}
function titleStatusToRowState(

View File

@ -52,7 +52,10 @@ function leafIdForPane(paneId: number): string {
type StoreState = {
activeWorktreeId: string | null
tabsByWorktree: Record<string, { id: string; ptyId: string | null; title?: string }[]>
tabsByWorktree: Record<
string,
{ id: string; ptyId: string | null; title?: string; launchAgent?: string }[]
>
ptyIdsByTabId?: Record<string, string[]>
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot>
unreadTerminalTabs?: Record<string, true>
@ -9287,40 +9290,76 @@ describe('connectPanePty', () => {
expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Codex - action required')
})
it('resolves synthetic terminal titles for remote hook status updates', async () => {
it('normalizes Pi-compatible remote titles to authoritative OMP launch identity', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-devin')
const transport = createMockTransport('pty-omp')
transportFactoryQueue.push(transport)
enableActiveRuntimeEnvironment()
mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: '\u280b Devin' } }
mockStoreState.tabsByWorktree = {
'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', launchAgent: 'omp' }]
}
mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: '\u280b Pi' } }
const pane = createPane(1)
const manager = createManager(1)
manager.getActivePane.mockReturnValue({ id: 1 })
const deps = createDeps()
connectPanePty(pane as never, manager as never, deps as never)
const titleHandler = createdTransportOptions[0]?.onTitleChange as
| ((title: string, rawTitle: string) => void)
| undefined
if (!titleHandler) {
throw new Error('Expected onTitleChange to be registered')
}
titleHandler('\u280b Pi', '\u280b Pi')
expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, '\u280b OMP')
expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', '\u280b OMP')
titleHandler('π: tmp', 'π: tmp')
expect(deps.setRuntimePaneTitle).toHaveBeenLastCalledWith('tab-1', 1, 'OMP ready')
expect(deps.updateTabTitle).toHaveBeenLastCalledWith('tab-1', 'OMP ready')
const statusHandler = createdTransportOptions[0]?.onAgentStatus as
| ((payload: { state: 'done'; prompt: string; agentType: 'devin' }) => void)
| ((payload: { state: 'working'; prompt: string; agentType: 'pi' }) => void)
| undefined
if (!statusHandler) {
throw new Error('Expected onAgentStatus to be registered')
}
statusHandler({
state: 'done',
prompt: 'finish the implementation',
agentType: 'devin'
state: 'working',
prompt: 'fix the remote title',
agentType: 'pi'
})
expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith(
makePaneKey('tab-1', LEAF_1),
{
state: 'done',
prompt: 'finish the implementation',
agentType: 'devin'
state: 'working',
prompt: 'fix the remote title',
agentType: 'omp'
},
'Devin ready'
'\u280b OMP'
)
mockStoreState.tabsByWorktree = {
'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }]
}
statusHandler({
state: 'working',
prompt: 'keep the remote title',
agentType: 'pi'
})
expect(mockStoreState.setAgentStatus).toHaveBeenLastCalledWith(
makePaneKey('tab-1', LEAF_1),
{
state: 'working',
prompt: 'keep the remote title',
agentType: 'omp'
},
'\u280b OMP'
)
})

View File

@ -76,7 +76,7 @@ import { createBrowserUuid } from '@/lib/browser-uuid'
import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id'
import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
import { e2eConfig } from '@/lib/e2e-config'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types'
import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id'
import {
createAgentInterruptInference,
@ -132,6 +132,10 @@ import {
type ResumableTuiAgent,
type SleepingAgentSessionRecord
} from '../../../../shared/agent-session-resume'
import {
normalizeCompatibleAgentTitleForOwner,
resolveCompatibleAgentTypeForOwner
} from '../../../../shared/agent-title-owner'
import type { TuiAgent } from '../../../../shared/types'
import { isWslUncPath } from '../../../../shared/wsl-paths'
@ -808,6 +812,10 @@ function containsCursorRestore(data: string): boolean {
return hideIndex !== -1 && showIndex > hideIndex && containsCursorPositionSequence(data)
}
/**
* Establishes a binding between a terminal pane and its corresponding PTY stream,
* managing input, output, title synchronization, and agent status tracking.
*/
export function connectPanePty(
pane: ManagedPane,
manager: PaneManager,
@ -975,6 +983,26 @@ export function connectPanePty(
)
return tab?.defaultTitle?.trim() || 'Terminal'
}
/**
* Resolves the authoritative owner agent type for this pane, checking tab launch,
* pane startup, 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.
*/
const getAuthoritativePaneAgent = (): AgentType | undefined => {
const state = useAppStore.getState()
const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find(
(entry) => entry.id === deps.tabId
)
return (
tab?.launchAgent ??
paneStartup?.launchAgent ??
paneStartup?.initialAgentStatus?.agent ??
state.agentStatusByPaneKey[cacheKey]?.agentType
)
}
const clearInferredInterruptWorkingTitle = (): void => {
const state = useAppStore.getState()
const currentTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
@ -1429,8 +1457,9 @@ export function connectPanePty(
let allowInitialIdleCacheSeed = false
const onTitleChange = (title: string, rawTitle: string): void => {
const paneTitle = normalizeCompatibleAgentTitleForOwner(title, getAuthoritativePaneAgent())
if (
shouldSuppressCodexAutoApprovalSyntheticTitle(title, {
shouldSuppressCodexAutoApprovalSyntheticTitle(paneTitle, {
paneKey: cacheKey,
tabId: deps.tabId,
...(launchToken ? { launchToken } : {})
@ -1439,7 +1468,7 @@ export function connectPanePty(
return
}
manager.setPaneGpuRendering(pane.id, !isGeminiTerminalTitle(rawTitle))
deps.setRuntimePaneTitle(deps.tabId, pane.id, title)
deps.setRuntimePaneTitle(deps.tabId, pane.id, paneTitle)
if (syncAgentTaskCompleteTrackingEnabled()) {
agentCompletionCoordinator.observeTitle(rawTitle)
}
@ -1449,7 +1478,7 @@ export function connectPanePty(
// focus changes, onActivePaneChange syncs the newly active pane's stored
// title to the tab.
if (manager.getActivePane()?.id === pane.id) {
deps.updateTabTitle(deps.tabId, title)
deps.updateTabTitle(deps.tabId, paneTitle)
}
if (!hasConsideredInitialCacheTimerSeed) {
@ -1476,7 +1505,10 @@ export function connectPanePty(
const statusPayload = {
state: 'working' as const,
prompt: initialStatus.prompt,
agentType: initialStatus.agent
agentType: resolveCompatibleAgentTypeForOwner(
initialStatus.agent,
getAuthoritativePaneAgent()
)
}
if (paneStartup.launchConfig) {
useAppStore
@ -1959,20 +1991,40 @@ export function connectPanePty(
// be stored against a title that was never paired with it.
const currentState = useAppStore.getState()
const title = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
const statusTitle = resolveAgentStatusTerminalTitle(payload, title)
const authoritativePaneAgent = getAuthoritativePaneAgent()
const agentType = resolveCompatibleAgentTypeForOwner(
payload.agentType,
authoritativePaneAgent
)
const statusPayload =
agentType === payload.agentType ? payload : { ...payload, agentType }
const resolvedStatusTitle = resolveAgentStatusTerminalTitle(statusPayload, title)
const statusTitle = resolvedStatusTitle
? normalizeCompatibleAgentTitleForOwner(
resolvedStatusTitle,
agentType ?? authoritativePaneAgent
)
: resolvedStatusTitle
if (launchToken) {
currentState.setAgentStatus(cacheKey, payload, statusTitle, undefined, undefined, {
launchToken
})
currentState.setAgentStatus(
cacheKey,
statusPayload,
statusTitle,
undefined,
undefined,
{
launchToken
}
)
} else {
currentState.setAgentStatus(cacheKey, payload, statusTitle)
currentState.setAgentStatus(cacheKey, statusPayload, statusTitle)
}
if (syncAgentTaskCompleteTrackingEnabled()) {
const storedStatus = useAppStore.getState().agentStatusByPaneKey[cacheKey]
const notificationPayload =
typeof storedStatus?.stateStartedAt === 'number'
? { ...payload, stateStartedAt: storedStatus.stateStartedAt }
: payload
? { ...statusPayload, stateStartedAt: storedStatus.stateStartedAt }
: statusPayload
agentCompletionCoordinator.observeHookStatus(notificationPayload)
}
if (payload.state === 'working' && pendingTerminalBellNotification) {

View File

@ -31,7 +31,8 @@ const TITLE_LABEL_TO_AGENT: Partial<Record<string, TuiAgent>> = {
Cursor: 'cursor',
Droid: 'droid',
Hermes: 'hermes',
Pi: 'pi'
Pi: 'pi',
OMP: 'omp'
}
const HELPER_FOREGROUND_RETRY_DELAYS_MS = [250, 1250, 3500, 750] as const

View File

@ -1509,6 +1509,49 @@ describe('applyWebSessionTabsSnapshot', () => {
expect(patch.sortEpoch).toBe(1)
})
it('keeps mirrored OMP tabs from repainting to Pi-compatible titles', () => {
const hostPaneKey = makePaneKey('host-tab-1', LEAF_ID)
const patch = applyWebSessionTabsSnapshot(
makeState(),
makeSnapshot([
{
type: 'terminal',
id: HOST_SURFACE_ID,
title: 'Pi ready',
parentTabId: 'host-tab-1',
leafId: LEAF_ID,
isActive: true,
status: 'ready',
terminal: 'terminal-1',
launchAgent: 'omp',
agentStatus: {
state: 'done',
prompt: '',
updatedAt: NOW - 100,
stateStartedAt: NOW - 1_000,
agentType: 'pi',
paneKey: hostPaneKey,
terminalTitle: 'Pi ready',
stateHistory: []
}
}
]),
ENV,
NOW
) as Partial<WebSessionTabsSyncState>
const mirroredId = patch.tabsByWorktree?.[WT]?.[0]?.id
const mirroredPaneKey = makePaneKey(mirroredId!, LEAF_ID)
expect(patch.tabsByWorktree?.[WT]?.[0]).toMatchObject({
title: 'OMP ready',
launchAgent: 'omp'
})
expect(patch.agentStatusByPaneKey?.[mirroredPaneKey]).toMatchObject({
agentType: 'omp',
terminalTitle: 'OMP ready'
})
})
it('hydrates multiple initial host snapshots in one merged patch', () => {
const secondWorktree = 'repo::/other-worktree'
const patch = applyWebSessionTabsSnapshots(

View File

@ -43,6 +43,10 @@ import {
toWebTerminalSurfaceTabId,
WEB_TERMINAL_SURFACE_TAB_PREFIX
} from './web-runtime-session'
import {
normalizeCompatibleAgentStatusEntryForOwner,
normalizeCompatibleAgentTitleForOwner
} from '../../../shared/agent-title-owner'
import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution'
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
import { clearWebSessionFocusIntent, peekWebSessionFocusIntent } from './web-session-focus-intent'
@ -479,6 +483,10 @@ function shouldReplaceTerminalTab(
)
}
/**
* Constructs mirrored terminal tabs from the mobile session status payload,
* normalising Pi-compatible agent titles under launch ownership.
*/
function buildMirroredTerminalTabs(
snapshot: RuntimeMobileSessionTabsResult,
environmentId: string,
@ -511,7 +519,16 @@ function buildMirroredTerminalTabs(
const ptyIds = surfaces
.map((surface) => ptyIdsByLeafId[surface.leafId]!)
.filter((ptyId): ptyId is string => typeof ptyId === 'string' && ptyId.length > 0)
const title = activeSurface.title.trim() || surfaces[0]?.title.trim() || 'Terminal'
const launchAgent =
activeSurface.launchAgent ?? surfaces.find((surface) => surface.launchAgent)?.launchAgent
const ownerAgent =
launchAgent ??
activeSurface.agentStatus?.agentType ??
surfaces.find((surface) => surface.agentStatus?.agentType)?.agentStatus?.agentType
const title = normalizeCompatibleAgentTitleForOwner(
activeSurface.title.trim() || surfaces[0]?.title.trim() || 'Terminal',
ownerAgent
)
const existing =
existingById.get(localTabId) ??
existingById.get(parentTabId) ??
@ -522,8 +539,6 @@ function buildMirroredTerminalTabs(
activeSurface.quickCommandLabel?.trim() ||
surfaces.find((surface) => surface.quickCommandLabel?.trim())?.quickCommandLabel?.trim() ||
existing?.quickCommandLabel?.trim()
const launchAgent =
activeSurface.launchAgent ?? surfaces.find((surface) => surface.launchAgent)?.launchAgent
// Why: tab color/pin echo back through host snapshots, so prefer the client's
// own record (kept authoritative in tabsByWorktree by the pin/color setters)
// and fall back to the host value only when this client has no prior tab —
@ -571,6 +586,10 @@ function toMirroredPaneKey(surface: TerminalSurface): string | null {
return makePaneKey(toWebTerminalSurfaceTabId(surface.parentTabId), surface.leafId)
}
/**
* Normalises and mirrors agent status updates from the host payload,
* preserving authoritative ownership metadata.
*/
function remapHostAgentStatus(surface: TerminalSurface): AgentStatusEntry | null {
if (!surface.agentStatus) {
return null
@ -579,8 +598,9 @@ function remapHostAgentStatus(surface: TerminalSurface): AgentStatusEntry | null
if (!paneKey) {
return null
}
const ownerAgent = surface.launchAgent ?? surface.agentStatus.agentType
return {
...surface.agentStatus,
...normalizeCompatibleAgentStatusEntryForOwner(surface.agentStatus, ownerAgent),
paneKey
}
}
@ -590,6 +610,10 @@ function isMirroredAgentPaneKeyForTabs(paneKey: string, tabIds: ReadonlySet<stri
return parsed !== null && tabIds.has(parsed.tabId)
}
/**
* Generates a state patch for mirrored agent statuses, merging host
* status entries with client overrides defensively.
*/
function buildMirroredAgentStatusPatch(
state: WebSessionTabsSyncState,
currentTerminalTabs: readonly TerminalTab[],
@ -620,10 +644,11 @@ function buildMirroredAgentStatusPatch(
// Why: active web streams can report a fresher OSC 9999 status for the same
// mirrored pane before the next host snapshot arrives. Do not rewind that
// row with an older host publication.
nextByPaneKey.set(
entry.paneKey,
existing && existing.updatedAt > entry.updatedAt ? existing : entry
)
const nextEntry =
existing && existing.updatedAt > entry.updatedAt
? normalizeCompatibleAgentStatusEntryForOwner(existing, entry.agentType)
: entry
nextByPaneKey.set(entry.paneKey, nextEntry)
}
let nextAgentStatusByPaneKey = state.agentStatusByPaneKey

View File

@ -7,6 +7,11 @@ import {
getAgentLabel,
MAX_OSC_TITLE_CHARS
} from './agent-detection'
import {
normalizeCompatibleAgentStatusEntryForOwner,
normalizeCompatibleAgentTitleForOwner,
resolveCompatibleAgentTypeForOwner
} from './agent-title-owner'
afterEach(() => {
vi.restoreAllMocks()
@ -91,6 +96,44 @@ describe('Pi-compatible title detection', () => {
expect(detectAgentStatusFromTitle(title)).toBe(expectedStatus)
})
it.each([
['\u280b Pi', 'omp', '\u280b OMP'],
['Pi ready', 'omp', 'OMP ready'],
['Pi - action required', 'omp', 'OMP - action required'],
['π - tmp', 'omp', 'OMP ready'],
['π: tmp', 'omp', 'OMP ready'],
['\u280b π: tmp', 'omp', '\u280b OMP'],
['\u280b π - tmp', 'omp', '\u280b OMP'],
['\u280b OMP', 'pi', '\u280b Pi']
] as const)('normalizes %s to the authoritative %s owner', (title, owner, expectedTitle) => {
expect(normalizeCompatibleAgentTitleForOwner(title, owner)).toBe(expectedTitle)
})
it('preserves Pi-compatible custom titles and unrelated owners', () => {
expect(normalizeCompatibleAgentTitleForOwner('Fix pi bugs', 'omp')).toBe('Fix pi bugs')
expect(normalizeCompatibleAgentTitleForOwner('\u280b Pi', 'codex')).toBe('\u280b Pi')
})
it('normalizes Pi-compatible status identity and terminal title to the owner', () => {
const status = normalizeCompatibleAgentStatusEntryForOwner(
{
state: 'working',
prompt: '',
updatedAt: 1,
stateStartedAt: 1,
agentType: 'pi',
paneKey: 'tab-1:leaf-1',
terminalTitle: '\u280b Pi',
stateHistory: []
},
'omp'
)
expect(status.agentType).toBe('omp')
expect(status.terminalTitle).toBe('\u280b OMP')
expect(resolveCompatibleAgentTypeForOwner('codex', 'omp')).toBe('codex')
})
it.each(['~/omp/working', 'omp-harness ready', '~/pi/working', 'pi-scratch ready'])(
'does not classify path or hyphen false positive %s',
(title) => {

View File

@ -0,0 +1,166 @@
import { detectAgentStatusFromTitle, getAgentLabel } from './agent-detection'
import type { AgentStatusEntry, AgentType } from './agent-status-types'
import {
getSyntheticAgentTitleProfile,
SYNTHETIC_AGENT_TITLE_PROFILES,
type SyntheticAgentTitleProfile
} from './synthetic-agent-title'
type TitleProfileMatch = {
profile: SyntheticAgentTitleProfile
}
const COMPATIBLE_IDLE_TITLE_RE = /(?<![\w./\\-])(?:ready|idle|done)(?![\w-])/i
const LEGACY_PI_COMPATIBLE_TITLE_RE = /^\s*(?:[\u2800-\u28ff]\s+)?π\s*(?:[-:]|\s)\s*.+/u
/**
* Resolves the synthetic title profile matching a given agent label.
*/
function getProfileForTitleLabel(label: string | null): TitleProfileMatch | null {
if (!label) {
return null
}
const normalizedLabel = label.trim().toLowerCase()
for (const profile of Object.values(SYNTHETIC_AGENT_TITLE_PROFILES)) {
if (profile.workingLabel.toLowerCase() === normalizedLabel) {
return { profile }
}
}
return null
}
/**
* Resolves the synthetic title profile matching a given terminal title.
*/
function getProfileForTitle(title: string): TitleProfileMatch | null {
const labelProfile = getProfileForTitleLabel(getAgentLabel(title))
if (labelProfile) {
return labelProfile
}
if (LEGACY_PI_COMPATIBLE_TITLE_RE.test(title)) {
return getProfileForTitleLabel('Pi')
}
return null
}
/**
* Detects the agent status (working, permission, idle) from a terminal title,
* accounting for legacy Pi titles.
*/
function getSourceTitleStatus(title: string): 'working' | 'permission' | 'idle' | null {
const detectedStatus = detectAgentStatusFromTitle(title)
if (detectedStatus) {
return detectedStatus
}
if (LEGACY_PI_COMPATIBLE_TITLE_RE.test(title)) {
return 'idle'
}
return null
}
/**
* Checks if a title indicates an agent is waiting for permissions or input.
*/
function hasPermissionSuffix(title: string, sourceProfile: SyntheticAgentTitleProfile): boolean {
const normalizedTitle = title.trim().toLowerCase()
return (
normalizedTitle === sourceProfile.permissionLabel.toLowerCase() ||
normalizedTitle.includes('action required') ||
normalizedTitle.includes('permission') ||
normalizedTitle.includes('waiting')
)
}
/**
* Checks if a title indicates an agent is idle or ready.
*/
function hasIdleSuffix(title: string, sourceProfile: SyntheticAgentTitleProfile): boolean {
const normalizedTitle = title.trim().toLowerCase()
return (
normalizedTitle === sourceProfile.idleLabel.toLowerCase() ||
COMPATIBLE_IDLE_TITLE_RE.test(title)
)
}
/**
* Why: remote OMP surfaces may report Pi as the live status identity, while
* launch ownership still identifies the user-selected agent.
*/
export function resolveCompatibleAgentTypeForOwner(
incomingAgentType: AgentType | null | undefined,
ownerAgentType: AgentType | null | undefined
): AgentType | undefined {
if (!incomingAgentType) {
return undefined
}
const incomingProfile = getSyntheticAgentTitleProfile(incomingAgentType)
const ownerProfile = getSyntheticAgentTitleProfile(ownerAgentType)
if (
!incomingProfile?.titleIdentityGroup ||
!ownerProfile?.titleIdentityGroup ||
incomingProfile.titleIdentityGroup !== ownerProfile.titleIdentityGroup
) {
return incomingAgentType
}
return ownerAgentType as AgentType
}
/**
* Why: Pi-compatible title frames can come from the wrapped harness during
* active work, so render them through the stable owner profile.
*/
export function normalizeCompatibleAgentTitleForOwner(
title: string,
ownerAgentType: AgentType | null | undefined
): string {
const ownerProfile = getSyntheticAgentTitleProfile(ownerAgentType)
if (!ownerProfile?.titleIdentityGroup) {
return title
}
const source = getProfileForTitle(title)
if (
!source?.profile.titleIdentityGroup ||
source.profile.titleIdentityGroup !== ownerProfile.titleIdentityGroup
) {
return title
}
const sourceStatus = getSourceTitleStatus(title)
if (sourceStatus === 'working') {
return `\u280b ${ownerProfile.workingLabel}`
}
if (sourceStatus === 'permission') {
return ownerProfile.permissionLabel
}
if (sourceStatus === 'idle') {
return ownerProfile.idleLabel
}
if (hasPermissionSuffix(title, source.profile)) {
return ownerProfile.permissionLabel
}
if (hasIdleSuffix(title, source.profile)) {
return ownerProfile.idleLabel
}
return ownerProfile.workingLabel
}
/**
* Why: mirrored remote status entries must keep the owner and title in sync
* or later snapshots repaint the same tab under the wrapper agent.
*/
export function normalizeCompatibleAgentStatusEntryForOwner(
entry: AgentStatusEntry,
ownerAgentType: AgentType | null | undefined
): AgentStatusEntry {
const agentType = resolveCompatibleAgentTypeForOwner(entry.agentType, ownerAgentType)
const terminalTitle = entry.terminalTitle
? normalizeCompatibleAgentTitleForOwner(entry.terminalTitle, agentType ?? ownerAgentType)
: entry.terminalTitle
if (agentType === entry.agentType && terminalTitle === entry.terminalTitle) {
return entry
}
return {
...entry,
...(agentType ? { agentType } : {}),
...(terminalTitle ? { terminalTitle } : {})
}
}

View File

@ -4,6 +4,7 @@ export type SyntheticAgentTitleProfile = {
workingLabel: string
permissionLabel: string
idleLabel: string
titleIdentityGroup?: string
synthesizeWorkingTitle?: boolean
}
@ -29,12 +30,14 @@ export const SYNTHETIC_AGENT_TITLE_PROFILES: Record<string, SyntheticAgentTitleP
pi: {
workingLabel: 'Pi',
permissionLabel: 'Pi - action required',
idleLabel: 'Pi ready'
idleLabel: 'Pi ready',
titleIdentityGroup: 'pi-compatible'
},
omp: {
workingLabel: 'OMP',
permissionLabel: 'OMP - action required',
idleLabel: 'OMP ready'
idleLabel: 'OMP ready',
titleIdentityGroup: 'pi-compatible'
},
droid: {
workingLabel: 'Droid',