From 81e4f0fa682d780f302aa5b8f6fa81a8565ba4c0 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 9 Jul 2026 11:21:35 -0700 Subject: [PATCH 1/5] fix(sidebar): only show the projects scrollbar when the list overflows (#7845) --- src/renderer/src/assets/main.css | 8 ++++---- src/renderer/src/components/sidebar/WorktreeList.tsx | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index d9d948b1f2e..44671b28a50 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -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; } diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index eb1dd5a6018..7b8aaab26d3 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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} >
-
+
{translate('auto.components.sidebar.WorktreeList.b7acbf038b', 'No workspaces found')} From 1534edc073f11f45f8591bc0d7e744d15bc2e370 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:38:26 -0700 Subject: [PATCH 2/5] Separate pane identity from liveness in the tab-agent resolver (fixes OMP tab flicker) (#7860) --- .../terminal-pane/pty-connection.ts | 13 +- .../src/lib/use-tab-agent-pi-identity.test.ts | 305 ++++++++++++++++++ src/renderer/src/lib/use-tab-agent.test.ts | 76 +---- src/renderer/src/lib/use-tab-agent.ts | 167 ++++++---- .../src/runtime/web-session-tabs-sync.ts | 16 +- src/shared/pane-agent-owner.test.ts | 44 +++ src/shared/pane-agent-owner.ts | 58 ++++ 7 files changed, 527 insertions(+), 152 deletions(-) create mode 100644 src/renderer/src/lib/use-tab-agent-pi-identity.test.ts create mode 100644 src/shared/pane-agent-owner.test.ts create mode 100644 src/shared/pane-agent-owner.ts diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 1ba85b67787..2d534757a41 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -165,6 +165,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, @@ -1497,11 +1498,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 diff --git a/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts b/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts new file mode 100644 index 00000000000..abe494ee174 --- /dev/null +++ b/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts @@ -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') + }) +}) diff --git a/src/renderer/src/lib/use-tab-agent.test.ts b/src/renderer/src/lib/use-tab-agent.test.ts index d4d6031b209..9152159f14b 100644 --- a/src/renderer/src/lib/use-tab-agent.test.ts +++ b/src/renderer/src/lib/use-tab-agent.test.ts @@ -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( diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 50420497155..93051d41d4f 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -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) => diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 47942c67433..e28072d5357 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -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 diff --git a/src/shared/pane-agent-owner.test.ts b/src/shared/pane-agent-owner.test.ts new file mode 100644 index 00000000000..1d57f0b79c4 --- /dev/null +++ b/src/shared/pane-agent-owner.test.ts @@ -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() + }) +}) diff --git a/src/shared/pane-agent-owner.ts b/src/shared/pane-agent-owner.ts new file mode 100644 index 00000000000..65659a55e59 --- /dev/null +++ b/src/shared/pane-agent-owner.ts @@ -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 + ) +} From ff661a7ab24e23692d849129024deef16bc52a2d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:39:27 +0000 Subject: [PATCH 3/5] release: v1.4.131-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ca303015121..a0ae48e60ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.130", + "version": "1.4.131-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 94662c84451d10e229ca77434353a051cffefea9 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:17:35 -0700 Subject: [PATCH 4/5] feat(codex): write in-Codex setting changes back to ~/.codex config (#7960) Co-authored-by: Orca --- config/tsconfig.cli.json | 1 + src/main/codex/codex-config-mirror.ts | 12 + .../codex/config-settings-promotion.test.ts | 296 ++++++++++++++++++ src/main/codex/config-settings-promotion.ts | 242 ++++++++++++++ 4 files changed, 551 insertions(+) create mode 100644 src/main/codex/config-settings-promotion.test.ts create mode 100644 src/main/codex/config-settings-promotion.ts diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 21cae9319cc..0fd87a36f7c 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -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", diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index ce9208e9b05..605f2cd5f24 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -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 { diff --git a/src/main/codex/config-settings-promotion.test.ts b/src/main/codex/config-settings-promotion.test.ts new file mode 100644 index 00000000000..f81866d8751 --- /dev/null +++ b/src/main/codex/config-settings-promotion.test.ts @@ -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() + 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') + }) +}) diff --git a/src/main/codex/config-settings-promotion.ts b/src/main/codex/config-settings-promotion.ts new file mode 100644 index 00000000000..934d57ed39e --- /dev/null +++ b/src/main/codex/config-settings-promotion.ts @@ -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 +} + +function getSettingsBaselinePath(runtimeHomePath: string): string { + return join(runtimeHomePath, '.orca-config-settings-baseline.json') +} + +function readSettingsBaseline(runtimeHomePath: string): Map | 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() + 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 { + const result = new Map() + 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 = {} + 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() + 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 { + const lines = content.split('\n') + let state = createTomlLineScanState() + let preambleEnd = lines.length + const keyLineIndexes = new Map() + 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` +} From 41edc493b80f1e0927f0d5be90b23e0225cc6be4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:37:51 +0000 Subject: [PATCH 5/5] release: v1.4.131-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a0ae48e60ee..e6ad02b2d04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.131-rc.0", + "version": "1.4.131-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai",