diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index dad2e4aaa..1c2dda4cb 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -3497,19 +3497,17 @@ describe('OrcaRuntimeService', () => { expect.arrayContaining([ expect.objectContaining({ worktreeId: `${TEST_REPO_ID}::C:\\Repo`, - worktreePath: 'C:\\Repo', - title: 'Windows shell' + worktreePath: 'C:\\Repo' }), expect.objectContaining({ worktreeId: `${TEST_REPO_ID}:://Server/Share/Repo`, - worktreePath: '//Server/Share/Repo', - title: 'UNC shell' + worktreePath: '//Server/Share/Repo' }) ]) ) }) - it('prefers OSC titles over provider titles for rendererless PTYs', async () => { + it('uses OSC titles rather than controller process names for rendererless PTYs', async () => { const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg` const runtime = createRuntime() runtime.setPtyController({ @@ -3522,7 +3520,7 @@ describe('OrcaRuntimeService', () => { runtime.markGraphReady(1) expect((await runtime.listTerminals()).terminals[0]).toMatchObject({ - title: 'shell' + title: null }) runtime.onPtyData(ptyId, '\x1b]0;Codex\x07', 123) @@ -3530,6 +3528,10 @@ describe('OrcaRuntimeService', () => { expect((await runtime.listTerminals()).terminals[0]).toMatchObject({ title: 'Codex' }) + + expect((await runtime.listTerminals()).terminals[0]).toMatchObject({ + title: 'Codex' + }) }) it('returns OSC titles from headless main terminal snapshots', async () => { @@ -5049,6 +5051,47 @@ describe('OrcaRuntimeService', () => { }) }) + it('reveals background terminal sessions with the freshest PTY title', async () => { + const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-adopted' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession, + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal: vi.fn(), + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + title: 'Claude working' + }) + runtime.onPtyData('pty-bg', '\x1b]0;claude agents\x07', 100) + + await runtime.focusTerminal(handle) + + expect(revealTerminalSession).toHaveBeenLastCalledWith( + TEST_WORKTREE_ID, + expect.objectContaining({ + ptyId: 'pty-bg', + title: 'claude agents' + }) + ) + }) + it('rejects focusing an exited background terminal session', async () => { const revealTerminalSession = vi.fn() const runtime = new OrcaRuntimeService(store) @@ -5999,6 +6042,580 @@ describe('OrcaRuntimeService', () => { await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(true) }) + it('does not recognize runtime-created Claude agents management screens as agents', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'claude agents', + title: 'claude agents' + }) + + await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(false) + }) + + it('uses stale runtime-created PTY status when there is no title or foreground evidence', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'claude' + }) + const pty = ( + runtime as unknown as { + ptysById: Map< + string, + { + lastAgentStatus: 'working' | null + } + > + } + ).ptysById.get('pty-bg') + expect(pty).toBeDefined() + if (!pty) { + throw new Error('expected runtime PTY record') + } + pty.lastAgentStatus = 'working' + runtime.setPtyController(null) + + await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(true) + }) + + it('lets Claude agents management titles clear stale runtime-created title status', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'claude agents', + title: 'claude agents' + }) + const pty = ( + runtime as unknown as { + ptysById: Map< + string, + { + lastAgentStatus: 'working' | null + lastOscTitle: string | null + lastOscTitleAt: number | null + } + > + } + ).ptysById.get('pty-bg') + expect(pty).toBeDefined() + if (!pty) { + throw new Error('expected runtime PTY record') + } + pty.lastAgentStatus = 'working' + pty.lastOscTitle = 'claude agents' + pty.lastOscTitleAt = 0 + + await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(false) + }) + + it('does not recognize live Claude agents panes from a Claude foreground process', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude' + }) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'claude agents' }) + const [terminal] = (await runtime.listTerminals()).terminals + + await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(false) + }) + + it('lets Claude agents pane titles override stale live-leaf title status', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude' + }) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'claude working' }) + runtime.onPtyData('pty-1', '\x1b]0;claude working\x07', 100) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'claude agents' }) + const [terminal] = (await runtime.listTerminals()).terminals + + await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(false) + }) + + it('lets Claude agents OSC titles override stale live-leaf pane titles', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude' + }) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'claude working' }) + runtime.onPtyData('pty-1', '\x1b]0;claude agents\x07', 100) + const [terminal] = (await runtime.listTerminals()).terminals + + await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(false) + }) + + it('does not let stale tab-level Claude agents titles suppress current pane activity', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude' + }) + syncSinglePty(runtime, 'pty-1', { + tabTitle: 'claude agents', + paneTitle: 'claude working' + }) + const [terminal] = (await runtime.listTerminals()).terminals + + await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(true) + }) + + it('does not let stale tab-level agent titles override current neutral pane titles', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + syncSinglePty(runtime, 'pty-1', { + tabTitle: 'claude working', + paneTitle: 'bash' + }) + const [terminal] = (await runtime.listTerminals()).terminals + + await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(false) + }) + + it('does not let stale live-leaf status override current neutral pane titles', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'claude working' }) + runtime.onPtyData('pty-1', '\x1b]0;claude working\x07', 100) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'bash' }) + const [terminal] = (await runtime.listTerminals()).terminals + + await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(false) + }) + + it('does not expose stale live-leaf agent status after Claude agents title supersedes it', async () => { + const runtime = new OrcaRuntimeService(store) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'claude working' }) + runtime.onPtyData('pty-1', '\x1b]0;claude working\x07', 100) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'claude agents' }) + const [terminal] = (await runtime.listTerminals()).terminals + + expect(runtime.getAgentStatusForHandle(terminal.handle)).toBeNull() + }) + + it('lists live terminals with fresh pane titles over stale tab titles', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'claude working', + activeLeafId: 'pane:1', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-1', + paneTitle: 'claude agents' + } + ] + }) + + const [terminal] = (await runtime.listTerminals()).terminals + + expect(terminal.title).toBe('claude agents') + }) + + it('does not let stale Claude agents OSC titles suppress current pane activity', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude' + }) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'claude agents' }) + runtime.onPtyData('pty-1', '\x1b]0;claude agents\x07', 100) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'claude working' }) + const [terminal] = (await runtime.listTerminals()).terminals + + await expect(runtime.isTerminalRunningAgent(terminal.handle)).resolves.toBe(true) + }) + + it('lets adopted pane Claude agents titles override stale PTY-handle activity', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'claude', + title: 'claude working' + }) + runtime.onPtyData('pty-bg', '\x1b]0;claude working\x07', 100) + + syncSinglePty(runtime, 'pty-bg', { paneTitle: 'claude agents' }) + + await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(false) + }) + + it('lets adopted neutral pane titles override stale PTY-handle activity', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'claude', + title: 'claude working' + }) + runtime.onPtyData('pty-bg', '\x1b]0;claude working\x07', 100) + + syncSinglePty(runtime, 'pty-bg', { paneTitle: 'bash' }) + + await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(false) + }) + + it('lets adopted neutral pane titles use non-shell foreground fallback', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'codex' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'bash', + title: 'bash' + }) + + syncSinglePty(runtime, 'pty-bg', { paneTitle: 'bash' }) + + await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(true) + }) + + it('lets adopted Claude agents pane titles use non-Claude foreground fallback', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'codex' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'claude agents', + title: 'claude agents' + }) + + syncSinglePty(runtime, 'pty-bg', { paneTitle: 'claude agents' }) + + await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(true) + }) + + it('keeps ready prompt evidence when an adopted pane title is neutral', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'codex', + title: 'Codex working' + }) + syncSinglePty(runtime, 'pty-bg', { paneTitle: 'bash' }) + runtime.onPtyData( + 'pty-bg', + ['OpenAI Codex', 'Model: gpt-5.4', 'Directory: /tmp/worktree-a'].join('\n'), + 100 + ) + + await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(true) + }) + + it('lets adopted pane agent titles override stale PTY Claude agents titles', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'claude agents', + title: 'claude agents' + }) + runtime.onPtyData('pty-bg', '\x1b]0;claude agents\x07', 100) + + syncSinglePty(runtime, 'pty-bg', { paneTitle: 'claude working' }) + + await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(true) + }) + + it('lets current Claude agents PTY titles override stale runtime-created OSC titles', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'claude agents', + title: 'claude agents' + }) + const pty = ( + runtime as unknown as { + ptysById: Map< + string, + { + lastOscTitle: string | null + lastOscTitleAt: number | null + } + > + } + ).ptysById.get('pty-bg') + expect(pty).toBeDefined() + if (!pty) { + throw new Error('expected runtime PTY record') + } + pty.lastOscTitle = 'claude working' + pty.lastOscTitleAt = 0 + + await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(false) + }) + + it('does not let stale Claude agents OSC titles suppress current PTY title activity', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'claude', + title: 'claude working' + }) + const pty = ( + runtime as unknown as { + ptysById: Map< + string, + { + lastOscTitle: string | null + lastOscTitleAt: number | null + } + > + } + ).ptysById.get('pty-bg') + expect(pty).toBeDefined() + if (!pty) { + throw new Error('expected runtime PTY record') + } + pty.lastOscTitle = 'claude agents' + pty.lastOscTitleAt = 0 + + await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(true) + }) + + it('recognizes fresh runtime-created agent OSC titles over stale Claude agents launch titles', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'claude agents', + title: 'claude agents' + }) + runtime.onPtyData('pty-bg', '\x1b]0;claude working\x07', 100) + + await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(true) + }) + + it('keeps Claude agents management evidence when controller refresh reports a Claude process title', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude', + listProcesses: async () => [{ id: 'pty-bg', cwd: TEST_WORKTREE_PATH, title: 'claude' }] + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'claude agents', + title: 'claude agents' + }) + + await runtime.getWorktreePs() + + await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(false) + }) + + it('allows non-Claude foreground agents after preserved Claude agents management evidence', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'codex', + listProcesses: async () => [{ id: 'pty-bg', cwd: TEST_WORKTREE_PATH, title: 'zsh' }] + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'claude agents', + title: 'claude agents' + }) + + await runtime.getWorktreePs() + + await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(true) + }) + + it('does not let stale PTY status override a fresh neutral PTY title', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'claude', + title: 'Claude working' + }) + runtime.onPtyData('pty-bg', '\x1b]0;Claude working\x07', 100) + runtime.onPtyData('pty-bg', '\x1b]0;zsh\x07', 101) + + await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(false) + }) + + it('does not use stale runtime-created PTY status when a neutral PTY title exists', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'claude', + title: 'zsh' + }) + const pty = ( + runtime as unknown as { + ptysById: Map< + string, + { + lastAgentStatus: 'working' | null + } + > + } + ).ptysById.get('pty-bg') + expect(pty).toBeDefined() + if (!pty) { + throw new Error('expected runtime PTY record') + } + pty.lastAgentStatus = 'working' + runtime.setPtyController(null) + + await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(false) + }) + + it('recognizes ready prompt evidence even with a stale Claude agents title', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, { + command: 'claude agents', + title: 'claude agents' + }) + + runtime.onPtyData( + 'pty-bg', + ['OpenAI Codex', 'Model: gpt-5.4', 'Directory: /tmp/worktree-a'].join('\n'), + 100 + ) + + await expect(runtime.isTerminalRunningAgent(handle)).resolves.toBe(true) + }) + it('recognizes runtime-created Codex PTY handles from the ready prompt', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ @@ -6547,6 +7164,268 @@ describe('OrcaRuntimeService', () => { ]) }) + it('keeps renderer-vetted mobile agent status for custom-titled terminals', 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: null, + activeTabId: `tab-1::${leafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `tab-1::${leafId}`, + parentTabId: 'tab-1', + leafId, + title: 'claude agents', + agentStatus: { + state: 'working', + prompt: 'fix parity', + updatedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, + agentType: 'codex', + paneKey: hostPaneKey, + terminalTitle: 'codex [working]', + stateHistory: [] + }, + isActive: true + } + ] + } + ] + }) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + title: 'claude agents', + agentStatus: expect.objectContaining({ + state: 'working', + agentType: 'codex', + paneKey: hostPaneKey + }) + }) + ) + }) + + it('suppresses saved mobile agent status when live evidence is the Claude agents screen', async () => { + const runtime = new OrcaRuntimeService(store) + const leafId = '11111111-1111-4111-8111-111111111111' + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'claude working', + activeLeafId: leafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId, + paneRuntimeId: 1, + ptyId: 'pty-1', + paneTitle: 'claude agents' + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: `tab-1::${leafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `tab-1::${leafId}`, + parentTabId: 'tab-1', + leafId, + title: 'claude agents', + agentStatus: { + state: 'working', + prompt: 'stale task', + updatedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, + agentType: 'claude', + paneKey: `tab-1:${leafId}`, + terminalTitle: 'claude working', + stateHistory: [] + }, + isActive: true + } + ] + } + ] + }) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + title: 'claude agents' + }) + ) + expect(result.tabs[0]).not.toHaveProperty('agentStatus') + }) + + it('suppresses saved mobile agent status when the current terminal title is neutral', async () => { + const runtime = new OrcaRuntimeService(store) + const leafId = '11111111-1111-4111-8111-111111111111' + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'claude working', + activeLeafId: leafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId, + paneRuntimeId: 1, + ptyId: 'pty-1', + paneTitle: 'bash' + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: `tab-1::${leafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `tab-1::${leafId}`, + parentTabId: 'tab-1', + leafId, + title: 'bash', + agentStatus: { + state: 'working', + prompt: 'stale task', + updatedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, + agentType: 'claude', + paneKey: `tab-1:${leafId}`, + terminalTitle: 'claude working', + stateHistory: [] + }, + isActive: true + } + ] + } + ] + }) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + title: 'bash' + }) + ) + expect(result.tabs[0]).not.toHaveProperty('agentStatus') + }) + + it('suppresses saved mobile agent status when fresh live OSC title is Claude agents', async () => { + const runtime = new OrcaRuntimeService(store) + const leafId = '11111111-1111-4111-8111-111111111111' + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + title: 'claude working', + activeLeafId: leafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: TEST_WORKTREE_ID, + leafId, + paneRuntimeId: 1, + ptyId: 'pty-1', + paneTitle: 'claude working' + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: `tab-1::${leafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `tab-1::${leafId}`, + parentTabId: 'tab-1', + leafId, + title: 'claude working', + agentStatus: { + state: 'working', + prompt: 'stale task', + updatedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, + agentType: 'claude', + paneKey: `tab-1:${leafId}`, + terminalTitle: 'claude working', + stateHistory: [] + }, + isActive: true + } + ] + } + ] + }) + + runtime.onPtyData('pty-1', '\x1b]0;claude agents\x07', 100) + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + title: 'claude agents' + }) + ) + expect(result.tabs[0]).not.toHaveProperty('agentStatus') + }) + it('keeps saved PTY bindings pending until the runtime knows the PTY is connected', async () => { const runtime = new OrcaRuntimeService(store) runtime.attachWindow(1) @@ -7555,6 +8434,186 @@ describe('OrcaRuntimeService', () => { unsubscribe() }) + it('does not publish stale PTY-backed mobile agent status for Claude agents screens', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude' + }) + const events: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) + + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'laptop-tab', + leafId: HEADLESS_LEAF_ID + }) + events.length = 0 + + runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude working\x07', 123) + runtime.onPtyData('laptop-created-pty', '\x1b]0;claude agents\x07', 124) + + expect(events[0]?.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + agentStatus: expect.objectContaining({ state: 'working' }) + }) + ) + expect(events[1]?.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + title: 'claude agents' + }) + ) + expect(events[1]?.tabs[0]).not.toHaveProperty('agentStatus') + + unsubscribe() + }) + + it('uses fresh PTY management titles over stale mobile snapshot and OSC titles', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude' + }) + const leafId = HEADLESS_LEAF_ID + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'laptop-tab', + leafId + }) + runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude working\x07', 123) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'laptop-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Claude working', + activeLeafId: leafId, + layout: null + } + ], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-stale', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: `laptop-tab::${leafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `laptop-tab::${leafId}`, + parentTabId: 'laptop-tab', + leafId, + title: 'Claude working', + agentStatus: { + state: 'working', + prompt: 'stale task', + updatedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, + agentType: 'claude', + paneKey: `laptop-tab:${leafId}`, + terminalTitle: 'Claude working', + stateHistory: [] + }, + isActive: true + } + ] + } + ] + }) + runtime.onPtyData('laptop-created-pty', '\x1b]0;claude agents\x07', 124) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + title: 'claude agents' + }) + ) + expect(result.tabs[0]).not.toHaveProperty('agentStatus') + }) + + it('uses fresh neutral PTY titles over stale mobile snapshot and OSC titles', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const leafId = HEADLESS_LEAF_ID + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'laptop-tab', + leafId + }) + runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude working\x07', 123) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'laptop-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Claude working', + activeLeafId: leafId, + layout: null + } + ], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-stale', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: `laptop-tab::${leafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `laptop-tab::${leafId}`, + parentTabId: 'laptop-tab', + leafId, + title: 'Claude working', + agentStatus: { + state: 'working', + prompt: 'stale task', + updatedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, + agentType: 'claude', + paneKey: `laptop-tab:${leafId}`, + terminalTitle: 'Claude working', + stateHistory: [] + }, + isActive: true + } + ] + } + ] + }) + runtime.onPtyData('laptop-created-pty', '\x1b]0;zsh\x07', 124) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + title: 'zsh' + }) + ) + expect(result.tabs[0]).not.toHaveProperty('agentStatus') + }) + it('pushes PTY-backed mobile session readiness changes when a server PTY exits', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'laptop-created-pty' }) const runtime = new OrcaRuntimeService(store) @@ -9167,6 +10226,30 @@ describe('OrcaRuntimeService', () => { expect(afterExit.worktrees[0].status).toBe('active') }) + it('shows worktree.ps active when the current pane is the Claude agents screen', async () => { + const runtime = new OrcaRuntimeService(store) + + syncSinglePty(runtime, 'pty-1', { paneTitle: 'claude working' }) + runtime.onPtyData('pty-1', '\x1b]0;claude working\x07', 100) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'claude agents' }) + + const summary = await runtime.getWorktreePs() + + expect(summary.worktrees[0].status).toBe('active') + }) + + it('shows worktree.ps working when the current pane supersedes a Claude agents OSC title', async () => { + const runtime = new OrcaRuntimeService(store) + + syncSinglePty(runtime, 'pty-1', { paneTitle: 'claude agents' }) + runtime.onPtyData('pty-1', '\x1b]0;claude agents\x07', 100) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'claude working' }) + + const summary = await runtime.getWorktreePs() + + expect(summary.worktrees[0].status).toBe('working') + }) + it('fails terminal stop closed while the renderer graph is reloading', async () => { const runtime = new OrcaRuntimeService(store) let killed = false diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5d37c2cd9..adeebb784 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -4,6 +4,7 @@ import { extractLastOscTitle, detectAgentStatusFromTitle, + isClaudeManagementTitle, isShellProcess } from '../../shared/agent-detection' import type { AgentStatus } from '../../shared/agent-detection' @@ -672,6 +673,8 @@ type RuntimeLeafRecord = RuntimeSyncedLeaf & { // serving a stale `lastAgentStatus` after the agent process exits and the // shell takes over the title — the bug behind issue #1437. lastOscTitle: string | null + lastOscTitleAt: number | null + paneTitleUpdatedAt: number | null } function isCursorAgentOrchestrationTarget( @@ -712,7 +715,11 @@ type RuntimePtyWorktreeRecord = { lastExitCode: number | null lastAgentStatus: AgentStatus | null lastOscTitle: string | null + lastOscTitleAt: number | null + managementTitle: string | null + managementTitleAt: number | null title: string | null + titleUpdatedAt: number | null lastOutputAt: number | null tailBuffer: string[] tailPartialLine: string @@ -1388,6 +1395,7 @@ export class OrcaRuntimeService { // iterates them all. Listeners are cleaned up via subscriptionCleanups. private notificationListeners = new Set<(event: MobileNotificationEvent) => void>() private ptysById = new Map() + private titleObservationSequence = 0 private headlessTerminals = new Map() private ptyOutputSequenceById = new Map() // Why: OSC 9999 status can span PTY chunks. Keeping parser state in the @@ -2065,6 +2073,7 @@ export class OrcaRuntimeService { this.tabs = new Map(graph.tabs.map((tab) => [tab.tabId, tab])) this.syncMobileSessionTabs(graph.mobileSessionTabs) const nextLeaves = new Map() + const graphSyncedAt = this.nextTitleObservationSequence() // Why: renderer reloads can briefly republish the same leaf with no ptyId; // keep live CLI handles usable while the UI graph rebuilds. @@ -2095,7 +2104,12 @@ export class OrcaRuntimeService { tailLinesTotal: existing?.ptyId === ptyId ? existing.tailLinesTotal : 0, preview: existing?.ptyId === ptyId ? existing.preview : '', lastAgentStatus: existing?.ptyId === ptyId ? existing.lastAgentStatus : null, - lastOscTitle: existing?.ptyId === ptyId ? existing.lastOscTitle : null + lastOscTitle: existing?.ptyId === ptyId ? existing.lastOscTitle : null, + lastOscTitleAt: existing?.ptyId === ptyId ? existing.lastOscTitleAt : null, + paneTitleUpdatedAt: + existing?.ptyId === ptyId && existing.paneTitle === leaf.paneTitle + ? existing.paneTitleUpdatedAt + : graphSyncedAt }) if (leaf.ptyId) { @@ -2356,7 +2370,7 @@ export class OrcaRuntimeService { args: { tabId: string; leafId: string; title: string | null; activate: boolean } ): void { const existing = this.mobileSessionTabsByWorktree.get(worktreeId) - const title = args.title ?? pty.title ?? pty.lastOscTitle ?? 'Terminal' + const title = args.title ?? getLatestPtyTitle(pty) ?? 'Terminal' const existingTab = existing?.tabs.find( (candidate): candidate is RuntimeMobileSessionTerminalTab => candidate.type === 'terminal' && @@ -3347,8 +3361,11 @@ export class OrcaRuntimeService { if (oscTitle !== null) { const prevStatus = pty.lastAgentStatus const prevTitle = pty.lastOscTitle + const observedAt = this.nextTitleObservationSequence() pty.lastOscTitle = oscTitle + pty.lastOscTitleAt = observedAt pty.lastAgentStatus = agentStatus + this.setPtyManagementTitleFromObservedTitle(pty, oscTitle, observedAt) shouldTouchPtyBackedSessionTabs = prevTitle !== oscTitle || prevStatus !== pty.lastAgentStatus if (agentStatus === 'idle' && prevStatus !== 'idle') { @@ -3407,6 +3424,7 @@ export class OrcaRuntimeService { // way to clear a stale 'working' status after the agent exited and // the shell took over the title — the stuck-spinner bug in #1437. leaf.lastOscTitle = oscTitle + leaf.lastOscTitleAt = this.nextTitleObservationSequence() const prevStatus = leaf.lastAgentStatus // Why: when a new OSC title doesn't classify as an agent state (e.g. // bare shell title after the agent exits), clear lastAgentStatus so @@ -3753,11 +3771,19 @@ export class OrcaRuntimeService { return } const status = detectAgentStatusFromTitle(title) + const pty = this.ptysById.get(ptyId) + if (pty) { + const observedAt = this.nextTitleObservationSequence() + pty.lastOscTitle = title + pty.lastOscTitleAt = observedAt + this.setPtyManagementTitleFromObservedTitle(pty, title, observedAt) + } for (const leaf of this.getLeavesForPty(ptyId)) { // Why: seed lastOscTitle even when the seeded title doesn't classify // as an agent state, so worktree.ps recomputes status from the live // title rather than treating the leaf as agentless. leaf.lastOscTitle = title + leaf.lastOscTitleAt = this.nextTitleObservationSequence() if (status !== null) { leaf.lastAgentStatus = status } @@ -11185,7 +11211,15 @@ export class OrcaRuntimeService { this.registerPty(result.id, worktree.id, repo?.connectionId ?? null) const pty = this.getOrCreatePtyWorktreeRecord(result.id) if (pty) { - pty.title = opts.title ?? null + if (opts.title) { + const observedAt = this.nextTitleObservationSequence() + pty.title = opts.title + pty.titleUpdatedAt = observedAt + this.setPtyManagementTitleFromObservedTitle(pty, opts.title, observedAt) + } else { + pty.title = null + pty.titleUpdatedAt = null + } pty.tabId = tabId pty.paneKey = paneKey } @@ -11676,7 +11710,7 @@ export class OrcaRuntimeService { const parsedPaneKey = parsePaneKey(pty.pty.paneKey ?? '') const revealed = await this.notifier?.revealTerminalSession?.(pty.pty.worktreeId, { ptyId: pty.pty.ptyId, - title: pty.pty.title ?? pty.pty.lastOscTitle, + title: getLatestPtyTitle(pty.pty), ...(pty.pty.tabId !== null ? { tabId: pty.pty.tabId } : {}), ...(parsedPaneKey ? { leafId: parsedPaneKey.leafId } : {}) }) @@ -12665,6 +12699,7 @@ export class OrcaRuntimeService { ): RuntimePtyWorktreeRecord { let pty = this.ptysById.get(ptyId) if (!pty) { + const titleObservedAt = state.title ? this.nextTitleObservationSequence() : null pty = { ptyId, worktreeId, @@ -12676,7 +12711,11 @@ export class OrcaRuntimeService { lastExitCode: null, lastAgentStatus: null, lastOscTitle: null, + lastOscTitleAt: null, + managementTitle: null, + managementTitleAt: null, title: state.title ?? null, + titleUpdatedAt: titleObservedAt, lastOutputAt: state.lastOutputAt ?? null, tailBuffer: [], tailPartialLine: '', @@ -12684,6 +12723,9 @@ export class OrcaRuntimeService { tailLinesTotal: 0, preview: state.preview ?? '' } + if (state.title) { + this.setPtyManagementTitleFromObservedTitle(pty, state.title, titleObservedAt ?? 0) + } this.ptysById.set(ptyId, pty) // Why: restored/controller-discovered PTYs learn their worktree here // without registerPty(), so URL enrichment must bind at this source. @@ -12713,7 +12755,10 @@ export class OrcaRuntimeService { pty.preview = state.preview } if (state.title !== undefined && state.title !== null && state.title.length > 0) { + const observedAt = this.nextTitleObservationSequence() pty.title = state.title + pty.titleUpdatedAt = observedAt + this.setPtyManagementTitleFromObservedTitle(pty, state.title, observedAt) } // Why: recordPtyWorktree is the common lifecycle point for every path that // resolves a PTY's worktree, including renderer restore and controller list. @@ -12766,8 +12811,7 @@ export class OrcaRuntimeService { findResolvedWorktreeIdForPath(resolvedWorktrees, session.cwd) if (worktreeId) { this.recordPtyWorktree(session.id, worktreeId, { - connected: true, - title: session.title + connected: true }) } } @@ -12880,7 +12924,7 @@ export class OrcaRuntimeService { branch: worktree?.branch ?? '', tabId: leaf.tabId, leafId: leaf.leafId, - title: tab?.title ?? null, + title: getLatestLeafTitle(leaf, tab?.title ?? null), connected: leaf.connected, writable: leaf.writable, lastOutputAt: leaf.lastOutputAt, @@ -13260,6 +13304,26 @@ export class OrcaRuntimeService { const paneKey = isTerminalLeafId(tab.leafId) ? makePaneKey(tab.parentTabId, tab.leafId) : `${tab.parentTabId}:${legacyPaneId ?? tab.leafId}` + const leafTitle = leaf + ? getLatestAgentCandidateTitle( + { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, + { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt } + ) + : null + const ptyTitle = pty + ? getLatestAgentCandidateTitle( + { title: pty.title, updatedAt: pty.titleUpdatedAt }, + { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } + ) + : null + const title = leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title + const liveTitleEvidence = leafTitle ?? ptyTitle + const liveTitleEvidenceClassification = classifyAgentTitle(liveTitleEvidence) + const agentStatus = + tab.agentStatus && + (liveTitleEvidence === null || liveTitleEvidenceClassification === 'agent') + ? { agentStatus: tab.agentStatus } + : null // Why: web/mobile clients hold these handles across renderer graph syncs; // leaf handles are graph-epoch-bound, but PTY handles remain streamable. const terminalHandle = liveLeafPtyId @@ -13278,12 +13342,10 @@ export class OrcaRuntimeService { id: tab.id, parentTabId: tab.parentTabId, leafId: tab.leafId, - title: leaf?.paneTitle ?? syncedTab?.title ?? pty?.lastOscTitle ?? pty?.title ?? tab.title, + title, ...(tab.ptyId ? { ptyId: tab.ptyId } : {}), ...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}), - ...(tab.agentStatus - ? { agentStatus: tab.agentStatus } - : this.buildPtyMobileAgentStatus(livePty ?? pty, tab, terminalHandle)), + ...(agentStatus ?? this.buildPtyMobileAgentStatus(livePty ?? pty, tab, terminalHandle)), ...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}), isActive: tab.isActive, ...(terminalHandle @@ -13338,6 +13400,14 @@ export class OrcaRuntimeService { if (!pty?.lastAgentStatus) { return {} } + const ptyTitle = getLatestAgentCandidateTitle( + { title: pty.title, updatedAt: pty.titleUpdatedAt }, + { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } + ) + const ptyTitleClassification = classifyAgentTitle(ptyTitle) + if (ptyTitle !== null && ptyTitleClassification !== 'agent') { + return {} + } const now = pty.lastOutputAt ?? Date.now() return { agentStatus: { @@ -13354,7 +13424,7 @@ export class OrcaRuntimeService { ...(terminalHandle ? { terminalHandle } : {}), worktreeId: pty.worktreeId, tabId: tab.parentTabId, - terminalTitle: pty.lastOscTitle ?? pty.title ?? tab.title, + terminalTitle: getLatestPtyTitle(pty) ?? tab.title, stateHistory: [] } } @@ -13428,6 +13498,14 @@ export class OrcaRuntimeService { getAgentStatusForHandle(handle: string): string | null { try { const { leaf } = this.getLiveLeafForHandle(handle) + const title = getLatestAgentCandidateTitle( + { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, + { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt }, + { title: this.tabs.get(leaf.tabId)?.title, updatedAt: 0 } + ) + if (title) { + return detectAgentStatusFromTitle(title) + } return leaf.lastAgentStatus } catch { return null @@ -13563,35 +13641,68 @@ export class OrcaRuntimeService { return makePaneKey(record.tabId, record.leafId) } - // Why: OSC title detection via onPtyData is the tightest signal for agent - // presence, but the runtime may not see PTY data for daemon-hosted terminals - // (the daemon adapter stubs getForegroundProcess). This checks three signals - // in order: (1) lastAgentStatus from PTY data OSC titles, (2) the renderer- - // synced tab title (which reflects OSC titles from the xterm instance), (3) - // retained ready-tail text, and (4) the PTY foreground process. Returns true - // if any signal indicates a non-shell agent is running. + private setPtyManagementTitleFromObservedTitle( + pty: RuntimePtyWorktreeRecord, + title: string | null | undefined, + observedAt: number + ): void { + const trimmed = title?.trim() + if (!trimmed) { + return + } + if (isClaudeManagementTitle(trimmed)) { + pty.managementTitle = trimmed + pty.managementTitleAt = observedAt + return + } + if ( + detectAgentStatusFromTitle(trimmed) !== null && + observedAt >= (pty.managementTitleAt ?? -1) + ) { + pty.managementTitle = null + pty.managementTitleAt = null + } + } + + private nextTitleObservationSequence(): number { + this.titleObservationSequence += 1 + return this.titleObservationSequence + } + + // Why: title detection is the tightest signal for agent presence, but a + // Claude management title is negative evidence for task-capable activity. + // Check pane-scoped titles before tab fallback, then retained ready-tail text, + // stale title status, and foreground process. async isTerminalRunningAgent(handle: string): Promise { try { const pty = this.getLivePtyForHandle(handle) if (pty) { - return await this.isPtyRunningAgent(pty.pty) + const leaf = this.getPrimaryLeafForPty(pty.pty.ptyId) + return await this.isPtyRunningAgent(pty.pty, leaf) } const { leaf } = this.getLiveLeafForHandle(handle) - if (leaf.lastAgentStatus !== null) { - return true - } // Why: check both the leaf-level pane title (synced from the renderer's // runtimePaneTitlesByTabId) and the tab-level title. The tab title already // includes OSC-enriched agent indicators (e.g. ✳ prefix) synced from the // renderer's xterm instance. - const titleToCheck = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title - if (titleToCheck && detectAgentStatusFromTitle(titleToCheck) !== null) { + const paneTitle = getLatestLeafTitle(leaf, null) + const paneTitleClassification = classifyAgentTitle(paneTitle) + if (paneTitleClassification === 'agent') { + return true + } + const tabTitle = this.tabs.get(leaf.tabId)?.title?.trim() || null + const tabTitleClassification = paneTitle === null ? classifyAgentTitle(tabTitle) : 'neutral' + if (tabTitleClassification === 'agent') { return true } const waitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) if (isKnownReadyPromptPreview(waitText)) { return true } + const hasCurrentTitleEvidence = paneTitle !== null || tabTitle !== null + if (leaf.lastAgentStatus !== null && !hasCurrentTitleEvidence) { + return true + } if (!leaf.ptyId || !this.ptyController) { return false } @@ -13599,24 +13710,61 @@ export class OrcaRuntimeService { if (!fg) { return false } + // Why: Claude's management UI runs under the Claude process but is not a + // task-capable agent session. Suppress that process only; another foreground + // agent can take over before titles update. + if ( + (paneTitleClassification === 'management' || tabTitleClassification === 'management') && + isExpectedAgentProcess(fg, 'claude') + ) { + return false + } return !isShellProcess(fg) } catch { return false } } - private async isPtyRunningAgent(pty: RuntimePtyWorktreeRecord): Promise { - if (pty.lastAgentStatus !== null) { + private async isPtyRunningAgent( + pty: RuntimePtyWorktreeRecord, + leaf: RuntimeLeafRecord | null = null + ): Promise { + const leafTitle = leaf + ? getLatestAgentCandidateTitle( + { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, + { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt } + ) + : null + const leafTitleClassification = classifyAgentTitle(leafTitle) + if (leafTitleClassification === 'agent') { return true } - const titleToCheck = pty.lastOscTitle ?? pty.title - if (titleToCheck && detectAgentStatusFromTitle(titleToCheck) !== null) { + const ptyTitle = getLatestAgentCandidateTitle( + { title: pty.title, updatedAt: pty.titleUpdatedAt }, + { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } + ) + const ptyTitleClassification = classifyAgentTitle(ptyTitle) + if (leafTitle === null && ptyTitleClassification === 'agent') { return true } + const managementTitleClassification = classifyLatestAgentTitle({ + title: pty.managementTitle, + updatedAt: pty.managementTitleAt + }) const waitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) if (isKnownReadyPromptPreview(waitText)) { return true } + // Why: stale status is only a fallback when no current title evidence + // exists; neutral titles such as shells should clear it. + if ( + pty.lastAgentStatus !== null && + leafTitle === null && + ptyTitle === null && + managementTitleClassification !== 'management' + ) { + return true + } if (!this.ptyController) { return false } @@ -13624,9 +13772,20 @@ export class OrcaRuntimeService { if (!fg) { return false } + const shouldSuppressClaudeForeground = + leafTitle !== null + ? leafTitleClassification === 'management' + : managementTitleClassification === 'management' + if (shouldSuppressClaudeForeground && isExpectedAgentProcess(fg, 'claude')) { + return false + } return !isShellProcess(fg) } + private getPrimaryLeafForPty(ptyId: string): RuntimeLeafRecord | null { + return this.getLeavesForPty(ptyId)[0] ?? null + } + deliverPendingMessagesForHandle(handle: string): void { try { const { leaf } = this.getLiveLeafForHandle(handle) @@ -13740,7 +13899,7 @@ export class OrcaRuntimeService { branch: worktree?.branch ?? '', tabId: `pty:${pty.ptyId}`, leafId: `pty:${pty.ptyId}`, - title: pty.lastOscTitle ?? pty.title, + title: getLatestPtyTitle(pty), connected: pty.connected, writable: pty.connected, lastOutputAt: pty.lastOutputAt, @@ -15962,12 +16121,16 @@ function getLeafWorktreeStatus( ): RuntimeWorktreeStatus { // Why: recompute from the live title each call so worktree.ps mirrors what // the desktop sidebar's getWorktreeStatus does (no sticky state). Prefer - // the runtime-tracked OSC title (covers daemon-hosted terminals) over the - // renderer-pushed leaf.title and the tab title. Falling back to - // lastAgentStatus only when no title is available preserves a sensible - // signal for very fresh leaves before any title has been observed. - const liveTitle = leaf.lastOscTitle ?? leaf.title ?? tabTitle ?? '' - const detected = liveTitle ? detectAgentStatusFromTitle(liveTitle) : leaf.lastAgentStatus + // the freshest pane/OSC title, then tab title. Falling back to lastAgentStatus + // only when no title is available preserves a sensible signal for very fresh + // leaves before any title has been observed. + const titleCandidates = [ + { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, + { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt }, + { title: tabTitle, updatedAt: 0 } + ] + const latestTitle = getLatestAgentCandidateTitle(...titleCandidates) + const detected = latestTitle ? detectAgentStatusFromTitle(latestTitle) : leaf.lastAgentStatus if (detected === 'permission') { return 'permission' } @@ -15977,6 +16140,54 @@ function getLeafWorktreeStatus( return leaf.ptyId ? 'active' : 'inactive' } +function classifyLatestAgentTitle( + ...titles: { title: string | null | undefined; updatedAt: number | null | undefined }[] +): 'agent' | 'management' | 'neutral' { + return classifyAgentTitle(getLatestAgentCandidateTitle(...titles)) +} + +function getLatestPtyTitle(pty: RuntimePtyWorktreeRecord): string | null { + return getLatestAgentCandidateTitle( + { title: pty.title, updatedAt: pty.titleUpdatedAt }, + { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } + ) +} + +function getLatestLeafTitle(leaf: RuntimeLeafRecord, tabTitle: string | null): string | null { + return getLatestAgentCandidateTitle( + { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, + { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt }, + { title: tabTitle, updatedAt: 0 } + ) +} + +function classifyAgentTitle(title: string | null): 'agent' | 'management' | 'neutral' { + if (!title) { + return 'neutral' + } + if (isClaudeManagementTitle(title)) { + return 'management' + } + return detectAgentStatusFromTitle(title) !== null ? 'agent' : 'neutral' +} + +function getLatestAgentCandidateTitle( + ...titles: { title: string | null | undefined; updatedAt: number | null | undefined }[] +): string | null { + let latest: { title: string; updatedAt: number } | null = null + for (const candidate of titles) { + const title = candidate.title?.trim() + if (!title) { + continue + } + const updatedAt = candidate.updatedAt ?? 0 + if (!latest || updatedAt > latest.updatedAt) { + latest = { title, updatedAt } + } + } + return latest?.title ?? null +} + function getSavedTabWorktreeStatus(title: string, hasPty: boolean): RuntimeWorktreeStatus { const detected = detectAgentStatusFromTitle(title) if (detected === 'permission') { diff --git a/src/renderer/src/components/sidebar/WorktreeCard.test.ts b/src/renderer/src/components/sidebar/WorktreeCard.test.ts index 17f28248c..295b8f973 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeCard.test.ts @@ -101,4 +101,20 @@ describe('deriveWorktreeCardStatus', () => { expect(status).toBe('done') }) + + it('stays active when the only live terminal signal is the Claude agents screen', () => { + const status = deriveWorktreeCardStatus({ + tabs: [makeTerminalTab('claude agents')], + browserTabs: [], + worktreeAgentEntries: [], + runtimePaneTitlesByTabId: { + 'tab-1': { + 1: 'claude agents' + } + }, + now: 1_000 + }) + + expect(status).toBe('active') + }) }) 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 a260471a1..86e1eb67a 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 @@ -138,6 +138,22 @@ describe('buildTitleDerivedAgentRows', () => { expect(rows).toHaveLength(0) }) + it('does not add title-derived rows for the Claude agents management screen', () => { + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1')], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { + 'tab-1': { 1: 'claude agents' } + }, + ptyIdsByTabId: { 'tab-1': ['pty-claude-agents'] }, + terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) }, + now: 2000 + }) + + expect(rows).toHaveLength(0) + }) + it('does not turn generic Codex-launched task titles into Claude Code rows', () => { const launchAgent: TuiAgent = 'codex' const rows = buildWorktreeAgentRows({ diff --git a/src/renderer/src/lib/agent-status.test.ts b/src/renderer/src/lib/agent-status.test.ts index abe18ce4c..88fe8692e 100644 --- a/src/renderer/src/lib/agent-status.test.ts +++ b/src/renderer/src/lib/agent-status.test.ts @@ -10,6 +10,7 @@ import { getAgentLabel, isGeminiTerminalTitle, isClaudeAgent, + isClaudeManagementTitle, normalizeTerminalTitle, isExplicitAgentStatusFresh, mapAgentStatusStateToVisualStatus, @@ -170,6 +171,22 @@ describe('detectAgentStatusFromTitle', () => { expect(detectAgentStatusFromTitle('⠋ OpenClaude')).toBe('working') }) + it('excludes the exact Claude agents management title', () => { + expect(detectAgentStatusFromTitle('claude agents')).toBeNull() + expect(detectAgentStatusFromTitle(' Claude Agents ')).toBeNull() + expect(detectAgentStatusFromTitle('claude.exe agents')).toBeNull() + expect(detectAgentStatusFromTitle('Claude.CMD agents')).toBeNull() + expect(detectAgentStatusFromTitle('claude.bat agents')).toBeNull() + expect(detectAgentStatusFromTitle('Claude.PS1 agents')).toBeNull() + expect( + detectAgentStatusFromTitle('C:\\Users\\dev\\AppData\\Roaming\\npm\\claude.cmd agents') + ).toBeNull() + expect( + detectAgentStatusFromTitle('"C:\\Users\\dev\\AppData\\Roaming\\npm\\claude.cmd" agents') + ).toBeNull() + expect(detectAgentStatusFromTitle('claude agents working')).toBe('working') + }) + it('detects Pi idle titles', () => { expect(detectAgentStatusFromTitle('π - my-project')).toBe('idle') expect(detectAgentStatusFromTitle('π - session-name - my-project')).toBe('idle') @@ -409,6 +426,10 @@ describe('getAgentLabel', () => { expect(getAgentLabel('Hermes ready')).toBe('Hermes') }) + it('does not label the Claude agents management title', () => { + expect(getAgentLabel('claude agents')).toBeNull() + }) + it('labels GitHub Copilot CLI', () => { expect(getAgentLabel('copilot working')).toBe('GitHub Copilot') expect(getAgentLabel('copilot idle')).toBe('GitHub Copilot') @@ -452,6 +473,21 @@ describe('isClaudeAgent', () => { expect(isClaudeAgent('ask claude later')).toBe(false) expect(getAgentLabel('ask claude later')).toBeNull() }) + + it('does not classify the Claude agents management title as a Claude agent', () => { + expect(isClaudeManagementTitle(' Claude Agents ')).toBe(true) + expect(isClaudeManagementTitle('claude.exe agents')).toBe(true) + expect(isClaudeManagementTitle('claude.cmd agents')).toBe(true) + expect(isClaudeManagementTitle('claude.bat agents')).toBe(true) + expect(isClaudeManagementTitle('claude.ps1 agents')).toBe(true) + expect( + isClaudeManagementTitle('C:\\Users\\dev\\AppData\\Roaming\\npm\\claude.cmd agents') + ).toBe(true) + expect( + isClaudeManagementTitle('"C:\\Users\\dev\\AppData\\Roaming\\npm\\claude.cmd" agents') + ).toBe(true) + expect(isClaudeAgent('claude agents')).toBe(false) + }) }) describe('createAgentStatusTracker', () => { diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 5b64edfcf..f413fc838 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -18,6 +18,7 @@ export { normalizeTerminalTitle, isGeminiTerminalTitle, isClaudeAgent, + isClaudeManagementTitle, getAgentLabel } from '../../../shared/agent-detection' import { diff --git a/src/renderer/src/runtime/sync-runtime-graph.test.ts b/src/renderer/src/runtime/sync-runtime-graph.test.ts index f932a289f..ef657bf3a 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.test.ts @@ -704,6 +704,46 @@ describe('buildMobileSessionTabSnapshots', () => { ]) }) + it('does not publish terminal pane agent status for the Claude agents screen behind a custom title', () => { + const leafId = '11111111-1111-4111-8111-111111111111' + const paneKey = `term-1:${leafId}` + const state = makeState({ + tabBarOrderByWorktree: { 'wt-1': ['term-1'] }, + tabsByWorktree: { + 'wt-1': [{ id: 'term-1', title: 'claude agents', customTitle: 'Pinned', ptyId: 'pty-1' }] + } as unknown as AppState['tabsByWorktree'], + terminalLayoutsByTabId: { + 'term-1': { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: 'pty-1' } + } + } as AppState['terminalLayoutsByTabId'], + agentStatusByPaneKey: { + [paneKey]: { + state: 'working', + prompt: 'stale task', + updatedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000, + agentType: 'claude', + paneKey, + terminalTitle: 'claude working', + stateHistory: [] + } + } + }) + + const [tab] = buildMobileSessionTabSnapshots(state)[0]?.tabs ?? [] + + expect(tab).toMatchObject({ + type: 'terminal', + id: `term-1::${leafId}`, + title: 'Pinned' + }) + expect(tab).not.toHaveProperty('agentStatus') + }) + it('publishes generated terminal titles to mobile snapshots only when enabled', () => { const leafId = '11111111-1111-4111-8111-111111111111' const base = makeState({ diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 80a8b27b7..9588aad91 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -24,6 +24,7 @@ import type { } from '../../../shared/runtime-types' import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' import { isWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import { isClaudeManagementTitle } from '../../../shared/agent-detection' import type { TabGroup, TabGroupLayoutNode, @@ -1074,15 +1075,20 @@ function buildMobileTerminalSurfaceTabs( ? paneTitles[Number(legacyPaneId)] : undefined const paneKey = isTerminalLeafId(leafId) ? makePaneKey(terminal.id, leafId) : null - const agentStatus = paneKey ? state.agentStatusByPaneKey?.[paneKey] : undefined + const title = resolveRuntimeTerminalTitle( + terminal, + generatedTitlesEnabled, + paneTitle ?? terminal.title ?? 'Terminal' + ) + const agentStatusTitle = paneTitle ?? terminal.title ?? '' + const agentStatus = + paneKey && !isClaudeManagementTitle(agentStatusTitle) + ? state.agentStatusByPaneKey?.[paneKey] + : undefined return { type: 'terminal' as const, id: mobileTerminalSurfaceId(terminal.id, leafId), - title: resolveRuntimeTerminalTitle( - terminal, - generatedTitlesEnabled, - paneTitle ?? terminal.title ?? 'Terminal' - ), + title, ...(terminal.quickCommandLabel?.trim() ? { quickCommandLabel: terminal.quickCommandLabel.trim() } : {}), diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index d6c7ba817..a4c15ebfe 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -17,10 +17,13 @@ import { // Re-export so existing `agent-detection` importers keep working. export { AGENT_NAMES, titleHasAgentName } from './agent-name-token-match' +export { isShellProcess } from './shell-process-detection' export type AgentStatus = 'working' | 'permission' | 'idle' const CLAUDE_IDLE = '\u2733' // ✳ (eight-spoked asterisk — Claude Code idle prefix) +const CLAUDE_MANAGEMENT_TITLE_RE = + /^\s*(?:"(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?"|'(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?'|(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?)\s+agents\s*$/i const GEMINI_WORKING = '\u2726' // ✦ const GEMINI_SILENT_WORKING = '\u23F2' // ⏲ @@ -299,7 +302,7 @@ export function normalizeTerminalTitle(title: string): string { * agents have different (or no) caching semantics. */ export function isClaudeAgent(title: string): boolean { - if (!title) { + if (!title || isClaudeManagementTitle(title)) { return false } const lower = title.toLowerCase() @@ -335,7 +338,14 @@ export function isClaudeAgent(title: string): boolean { return false } +export function isClaudeManagementTitle(title: string): boolean { + return CLAUDE_MANAGEMENT_TITLE_RE.test(title) +} + export function getAgentLabel(title: string): string | null { + if (isClaudeManagementTitle(title)) { + return null + } if (isGeminiTerminalTitle(title)) { return 'Gemini CLI' } @@ -409,6 +419,9 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null { if (!title) { return null } + if (isClaudeManagementTitle(title)) { + return null + } // Why: "Cursor Agent" exactly (case-insensitive, no prefix/suffix) is cursor's // native title. Anything with additional tokens ("⠋ Cursor Agent", "Cursor - // action required") is either an Orca-synthesized working/permission title @@ -488,21 +501,3 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null { return null } - -// Why: shared between the runtime (dispatch guard, tui-idle fallback) and the -// renderer (agent-ready-wait, new-workspace). A bare shell is the only process -// type that garbles injected preambles, so this is the negative signal for -// "is an agent running". -const SHELL_NAMES = new Set( - '|bash|zsh|sh|fish|cmd|cmd.exe|powershell|powershell.exe|pwsh|pwsh.exe|nu'.split('|') -) - -export function isShellProcess(processName: string): boolean { - const normalized = processName - .trim() - .replace(/^["']|["']$/g, '') - .toLowerCase() - return ( - SHELL_NAMES.has(normalized) || SHELL_NAMES.has(normalized.split(/[\\/]/).pop() ?? normalized) - ) -} diff --git a/src/shared/shell-process-detection.ts b/src/shared/shell-process-detection.ts new file mode 100644 index 000000000..729bfe110 --- /dev/null +++ b/src/shared/shell-process-detection.ts @@ -0,0 +1,16 @@ +// Why: shared between the runtime (dispatch guard, tui-idle fallback) and the +// renderer (agent-ready-wait, new-workspace). A bare shell is the negative +// signal for "is an agent running" because it garbles injected preambles. +const SHELL_NAMES = new Set( + '|bash|zsh|sh|fish|cmd|cmd.exe|powershell|powershell.exe|pwsh|pwsh.exe|nu'.split('|') +) + +export function isShellProcess(processName: string): boolean { + const normalized = processName + .trim() + .replace(/^["']|["']$/g, '') + .toLowerCase() + return ( + SHELL_NAMES.has(normalized) || SHELL_NAMES.has(normalized.split(/[\\/]/).pop() ?? normalized) + ) +}