diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index ec21d6976..6813c97b8 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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 => 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' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 44bb96f56..ed4f02bd5 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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>() private agentDetector: AgentDetector | null = null + private ptyForegroundAgentRefreshes = new Map>() private _orchestrationDb: OrchestrationDb | null = null private messageWaitersByHandle = new Map>() // 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 { + 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 { + 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 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: [] } } diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts index c763304a3..4e86f07c0 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts @@ -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, { diff --git a/src/renderer/src/components/sidebar/worktree-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-agent-rows.ts index 47a30526f..72dca370e 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-rows.ts @@ -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' ) } diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts index becaafb3e..1114dd9e1 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts @@ -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')], diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts index 5b37123b0..de8779b05 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts @@ -17,6 +17,10 @@ import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/types' +import { + normalizeCompatibleAgentTitleForOwner, + resolveCompatibleAgentTypeForOwner +} from '../../../../shared/agent-title-owner' const EMPTY_RUNTIME_TITLES: Record> = {} const EMPTY_LIVE_PTY_IDS: Record = {} @@ -36,7 +40,8 @@ const TITLE_AGENT_LABEL_TO_TYPE: Record = { Cursor: 'cursor', Droid: 'droid', Hermes: 'hermes', - Pi: 'pi' + Pi: 'pi', + OMP: 'omp' } const CLAUDE_AGENT_TOKEN_RE = /(? }): 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( diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index ce9b2e20c..d71ad5d7f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -52,7 +52,10 @@ function leafIdForPane(paneId: number): string { type StoreState = { activeWorktreeId: string | null - tabsByWorktree: Record + tabsByWorktree: Record< + string, + { id: string; ptyId: string | null; title?: string; launchAgent?: string }[] + > ptyIdsByTabId?: Record terminalLayoutsByTabId?: Record unreadTerminalTabs?: Record @@ -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' ) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 7aa267605..de7a9e611 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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) { diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 04a108f1b..a30d16c2d 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -31,7 +31,8 @@ const TITLE_LABEL_TO_AGENT: Partial> = { Cursor: 'cursor', Droid: 'droid', Hermes: 'hermes', - Pi: 'pi' + Pi: 'pi', + OMP: 'omp' } const HELPER_FOREGROUND_RETRY_DELAYS_MS = [250, 1250, 3500, 750] as const diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index b31cb9a92..ed91b7a5d 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -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 + + 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( diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index edb7ccfa0..74496159e 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -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 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 diff --git a/src/shared/agent-detection.test.ts b/src/shared/agent-detection.test.ts index 61796a29a..b4f849554 100644 --- a/src/shared/agent-detection.test.ts +++ b/src/shared/agent-detection.test.ts @@ -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) => { diff --git a/src/shared/agent-title-owner.ts b/src/shared/agent-title-owner.ts new file mode 100644 index 000000000..a72a46b1c --- /dev/null +++ b/src/shared/agent-title-owner.ts @@ -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 = /(?