mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user