Merge origin/main (v1.4.131-rc.1) into orca-performance

Clean auto-merge, no conflicts. Brought in from main:
- feat(codex): write in-Codex setting changes back to ~/.codex config (#7960)
  — codex-config-mirror.ts, config-settings-promotion.ts
- Separate pane identity from liveness in the tab-agent resolver, fixes OMP
  tab flicker (#7860) — pane-agent-owner.ts, use-tab-agent.ts
- fix(sidebar): only show projects scrollbar when the list overflows (#7845)

Perf-branch structure preserved (clean auto-merge; pty-connection.ts auto-merged
without conflict). Gates: typecheck 0 errors; lint 0 errors; ipc/pty + daemon +
codex + terminal-pane + lib + runtime + shared + pane-manager suites all pass
(9015 + 2120 tests, 0 failed).

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-09 16:00:02 -04:00
co-authored by Orca
14 changed files with 1085 additions and 159 deletions
+1
View File
@@ -14,6 +14,7 @@
"../src/main/codex/codex-config-path-reference-rewrite.ts",
"../src/main/codex/codex-home-paths.ts",
"../src/main/codex/codex-hook-identity.ts",
"../src/main/codex/config-settings-promotion.ts",
"../src/main/codex/config-toml-line-scan.ts",
"../src/main/codex/config-toml-trust.ts",
"../src/main/codex/hook-service.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.130",
"version": "1.4.131-rc.1",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
+12
View File
@@ -3,6 +3,10 @@ import { dirname, join } from 'node:path'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
import { rewriteRelativePathConfigValues } from './codex-config-path-reference-rewrite'
import {
promoteCodexRuntimeSettingsToSystem,
snapshotCodexRuntimeSettingsBaseline
} from './config-settings-promotion'
import {
createTomlLineScanState,
getTomlTableHeader,
@@ -19,11 +23,19 @@ function getSystemCodexConfigTomlPath(): string {
}
export function syncSystemConfigIntoManagedCodexHome(): void {
// Why: the mirror overwrites runtime settings from ~/.codex, so changes the
// user made inside Orca-launched Codex (/model, /approvals) must be written
// back to ~/.codex first or this very pass silently reverts them.
promoteCodexRuntimeSettingsToSystem()
try {
syncSystemConfigIntoManagedCodexHomeUnsafe()
} catch (error) {
console.warn('[codex-config] Failed to mirror system Codex config:', error)
return
}
// Why: the baseline advances only after a successful mirror; recording an
// unpromoted runtime change as Orca-written would strand it forever.
snapshotCodexRuntimeSettingsBaseline()
}
function syncSystemConfigIntoManagedCodexHomeUnsafe(): void {
@@ -0,0 +1,296 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import type * as Os from 'node:os'
import { join } from 'node:path'
const { homedirMock } = vi.hoisted(() => ({
homedirMock: vi.fn<() => string>()
}))
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof Os>()
return {
...actual,
homedir: homedirMock
}
})
import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror'
import { upsertTopLevelSettingsInContent } from './config-settings-promotion'
let tmpHome: string
let userDataDir: string
let previousUserDataPath: string | undefined
beforeEach(() => {
tmpHome = mkdtempSync(join(tmpdir(), 'orca-codex-settings-home-'))
userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-settings-user-data-'))
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
process.env.ORCA_USER_DATA_PATH = userDataDir
homedirMock.mockReturnValue(tmpHome)
// Why: promotion writes into homedir()/.codex — if the mock ever fails to
// intercept, these tests would rewrite the developer's real Codex config.
if (homedir() !== tmpHome) {
throw new Error('node:os homedir mock is not active; refusing to touch the real ~/.codex')
}
})
afterEach(() => {
rmSync(tmpHome, { recursive: true, force: true })
rmSync(userDataDir, { recursive: true, force: true })
if (previousUserDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
} else {
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
}
vi.clearAllMocks()
})
function systemConfigPath(): string {
return join(tmpHome, '.codex', 'config.toml')
}
function runtimeHomeDir(): string {
return join(userDataDir, 'codex-runtime-home', 'home')
}
function runtimeConfigPath(): string {
return join(runtimeHomeDir(), 'config.toml')
}
function baselinePath(): string {
return join(runtimeHomeDir(), '.orca-config-settings-baseline.json')
}
function writeSystemConfig(content: string): void {
mkdirSync(join(tmpHome, '.codex'), { recursive: true })
writeFileSync(systemConfigPath(), content, 'utf-8')
}
function readSystemConfig(): string {
return readFileSync(systemConfigPath(), 'utf-8')
}
function readRuntimeConfig(): string {
return readFileSync(runtimeConfigPath(), 'utf-8')
}
// Mimics how Codex (toml_edit) persists a /model or /approvals change: the
// top-level key line is rewritten in place, or created when absent.
function simulateCodexSettingWrite(key: string, rawValue: string): void {
mkdirSync(runtimeHomeDir(), { recursive: true })
const existing = existsSync(runtimeConfigPath()) ? readFileSync(runtimeConfigPath(), 'utf-8') : ''
const linePattern = new RegExp(`^${key}[ \\t]*=.*$`, 'm')
const rendered = `${key} = ${rawValue}`
const next = linePattern.test(existing)
? existing.replace(linePattern, rendered)
: `${rendered}\n${existing}`
writeFileSync(runtimeConfigPath(), next, 'utf-8')
}
function simulateCodexSettingRemoval(key: string): void {
const existing = readFileSync(runtimeConfigPath(), 'utf-8')
const linePattern = new RegExp(`^${key}[ \\t]*=.*\\n?`, 'm')
writeFileSync(runtimeConfigPath(), existing.replace(linePattern, ''), 'utf-8')
}
describe('codex settings write-back promotion', () => {
it('promotes an in-Codex model change to ~/.codex and reaches a steady state', () => {
writeSystemConfig(
'model = "gpt-5"\napproval_policy = "on-request"\n\n[features]\nhooks = true\n'
)
syncSystemConfigIntoManagedCodexHome()
expect(existsSync(baselinePath())).toBe(true)
simulateCodexSettingWrite('model', '"gpt-5.5-codex"')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'model = "gpt-5.5-codex"\napproval_policy = "on-request"\n\n[features]\nhooks = true\n'
)
expect(readRuntimeConfig()).toContain('model = "gpt-5.5-codex"')
const settledSystem = readSystemConfig()
const settledRuntime = readRuntimeConfig()
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(settledSystem)
expect(readRuntimeConfig()).toBe(settledRuntime)
})
it('promotes multiple approvals keys in one pass', () => {
writeSystemConfig('model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingWrite('approval_policy', '"never"')
simulateCodexSettingWrite('sandbox_mode', '"danger-full-access"')
syncSystemConfigIntoManagedCodexHome()
const system = readSystemConfig()
expect(system).toContain('approval_policy = "never"')
expect(system).toContain('sandbox_mode = "danger-full-access"')
expect(system).toContain('model = "gpt-5"')
})
it('does not promote on the first pass without a baseline, then promotes after one', () => {
writeSystemConfig('model = "gpt-5"\n')
mkdirSync(runtimeHomeDir(), { recursive: true })
// Pre-upgrade state: runtime already diverged, but no baseline exists.
writeFileSync(runtimeConfigPath(), 'model = "user-changed-before-upgrade"\n', 'utf-8')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\n')
expect(readRuntimeConfig()).toContain('model = "gpt-5"')
expect(existsSync(baselinePath())).toBe(true)
simulateCodexSettingWrite('model', '"o4"')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "o4"\n')
})
it('treats a corrupt baseline as missing and rewrites it', () => {
writeSystemConfig('model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
writeFileSync(baselinePath(), 'not json', 'utf-8')
simulateCodexSettingWrite('model', '"o4"')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\n')
expect(JSON.parse(readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 1 })
simulateCodexSettingWrite('model', '"o4"')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "o4"\n')
})
it('lets an outside ~/.codex edit win over a conflicting in-Codex change', () => {
writeSystemConfig('model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingWrite('model', '"in-codex-choice"')
writeSystemConfig('model = "outside-edit"\n')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "outside-edit"\n')
expect(readRuntimeConfig()).toContain('model = "outside-edit"')
})
it('inserts a key ~/.codex lacks into the preamble without disturbing the rest', () => {
writeSystemConfig('# my codex config\nmodel = "gpt-5"\n\n[features]\nhooks = true\n')
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingWrite('approval_policy', '"on-request"')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe(
'# my codex config\nmodel = "gpt-5"\napproval_policy = "on-request"\n\n[features]\nhooks = true\n'
)
})
it('creates ~/.codex/config.toml when a user without one changes a setting', () => {
mkdirSync(join(tmpHome, '.codex'), { recursive: true })
syncSystemConfigIntoManagedCodexHome()
expect(existsSync(baselinePath())).toBe(true)
// Codex itself creates the runtime config.toml on the first /model write.
simulateCodexSettingWrite('model', '"gpt-5.5-codex"')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5.5-codex"\n')
expect(readRuntimeConfig()).toContain('model = "gpt-5.5-codex"')
})
it('does not promote a key deletion', () => {
writeSystemConfig('model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingRemoval('model')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\n')
expect(readRuntimeConfig()).toContain('model = "gpt-5"')
})
it('ignores keys outside the allowlist', () => {
writeSystemConfig('model = "gpt-5"\n')
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingWrite('notify', '["custom-notifier"]')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\n')
})
it('ignores allowlisted keys inside tables such as [profiles.*]', () => {
writeSystemConfig('model = "gpt-5"\n\n[profiles.dev]\nmodel = "profile-model"\n')
syncSystemConfigIntoManagedCodexHome()
const runtime = readRuntimeConfig()
writeFileSync(
runtimeConfigPath(),
runtime.replace('model = "profile-model"', 'model = "profile-changed"'),
'utf-8'
)
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "gpt-5"\n\n[profiles.dev]\nmodel = "profile-model"\n')
})
it('never rewrites a multiline system value', () => {
writeSystemConfig('model = """\nodd\nmultiline\n"""\n')
syncSystemConfigIntoManagedCodexHome()
writeFileSync(runtimeConfigPath(), 'model = "single"\n', 'utf-8')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = """\nodd\nmultiline\n"""\n')
})
it('preserves CRLF line endings when replacing a value', () => {
writeSystemConfig('model = "gpt-5"\r\napproval_policy = "on-request"\r\n')
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingWrite('model', '"o4"')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toContain('model = "o4"\r\n')
expect(readSystemConfig()).toContain('approval_policy = "on-request"\r\n')
})
it('promotes over a value that carried an inline comment', () => {
writeSystemConfig('model = "gpt-5" # my favorite\n')
syncSystemConfigIntoManagedCodexHome()
simulateCodexSettingWrite('model', '"o4"')
syncSystemConfigIntoManagedCodexHome()
expect(readSystemConfig()).toBe('model = "o4"\n')
})
})
describe('upsertTopLevelSettingsInContent', () => {
it('writes into empty content', () => {
expect(upsertTopLevelSettingsInContent('', new Map([['model', '"x"']]))).toBe('model = "x"\n')
})
it('inserts before the first table with a separating blank line', () => {
expect(
upsertTopLevelSettingsInContent('[features]\nhooks = true\n', new Map([['model', '"x"']]))
).toBe('model = "x"\n\n[features]\nhooks = true\n')
})
it('appends to a preamble-only file without a trailing newline', () => {
expect(
upsertTopLevelSettingsInContent('approval_policy = "never"', new Map([['model', '"x"']]))
).toBe('approval_policy = "never"\nmodel = "x"\n')
})
it('replaces the existing line in place', () => {
expect(
upsertTopLevelSettingsInContent(
'# keep\nmodel = "old"\n\n[t]\nk = 1\n',
new Map([['model', '"new"']])
)
).toBe('# keep\nmodel = "new"\n\n[t]\nk = 1\n')
})
})
+242
View File
@@ -0,0 +1,242 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { writeFileAtomically } from '../codex-accounts/fs-utils'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
import {
createTomlLineScanState,
getTomlTableHeader,
isTomlStructuralLine,
updateTomlLineScanState
} from './config-toml-line-scan'
// Why: the config mirror rewrites the runtime config.toml from ~/.codex on
// every launch (and on background rate-limit fetches), so settings the user
// changes inside Orca-launched Codex silently revert. Promotion diffs the
// runtime file against a baseline of what Orca last wrote — anything that
// differs is a change Codex persisted for the user and belongs in ~/.codex.
// Why: only the user-preference scalars the Codex TUI itself persists
// (/model writes model + model_reasoning_effort, /approvals writes
// approval_policy + sandbox_mode). Every key added here gets written into the
// user's real ~/.codex/config.toml, so grow this list deliberately.
export const PROMOTED_CODEX_SETTING_KEYS = [
'model',
'model_reasoning_effort',
'approval_policy',
'sandbox_mode'
] as const
type TopLevelSettingValue = {
raw: string
// Why: a value that opens a multiline string/array cannot be replaced or
// copied line-by-line safely, so it is excluded from promotion entirely.
multiline: boolean
}
type SettingsBaselineFile = {
version: 1
settings: Record<string, string>
}
function getSettingsBaselinePath(runtimeHomePath: string): string {
return join(runtimeHomePath, '.orca-config-settings-baseline.json')
}
function readSettingsBaseline(runtimeHomePath: string): Map<string, string> | null {
const baselinePath = getSettingsBaselinePath(runtimeHomePath)
if (!existsSync(baselinePath)) {
return null
}
try {
const parsed: unknown = JSON.parse(readFileSync(baselinePath, 'utf-8'))
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null
}
const settings = (parsed as SettingsBaselineFile).settings
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
return null
}
const result = new Map<string, string>()
for (const [key, value] of Object.entries(settings)) {
if (typeof value === 'string') {
result.set(key, value)
}
}
return result
} catch {
return null
}
}
// Why: only keys in the top-level preamble are scanned — Codex writes profile
// overrides into [profiles.*] tables, and rewriting nested tables surgically
// is not worth the risk for stage-1 promotion.
function readTopLevelSettingValues(configPath: string): Map<string, TopLevelSettingValue> {
const result = new Map<string, TopLevelSettingValue>()
if (!existsSync(configPath)) {
return result
}
const lines = readFileSync(configPath, 'utf-8').split('\n')
let state = createTomlLineScanState()
for (const line of lines) {
if (isTomlStructuralLine(state)) {
if (getTomlTableHeader(line)) {
break
}
const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=[ \t]*(.*?)[ \t\r]*$/.exec(line)
const key = match?.[1]
if (key && (PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(key)) {
const nextState = updateTomlLineScanState(state, line)
result.set(key, { raw: match?.[2] ?? '', multiline: !isTomlStructuralLine(nextState) })
state = nextState
continue
}
}
state = updateTomlLineScanState(state, line)
}
return result
}
/**
* Records the promotable top-level settings the runtime config.toml holds
* after a mirror, so the next promotion can tell "value Orca mirrored" apart
* from "value Codex wrote for the user". Call after a successful mirror only —
* advancing the baseline past an unpromoted change would strand it forever.
*/
export function snapshotCodexRuntimeSettingsBaseline(): void {
try {
const runtimeHomePath = getOrcaManagedCodexHomePath()
const runtimeTomlPath = join(runtimeHomePath, 'config.toml')
// Why: a missing runtime config still records an empty baseline — when
// Codex later creates the file for a user with no ~/.codex/config.toml,
// that first change must diff against "Orca left nothing" and promote.
const settings: Record<string, string> = {}
for (const [key, value] of readTopLevelSettingValues(runtimeTomlPath)) {
if (!value.multiline) {
settings[key] = value.raw
}
}
const file: SettingsBaselineFile = { version: 1, settings }
writeFileSync(getSettingsBaselinePath(runtimeHomePath), `${JSON.stringify(file, null, 2)}\n`, {
encoding: 'utf-8',
mode: 0o600
})
} catch (error) {
console.warn('[codex-settings-promotion] failed to snapshot settings baseline', error)
}
}
/**
* Promotes setting changes the user made inside Orca-launched Codex (written
* by Codex into the runtime config.toml) into ~/.codex/config.toml. Runs
* before the config mirror so the promoted values survive the same mirror
* pass instead of reverting.
*/
export function promoteCodexRuntimeSettingsToSystem(): void {
try {
promoteCodexRuntimeSettingsToSystemUnsafe()
} catch (error) {
// Why: promotion is best-effort launch prep; a malformed runtime file
// must not block the config mirror or the Codex launch itself.
console.warn('[codex-settings-promotion] failed to promote runtime settings', error)
}
}
function promoteCodexRuntimeSettingsToSystemUnsafe(): void {
const runtimeHomePath = getOrcaManagedCodexHomePath()
const systemHomePath = getSystemCodexHomePath()
const runtimeTomlPath = join(runtimeHomePath, 'config.toml')
const systemTomlPath = join(systemHomePath, 'config.toml')
if (resolve(runtimeTomlPath) === resolve(systemTomlPath)) {
return
}
if (!existsSync(runtimeTomlPath)) {
return
}
// Why: without a baseline of what Orca last mirrored (first launch after
// upgrading to a build with promotion, or a corrupted snapshot), a stale
// runtime value is indistinguishable from a fresh in-Codex change. Skip
// this pass — the mirror writes the first baseline and promotion starts on
// the next one.
const baseline = readSettingsBaseline(runtimeHomePath)
if (!baseline) {
return
}
const runtimeValues = readTopLevelSettingValues(runtimeTomlPath)
const systemValues = readTopLevelSettingValues(systemTomlPath)
const updates = new Map<string, string>()
for (const key of PROMOTED_CODEX_SETTING_KEYS) {
const runtime = runtimeValues.get(key)
if (!runtime || runtime.multiline) {
continue
}
if (runtime.raw === baseline.get(key)) {
// Orca mirrored this value and nothing touched it since — not a change.
continue
}
const system = systemValues.get(key)
if (system?.multiline) {
continue
}
// Why: ~/.codex stays source of truth — if the user also edited it there
// since the baseline, the outside edit wins over the in-Codex change.
if (system?.raw !== baseline.get(key)) {
continue
}
updates.set(key, runtime.raw)
}
if (updates.size === 0) {
return
}
const systemContent = existsSync(systemTomlPath) ? readFileSync(systemTomlPath, 'utf-8') : ''
writeFileAtomically(systemTomlPath, upsertTopLevelSettingsInContent(systemContent, updates))
}
export function upsertTopLevelSettingsInContent(
content: string,
updates: Map<string, string>
): string {
const lines = content.split('\n')
let state = createTomlLineScanState()
let preambleEnd = lines.length
const keyLineIndexes = new Map<string, number>()
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? ''
if (isTomlStructuralLine(state)) {
if (getTomlTableHeader(line)) {
preambleEnd = index
break
}
const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=/.exec(line)
if (match?.[1] && updates.has(match[1])) {
keyLineIndexes.set(match[1], index)
}
}
state = updateTomlLineScanState(state, line)
}
const insertions: string[] = []
for (const [key, raw] of updates) {
const existingIndex = keyLineIndexes.get(key)
const rendered = `${key} = ${raw}`
if (existingIndex !== undefined) {
// Why: CRLF configs keep a trailing \r after the split; preserve it so
// the rewritten line matches the file's existing endings.
lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered
} else {
insertions.push(rendered)
}
}
if (insertions.length > 0) {
let insertAt = preambleEnd
while (insertAt > 0 && (lines[insertAt - 1] ?? '').trim() === '') {
insertAt -= 1
}
if (insertAt === preambleEnd && preambleEnd < lines.length) {
insertions.push('')
}
lines.splice(insertAt, 0, ...insertions)
}
const result = lines.join('\n')
return result.endsWith('\n') || result.length === 0 ? result : `${result}\n`
}
+4 -4
View File
@@ -473,12 +473,12 @@
scrollbar-color: color-mix(in srgb, var(--muted-foreground, #737373) 48%, transparent) transparent;
}
/* Keep the sidebar gutter reserved; only the thumb fades in on hover. */
/* Why: no reserved `scrollbar-gutter` — paired with `overflow-y: auto`, a list
that fits shows no scrollbar (and no Windows scrollbar arrow buttons), so its
rows stay flush with the fixed header controls, matching macOS. The gutter is
only taken while actually scrolling. Thumb still fades in on hover. */
.worktree-sidebar-scrollbar {
/* Why: the scrollbar gutter is paint-only in Chromium; this keeps card
surfaces clear of Orca's resize handle and right-edge sidebar chrome. */
padding-right: 4px;
scrollbar-gutter: stable;
scrollbar-color: transparent transparent;
}
@@ -3988,7 +3988,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
onWheel={markDirectScrollInput}
onDragOver={handleWorktreeDragOver}
onDrop={handleWorktreeDrop}
className="worktree-sidebar-scrollbar h-full overflow-y-scroll overflow-x-hidden pl-1 scrollbar-sleek outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset pt-px"
className="worktree-sidebar-scrollbar h-full overflow-y-auto overflow-x-hidden pl-1 scrollbar-sleek outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset pt-px"
style={WORKTREE_SIDEBAR_SCROLL_STYLE}
>
<div
@@ -6722,7 +6722,7 @@ const WorktreeList = React.memo(function WorktreeList({
data-contextual-tour-target="workspace-list"
className="relative min-h-0 flex-1"
>
<div className="worktree-sidebar-scrollbar flex h-full flex-col overflow-y-scroll overflow-x-hidden pl-1 scrollbar-sleek pt-px">
<div className="worktree-sidebar-scrollbar flex h-full flex-col overflow-y-auto overflow-x-hidden pl-1 scrollbar-sleek pt-px">
<div className="flex flex-col items-center gap-2 px-4 py-6 text-center text-[11px] text-muted-foreground">
<span>
{translate('auto.components.sidebar.WorktreeList.b7acbf038b', 'No workspaces found')}
@@ -187,6 +187,7 @@ import {
normalizeCompatibleAgentTitleForOwner,
resolveCompatibleAgentTypeForOwner
} from '../../../../shared/agent-title-owner'
import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner'
import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence'
import {
isExpectedAgentProcess,
@@ -1362,11 +1363,13 @@ export function connectPanePty(
(entry) => entry.id === deps.tabId
)
return (
tab?.launchAgent ??
paneStartup?.launchAgent ??
paneStartup?.initialAgentStatus?.agent ??
commandInferredPaneAgent ??
state.agentStatusByPaneKey[cacheKey]?.agentType
resolvePaneAgentOwner({
launchAgent: tab?.launchAgent,
startupLaunchAgent: paneStartup?.launchAgent,
initialStatusAgent: paneStartup?.initialAgentStatus?.agent,
commandInferredAgent: commandInferredPaneAgent,
hookAgent: state.agentStatusByPaneKey[cacheKey]?.agentType
}) ?? undefined
)
}
// Why: the renderer veto (owner evidence beating a Gemini-looking title) must
@@ -0,0 +1,305 @@
import { describe, expect, it } from 'vitest'
import { resolveTabAgentFromSignals } from './use-tab-agent'
// Pi/OMP share a title-identity group: OMP wraps Pi and emits Pi-compatible
// wrapper title frames. These tests pin how the tab-icon resolver keeps an
// OMP-owned pane on OMP (and a Pi-owned pane on Pi) as those frames arrive,
// including when the pane loses its host-owned launchAgent on a mirrored or
// restored client.
describe('resolveTabAgentFromSignals — Pi/OMP identity', () => {
it('keeps OMP launch identity over Pi-compatible wrapper titles after activity', () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: null,
launchAgent: 'omp'
})
).toBe('omp')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: 'pi',
launchAgent: 'omp'
})
).toBe('omp')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: false,
title: '⠋ Pi',
hookAgent: 'pi',
launchAgent: 'omp'
})
).toBe('omp')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: 'Terminal 1',
hookAgent: null,
siblingHookAgent: 'pi',
launchAgent: 'omp'
})
).toBe('omp')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ OMP',
hookAgent: 'omp',
launchAgent: 'pi'
})
).toBe('pi')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: 'zsh',
hookAgent: null,
focusedCompletedHookAgent: 'pi',
launchAgent: 'omp'
})
).toBe('omp')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: false,
title: 'zsh',
hookAgent: null,
siblingCompletedHookAgent: 'pi',
launchAgent: 'omp'
})
).toBe('omp')
})
it('keeps a restored/mirrored OMP pane on OMP when launchAgent is gone', () => {
// Why: a mirrored or restored OMP pane loses its host-owned launchAgent but
// keeps emitting Pi-compatible wrapper title frames. Durable pane identity
// (last completed hook / hibernated session) must anchor those frames to OMP
// instead of letting them repaint the tab as Pi.
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: null,
focusedCompletedHookAgent: 'omp',
launchAgent: undefined
})
).toBe('omp')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: null,
sleepingSessionAgent: 'omp',
launchAgent: undefined
})
).toBe('omp')
})
it('does not flap between OMP and Pi as a launchAgent-less pane cycles hooks', () => {
// The flicker: identity must not flip when the live hook row appears/clears.
const withLiveHook = resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: 'omp',
focusedCompletedHookAgent: 'omp',
launchAgent: undefined
})
const afterHookCleared = resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: null,
focusedCompletedHookAgent: 'omp',
launchAgent: undefined
})
expect(withLiveHook).toBe('omp')
expect(afterHookCleared).toBe('omp')
})
it('keeps a launchAgent-less Pi pane on Pi and rejects a stale OMP session record', () => {
// The fallback must not over-reach: a genuine Pi pane (recent Pi hook) stays
// Pi even if a stale hibernated OMP record is present.
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: null,
focusedCompletedHookAgent: 'pi',
sleepingSessionAgent: 'omp',
launchAgent: undefined
})
).toBe('pi')
// An OMP-compatible title on a launchAgent-less Pi pane still resolves to Pi.
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ OMP',
hookAgent: null,
focusedCompletedHookAgent: 'pi',
launchAgent: undefined
})
).toBe('pi')
})
})
// The tab icon is a pane's IDENTITY, not its activity state: a hook record
// identifies the pane whether the agent is mid-turn (live) or idle (done). These
// pin that separation so identity can't collapse back into the (non-
// distinguishing) title layer.
describe('resolveTabAgentFromSignals — identity vs liveness', () => {
it('surfaces the focused idle identity from the record, not the title', () => {
// Agent went idle between turns; the title names no agent. Identity still
// comes from the pane's own done-hook record.
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: 'Terminal',
hookAgent: null,
focusedCompletedHookAgent: 'omp',
launchAgent: undefined
})
).toBe('omp')
})
it('ranks the focused idle identity above a hibernated session and launch bootstrap', () => {
// The agent that actually ran and idled here beats both a hibernation record
// and stale launch intent.
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: 'Terminal',
hookAgent: null,
focusedCompletedHookAgent: 'omp',
sleepingSessionAgent: 'claude',
launchAgent: 'codex'
})
).toBe('omp')
})
it('never lets a title override a live hook (ground truth)', () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '✳ Claude Code',
hookAgent: 'omp',
launchAgent: undefined
})
).toBe('omp')
})
it('lets a different-group title reclaim a reused idle pane without launch metadata', () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '✳ Claude Code',
hookAgent: null,
focusedCompletedHookAgent: 'codex',
launchAgent: undefined
})
).toBe('claude')
})
it('keeps a launchAgent-less pane with a live Pi hook stable on Pi', () => {
// A launchless pane whose live hook reports Pi resolves to Pi and stays Pi
// when the hook clears (the completed record is Pi too) — no flip to OMP.
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: 'pi',
launchAgent: undefined
})
).toBe('pi')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: null,
focusedCompletedHookAgent: 'pi',
launchAgent: undefined
})
).toBe('pi')
})
it('keeps a sibling idle identity when the focused pane returns to its shell', () => {
// Focused pane's local shell-exit evidence must not clear the sibling's idle
// identity — the sibling agent is still there.
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: false,
title: 'zsh',
hookAgent: null,
focusedCompletedHookAgent: 'claude',
siblingCompletedHookAgent: 'gemini',
launchAgent: undefined
})
).toBe('gemini')
})
it('does not let a sibling pane re-own the focused pane ambiguous Pi title', () => {
// A split-pane sibling running OMP says nothing about which Pi-variant the
// focused pane runs; the focused pane's own Pi title must stay Pi.
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: null,
focusedCompletedHookAgent: null,
siblingCompletedHookAgent: 'omp',
launchAgent: undefined
})
).toBe('pi')
})
it('does not flash the exited agent before a hookless reuse title reclaims on mount', () => {
// hasObservedAgentSignal starts false for one mount commit; a completed hook
// is itself activity evidence, so the reuse title reclaims immediately
// instead of flashing the prior agent's idle identity. (claude ran+idled,
// then a hookless codex reused the pane and emits its own title.)
const onMount = resolveTabAgentFromSignals({
hasObservedAgentSignal: false,
isRemote: false,
title: '⠋ Codex',
hookAgent: null,
focusedCompletedHookAgent: 'claude',
launchAgent: undefined
})
const afterObserved = resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: false,
title: '⠋ Codex',
hookAgent: null,
focusedCompletedHookAgent: 'claude',
launchAgent: undefined
})
expect(onMount).toBe('codex')
expect(afterObserved).toBe('codex')
})
})
+2 -74
View File
@@ -271,80 +271,8 @@ describe('resolveTabAgentFromSignals', () => {
).toBe('codex')
})
it('keeps OMP launch identity over Pi-compatible wrapper titles after activity', () => {
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: null,
launchAgent: 'omp'
})
).toBe('omp')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ Pi',
hookAgent: 'pi',
launchAgent: 'omp'
})
).toBe('omp')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: false,
title: '⠋ Pi',
hookAgent: 'pi',
launchAgent: 'omp'
})
).toBe('omp')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: 'Terminal 1',
hookAgent: null,
siblingHookAgent: 'pi',
launchAgent: 'omp'
})
).toBe('omp')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: '⠋ OMP',
hookAgent: 'omp',
launchAgent: 'pi'
})
).toBe('pi')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: true,
title: 'zsh',
hookAgent: null,
focusedCompletedHookAgent: 'pi',
launchAgent: 'omp'
})
).toBe('omp')
expect(
resolveTabAgentFromSignals({
hasObservedAgentSignal: true,
isRemote: false,
title: 'zsh',
hookAgent: null,
siblingCompletedHookAgent: 'pi',
launchAgent: 'omp'
})
).toBe('omp')
})
// Pi/OMP identity (shared title-identity group, launchAgent-loss flicker)
// lives in use-tab-agent-pi-identity.test.ts.
it('prefers explicit hook identity over a conflicting title mention', () => {
expect(
+99 -68
View File
@@ -12,6 +12,7 @@ import {
} from './tab-agent'
import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type'
import { resolveCompatibleAgentTypeForOwner } from '../../../shared/agent-title-owner'
import { resolvePaneAgentOwner } from '../../../shared/pane-agent-owner'
import type { TerminalTab, TuiAgent } from '../../../shared/types'
// A shell name, or the tab's neutral default title — where Orca's
@@ -83,50 +84,85 @@ export function resolveTabAgentFromSignals(args: {
launchAgent?: TuiAgent
}): TuiAgent | null {
const launchAgent = args.launchAgent ?? null
const explicitTitleAgent = resolveSignalAgentForLaunchOwner(
resolveExplicitTerminalTitleAgentType(args.title),
launchAgent
)
// Why: explicit titles can override stale launches after activity, but
// Pi-compatible wrapper signals first resolve through the launch owner so
// OMP-created sessions do not repaint as Pi.
// Why: OSC 133;D proved this local pane's foreground is back at the shell,
// so any title-derived identity is stale by definition — a TUI that died
// with a stuck title must not keep painting the tab through the title layer.
// The focused pane's durable agent owner: launch intent first, then this
// pane's own host-stamped hook identity (live or completed), then its
// hibernated session record. It anchors every identity decision for THIS pane
// — it re-owns ambiguous Pi-compatible wrapper titles (OMP emits Pi's frames)
// and keeps a mirrored or restored pane, which dropped its host-owned
// launchAgent, resolving through the durable record instead of the title.
// Why: strictly focused-pane-scoped — a sibling split-pane's identity says
// nothing about which Pi-variant this pane runs, so it must not re-own the
// focused title (it would mislabel a genuine Pi pane as its sibling's OMP).
const owner = resolvePaneAgentOwner({
launchAgent,
hookAgent: args.hookAgent,
completedHookAgent: args.focusedCompletedHookAgent,
sleepingSessionAgent: args.sleepingSessionAgent
}) as TuiAgent | null
// A pane's identity comes from its hook record regardless of activity state —
// being idle between turns must not erase which agent is here. We keep the
// live/idle split only because it governs how a title may override identity: a
// LIVE hook is ground truth (a title never overrides it), while an IDLE record
// is durable but a cross-group title can reclaim a pane reused for a different
// agent. Sibling identities normalize against launchAgent only (the tab's
// shared launch intent), never the focused pane's own hook identity.
const liveFocusedIdentity = resolveSignalAgentForLaunchOwner(args.hookAgent, owner)
const liveSiblingIdentity = resolveSignalAgentForLaunchOwner(args.siblingHookAgent, launchAgent)
// Why: OSC 133;D proved this local pane's foreground is back at the shell, so
// the finished agent's idle identity is stale and must stop painting the tab.
// Remote titles lag their runtime, so keep the idle identity there.
const processProvesShell = !args.isRemote && args.processShellForeground === true
const titleOverridesLaunch =
launchAgent !== null &&
explicitTitleAgent !== null &&
explicitTitleAgent !== launchAgent &&
args.hasObservedAgentSignal
const titleAgent = processProvesShell
? null
: titleOverridesLaunch
? explicitTitleAgent
: launchAgent
? null
: explicitTitleAgent
const hasCompletedHook = (args.focusedCompletedHookAgent ?? null) !== null
const noAgentTitle = titleShowsNoAgent(args.title, args.defaultTitle)
// Why: remote pane titles can lag their runtime, so keep the last completed
// hook identity instead of flashing unknown when the title reads as a shell.
const completedHookAgent =
const idleIdentitySuppressed =
!args.isRemote && (noAgentTitle || processProvesShell) && hasCompletedHook
? null
: resolveSignalAgentForLaunchOwner(
args.focusedCompletedHookAgent ?? args.siblingCompletedHookAgent,
launchAgent
)
const focusedHookAgent = resolveSignalAgentForLaunchOwner(args.hookAgent, launchAgent)
const siblingHookAgent = resolveSignalAgentForLaunchOwner(args.siblingHookAgent, launchAgent)
const fallbackHookAgent = siblingHookAgent ?? completedHookAgent
const idleFocusedIdentity = idleIdentitySuppressed
? null
: resolveSignalAgentForLaunchOwner(args.focusedCompletedHookAgent, owner)
// Why: `idleIdentitySuppressed` is the FOCUSED pane's own exit evidence, so it
// must not clear a sibling split-pane's idle identity — a focused pane back at
// its shell says nothing about whether the sibling's agent has exited.
const idleSiblingIdentity = resolveSignalAgentForLaunchOwner(
args.siblingCompletedHookAgent,
launchAgent
)
// The title carries identity in only two roles: (a) a reuse override — it
// names a DIFFERENT-group agent than the pane's known identity, proving the
// pane was reused for a new agent — or (b) a legacy standalone identity when
// the pane has no hook at all. Within the same title-identity group it says
// nothing (OMP wraps Pi and emits identical frames), so the durable record
// wins; re-owning explicitTitleAgent through `owner` enforces that.
const explicitTitleAgent = resolveSignalAgentForLaunchOwner(
resolveExplicitTerminalTitleAgentType(args.title),
owner
)
const priorIdentity = idleFocusedIdentity ?? launchAgent
// Why: a completed hook is itself proof the pane has shown activity, so it
// arms the reuse override without waiting for `hasObservedAgentSignal` — which
// starts false for one mount commit and would otherwise flash the exited
// agent's idle identity before the new (hookless) agent's title reclaims.
const titleReclaimsReusedPane =
priorIdentity !== null &&
explicitTitleAgent !== null &&
explicitTitleAgent !== priorIdentity &&
(args.hasObservedAgentSignal || hasCompletedHook)
const titleAgent = processProvesShell
? null
: titleReclaimsReusedPane
? explicitTitleAgent
: priorIdentity
? null
: explicitTitleAgent
const launchedAgentExited = resolveLaunchedAgentExitEvidence({
title: args.title,
defaultTitle: args.defaultTitle,
isRemote: args.isRemote,
hasObservedAgentSignal: args.hasObservedAgentSignal,
hookAgent: focusedHookAgent,
siblingHookAgent,
hookAgent: liveFocusedIdentity,
siblingHookAgent: liveSiblingIdentity,
hasCompletedHook,
processAgent: args.processAgent,
processShellForeground: args.processShellForeground
@@ -134,53 +170,48 @@ export function resolveTabAgentFromSignals(args: {
const activeLaunchAgent = launchedAgentExited ? null : launchAgent
const processAgent = args.processAgent ?? null
const sleepingSessionAgent = args.sleepingSessionAgent ?? null
// Why: titleAgent ranks ahead of launch/fallback hooks because, once the
// pane has shown activity, a live explicit title is the freshest identity
// signal — it beats a launchAgent gone stale through pane reuse. Before any
// activity, titleAgent is null while launchAgent exists, so launch bootstrap
// still wins the startup window. Process identity (the recognized foreground
// process) is ground truth below hooks — it covers agents that emit neither
// hooks nor titles. sleepingSessionAgent ranks above launch bootstrap: a
// hibernated pane's captured session identity is pane-scoped proof that the
// launch identity went stale (a codex launch later reused for claude), and it
// is the only live signal while the pane is asleep (its PTY, hooks, and
// process are all gone).
// Identity-first precedence. The live focused hook is ground truth while the
// agent works; process identity covers agents with neither hook nor title; the
// title then acts only in its reuse-override / legacy roles; and the pane's
// durable idle identity — the record for an agent that ran here and went idle —
// ranks above the hibernated session, the launch bootstrap, and sibling panes.
return (
focusedHookAgent ??
liveFocusedIdentity ??
processAgent ??
titleAgent ??
idleFocusedIdentity ??
sleepingSessionAgent ??
activeLaunchAgent ??
fallbackHookAgent
liveSiblingIdentity ??
idleSiblingIdentity
)
}
/**
* Resolve which coding-harness agent a terminal tab is running, for its tab-bar
* icon. Identity flows through the same already-computed state as the sidebar
* agent rows — no foreground probing. Layered signals, most-authoritative
* first:
* agent rows — no foreground probing. It is a pane's IDENTITY, kept separate
* from its activity state: a hook record identifies the pane whether the agent
* is working or idle. Identity-first precedence:
*
* 1. Hook status — provider identity from native integrations; the live entry
* for the pane, dropped by the same OSC 133 command-finished machinery that
* clears the sidebar row when the process exits.
* 1. Live focused hook — provider identity from native integrations while the
* agent is actively working; ground truth, never overridden by a title.
* 2. Process identity — the recognized foreground process, read at OSC 133
* command boundaries (local panes only); covers agents that emit neither
* hooks nor titles, and its shell-foreground mark is title-independent
* exit evidence.
* 3. Sleeping session identity — a hibernated pane's captured session record
* (agent + provider session). Ranks below title but above launchAgent
* because it is pane-scoped proof of reuse: a codex launch later reused for
* claude leaves a claude sleeping record, and while the pane is asleep this
* is the only live identity signal (its PTY, hooks, and process are gone).
* 4. launchAgent — what Orca launched here; instant bootstrap before hooks
* arrive, cleared once hook/process/title evidence shows the launched
* agent exited.
* 5. Title — legacy/unknown-session fallback, and the live override when a pane
* is reused: once the pane has shown activity, a title that explicitly names
* a different agent than launchAgent wins over that stale launch identity.
* Otherwise it is ignored while launchAgent exists, and generic spinner-only
* titles never identify an agent.
* hooks nor titles, and its shell-foreground mark is title-independent exit
* evidence.
* 3. Title — only as a reuse override (it names a DIFFERENT-group agent than the
* pane's known identity, proving reuse) or as a legacy standalone identity
* when the pane has no hook. Within the same title-identity group it carries
* no identity (OMP wraps Pi with identical frames), so the record wins.
* 4. Idle focused identity — the pane's own hook record after the agent went
* idle between turns; the durable answer to "which agent is this" once the
* live hook, process, and any reuse-title are absent. Suppressed on a local
* pane once OSC 133;D proves the agent exited.
* 5. Sleeping session identity — a hibernated pane's captured session record.
* 6. launchAgent — what Orca launched here; the bootstrap before any hook, hook
* record, or process signal exists, cleared once exit evidence shows it left.
* 7. Sibling-pane identity (live, then idle) — split-tab fallback.
*/
export function useTabAgent(tab: TerminalTab): TuiAgent | null {
const focusedHookAgent = useAppStore((s) =>
@@ -48,6 +48,7 @@ import {
normalizeCompatibleAgentStatusEntryForOwner,
normalizeCompatibleAgentTitleForOwner
} from '../../../shared/agent-title-owner'
import { resolvePaneAgentOwner } from '../../../shared/pane-agent-owner'
import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution'
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
import { clearWebSessionFocusIntent, peekWebSessionFocusIntent } from './web-session-focus-intent'
@@ -542,10 +543,12 @@ function buildMirroredTerminalTabs(
.filter((ptyId): ptyId is string => typeof ptyId === 'string' && ptyId.length > 0)
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 ownerAgent = resolvePaneAgentOwner({
launchAgent,
hookAgent: activeSurface.agentStatus?.agentType,
siblingHookAgent: surfaces.find((surface) => surface.agentStatus?.agentType)?.agentStatus
?.agentType
})
const title = normalizeCompatibleAgentTitleForOwner(
activeSurface.title.trim() || surfaces[0]?.title.trim() || 'Terminal',
ownerAgent
@@ -624,7 +627,10 @@ function remapHostAgentStatus(surface: TerminalSurface): AgentStatusEntry | null
if (!paneKey) {
return null
}
const ownerAgent = surface.launchAgent ?? surface.agentStatus.agentType
const ownerAgent = resolvePaneAgentOwner({
launchAgent: surface.launchAgent,
hookAgent: surface.agentStatus.agentType
})
return {
...normalizeCompatibleAgentStatusEntryForOwner(surface.agentStatus, ownerAgent),
paneKey
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { resolvePaneAgentOwner } from './pane-agent-owner'
describe('resolvePaneAgentOwner', () => {
it('leads with launch intent', () => {
expect(
resolvePaneAgentOwner({ launchAgent: 'omp', hookAgent: 'pi', sleepingSessionAgent: 'claude' })
).toBe('omp')
expect(resolvePaneAgentOwner({ startupLaunchAgent: 'codex', hookAgent: 'claude' })).toBe(
'codex'
)
expect(
resolvePaneAgentOwner({ initialStatusAgent: 'gemini', commandInferredAgent: 'codex' })
).toBe('gemini')
})
it('falls through to the durable host-stamped hook identity when launch intent is gone', () => {
// The mirror/restore case: launchAgent dropped, live hook is the anchor.
expect(resolvePaneAgentOwner({ hookAgent: 'omp' })).toBe('omp')
// Live hook cleared: the last completed hook carries the identity.
expect(resolvePaneAgentOwner({ completedHookAgent: 'omp' })).toBe('omp')
// Nothing live at all: the hibernated session record is the last resort.
expect(resolvePaneAgentOwner({ sleepingSessionAgent: 'omp' })).toBe('omp')
})
it('ranks live/recent evidence above the hibernated record so a stale record cannot hijack', () => {
expect(resolvePaneAgentOwner({ hookAgent: 'pi', sleepingSessionAgent: 'omp' })).toBe('pi')
expect(resolvePaneAgentOwner({ completedHookAgent: 'pi', sleepingSessionAgent: 'omp' })).toBe(
'pi'
)
})
it('prefers focused over sibling evidence at each tier', () => {
expect(resolvePaneAgentOwner({ hookAgent: 'omp', siblingHookAgent: 'pi' })).toBe('omp')
expect(
resolvePaneAgentOwner({ completedHookAgent: 'omp', siblingCompletedHookAgent: 'pi' })
).toBe('omp')
})
it('returns null when no owner evidence exists', () => {
expect(resolvePaneAgentOwner({})).toBeNull()
expect(resolvePaneAgentOwner({ launchAgent: null, hookAgent: undefined })).toBeNull()
})
})
+58
View File
@@ -0,0 +1,58 @@
import type { AgentType } from './agent-status-types'
/**
* The owner-evidence signals a terminal pane can carry, strongest launch intent
* first, ending in the durable host-stamped identity. Every field is optional so
* each call site passes only what it holds; absent signals are skipped.
*/
export type PaneAgentOwnerSignals = {
/** Tab-scoped launch intent — what Orca launched into this tab. */
launchAgent?: AgentType | null
/** Never-cleared per-connection launch seed (pane connection only). */
startupLaunchAgent?: AgentType | null
/** Startup-provided initial agent status (pane connection only). */
initialStatusAgent?: AgentType | null
/** Agent inferred from a manually typed shell command (pane connection only). */
commandInferredAgent?: AgentType | null
/** Live focused-pane hook identity — host-stamped, published, mirror-safe. */
hookAgent?: AgentType | null
/** Live sibling-pane hook identity. */
siblingHookAgent?: AgentType | null
/** Last completed focused-pane hook — survives the live hook row clearing. */
completedHookAgent?: AgentType | null
/** Last completed sibling-pane hook. */
siblingCompletedHookAgent?: AgentType | null
/** Hibernated session record — the final durable identity while a pane sleeps. */
sleepingSessionAgent?: AgentType | null
}
/**
* The single authoritative resolver for "which agent owns this pane", shared by
* the tab-icon resolver, the terminal-pane display/renderer owner, and the
* mirrored-tab title owner so they cannot drift apart.
*
* Why this precedence: launch intent is the authoritative bootstrap before any
* process signal exists, so it leads. Once launch metadata is gone — a mirrored
* or restored pane drops the host-owned launchAgent — the owner must fall
* through to a durable pane identity rather than to the raw title, because a
* wrapper agent's title (OMP emits Pi-compatible frames) cannot be told apart
* from the agent it wraps. The host-stamped hook identity is that durable,
* published, mirror-safe anchor; the last completed hook and the hibernated
* session record carry it across the windows where no live hook exists. Ranking
* launch/live-hook above the completed/sleeping records keeps a genuine pane on
* its real agent and stops a stale record from hijacking it.
*/
export function resolvePaneAgentOwner(signals: PaneAgentOwnerSignals): AgentType | null {
return (
signals.launchAgent ??
signals.startupLaunchAgent ??
signals.initialStatusAgent ??
signals.commandInferredAgent ??
signals.hookAgent ??
signals.siblingHookAgent ??
signals.completedHookAgent ??
signals.siblingCompletedHookAgent ??
signals.sleepingSessionAgent ??
null
)
}