* perf(codex): skip migration scans for nonshared reattaches
* fix(codex): require provenance to skip migration scans and bound ignored launches
Drops the exit-match escape hatch that skipped a scan without route provenance, and ages out ignored reattach launches on the existing 60s/256 lease policy so a lost exit can no longer pin an active launch (and its completion marker) for the process lifetime.
Co-authored-by: Orca <help@stably.ai>
---------
Co-authored-by: Orca <help@stably.ai>
* fix(agent-hooks): refresh existing shared hook scripts when the CLI is no longer detected
A CLI that falls off PATH (moved npm prefix, relocated shim) keeps its user-wide
config invoking Orca's launcher script under ~/.orca/agent-hooks, but the
presence gate skips install() with no removal — freezing the script at whatever
Orca generated last. Anyone in that state kept the pre-#11568 more.com-leaking
.cmd forever, because no launcher script is ever deleted and Windows startup
deliberately skips shell PATH hydration.
Reconcile before gating: every existing shared launcher/statusline script is
rewritten to the current template on each install pass. Creating scripts stays
behind the presence gate — an existing file is proof of a prior install; a
missing one means the gate did its job. Amp and Hermes are deliberately absent:
they write provider-native plugin code with its own install lifecycle, not
shared launchers.
- refreshManagedScriptIfPresent() in installer-utils (no-op unless the file exists)
- refreshManagedScripts() on the 11 launcher-writing services (openclaude via
the shared Claude class)
- reconcile pass in installManagedAgentHooks before presence detection,
filtered by the agents option, best-effort per agent
- coverage gate: a launcher written to ~/.orca/agent-hooks without a matching
refresher entry fails the suite, in both directions
* perf(agent-hooks): refresh launchers off the main thread
* test(agent-hooks): keep refresh mode assertion POSIX-only
Command, cwd, pinned account home, and the thread to resume all come back
out of the record the lease already wrote, never from the client-declared
identity: a caller that could name the thread could point a session at
someone else's conversation.
It refuses rather than guesses — a record pinned to another execution host or
a WSL distro is not this host's to spawn for, a record for another provider or
another account-home variable is not this adapter's to speak for, and a
workspace that no longer resolves surfaces instead of falling back to a
default cwd.
The adapter now writes what Codex streams straight into the session's
journal, so nothing between the child and the durable record has to remember
state: streamed text folds through the coalescing window, lifecycle events
flush it first, and every write is a highest-revision-wins upsert on the
durable key, which is what lets a completed body land on the row its own
deltas opened.
Journal first, observers second — a tap must never see a row the journal has
not been told about. A session with no sink still runs, unjournaled, which is
how the existing adapter tests keep working unchanged.
Starting or resuming the thread moved into its own module on the way past the
adapter's line cap; the check it carries is unchanged, and still refuses a
resume that lands on a different thread, since recording that would make the
durable handle chain lie about what the session proved.
Codex item ids are not stable across resume, so a journal keyed by them
cannot reconcile a recovered thread against a live one: the same message
would arrive under a new id and append a second copy of itself.
The durable key is instead the position the item holds — thread, turn, and
ordinal within that turn, counting message items only. Non-message items get
no ordinal at all rather than a number from a second counter, because a
counter a resumed history cannot reproduce is worse than no key: it looks
reconcilable and reconciles wrongly. Those rows fall back to a live-session
namespace that a recovered journal simply will not contain, which is correct,
since Codex does not persist them either.
Approvals and questions become journal rows the same way, naming the command
the item already announced so the user is not asked to approve an opaque id,
and one row per question rather than one row per request.
Six findings from the review of the structured Codex session, all of them
about a session that is live in the map while the thing it names is not:
- A prompt is addressed by its own approval id, not by the tool item it is
about. A shell bridge re-asks per command under one parent item, and the
second ask used to evict the first, leaving that turn blocked on a request
nobody could answer.
- A subagent runs its own thread over the same connection. Its `turn/started`
no longer satisfies the root thread's waiter, and every event now carries the
thread it actually came from.
- Publication is one step at the end of acquire: the process identity is in
hand, the child is still alive, and the attempt still owns the session. A
failure reaps only its own child, so a replacement already published keeps
running.
- Codex talks before its thread is named. Those events wait in the acquisition
window and are delivered in arrival order once the session is published,
instead of being dropped.
- A broken stdin pipe ends the transport rather than one write: the child is
reaped and the owner learns its lease is worthless.
- A death that reaches us through several listeners reports once, keeping the
first cause rather than the generic exit that follows it.
Implements the structured agent-session adapter for Codex: one long-lived
app-server child per session, started or resumed under the lease the host
already reserved, reporting the process identity the lease re-proves and the
provider handle link the journal binds to.
The thread the adapter reports is the one Codex actually opened, never the one
a client declared, and a resume that lands on a different thread is refused
rather than recorded as a resume. A turn is named from the start response when
Codex sends an id there and from the turn-started notification when it acks
first; when neither names it in time the submission settles unknown, which the
journal renders as delivery unconfirmed rather than failure. Only a refusal
Codex actually answered becomes a rejection.
Model, effort, and policy are per-turn overrides — there is no thread-settings
write — so an option set between turns is applied to the next turn/start, and
unlisted keys are refused instead of becoming arbitrary client-controlled
parameters.
Codex blocks a turn until every approval and tool-input request it sent is
answered, but the answer arrives much later and through a durable journal item
id rather than the live JSON-RPC request id. This holds the live requests for a
session and turns a chosen option back into the reply Codex expects.
Three cases the journal boundary forces: one request can carry several
questions but takes a single reply, so answers are buffered until the request is
complete; a request this build does not model is refused rather than left
blocking or silently approved; and a prompt is forgotten before its reply is
written, so a second answer finds nothing instead of replying twice.
The existing app-server client is request-scoped: one deadline for the whole
session, and every inbound message that is not a pending response is dropped. A
structured chat session needs the opposite — a child that outlives any single
call, per-request deadlines, and both traffic directions, because Codex streams
a turn as notifications and asks for approvals by sending requests back.
Adds a connection that spawns the child, completes the initialize handshake,
and stays open until closed, keeping the same environment overlay and reap
semantics as the short-lived client. A refusal is now distinguishable from a
call that never settled, so callers can tell "Codex declined" from "the child
died" instead of reporting both as failure.
PR 9501 shipped real-home routing for the host system default, and the
env override that could turn it back off was never a shipped control. The
managed-account half of the shared runtime mirror has been unreachable
since: every host account routes to its own self-contained CODEX_HOME
before that code runs.
Delete the flag module and its env plumbing plus the managed branch of
syncForCurrentSelection and the six helpers only it called. The three
lanes that still use the shared mirror -- Windows, a custom CODEX_HOME,
and a hook-lane gate that reports unusable -- are untouched, as are every
legacy migration and the WSL read-back helpers.
* fix(codex): trust extended-length resume paths
* test(codex): cover both sides of extended-length resume folding
The provenance fix folds the extended drive spelling on the rollout path and
the trusted sessions root, and runs per entry in the legacy id scan. Only the
rollout-path side had coverage, so pin the rest:
- normal-form rollout under an extended-length trusted home (root side)
- compressed .zst sibling derived from an extended-length persisted path,
where a folded comparison copy could leak into the returned path
- legacy id-scan entries: extended-length accepted, device namespace refused
All three fail with the production change reverted.
* docs(codex): state accurately what the resume path fold rejects
The helper's comment called the rejected spellings "arbitrary device
namespaces", but \?\UNC\ is the extended-length form of \server\share\ —
a network share, not a device. Reading it as a security decision hides that
UNC is simply not folded yet, so say what is actually excluded.
Also trim the two new test comments to one line to match the file.
* fix(codex): reject rollout alternate data streams
* fix(codex): preserve resume provenance guard
---------
Co-authored-by: OrcaWin <alpha-eng@stably.ai>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
* fix(ai-vault): resume a bridged Codex session under the selected account's home
The account session bridge hardlinks every rollout into each per-account
CODEX_HOME, and vault dedup keeps the lexicographically-smallest alias, so
Resume could pin an inline CODEX_HOME naming a peer account — running the
session under that account's auth.json and quota. At resume time the owning
host now substitutes the selected account's home when it holds the same
rollout at the same sessions-relative path, declining on any uncertainty so
resume degrades to today's behavior instead of failing.
* fix(ai-vault): repin dropped sessions without a cwd instead of resuming under the wrong account
The drag payload only carried sessionCwd when session.cwd was truthy, so a
null-cwd codex session dropped onto a pane silently fell back to the prebuilt
command - which pins the wrong account's CODEX_HOME, the exact defect this PR
eliminates on the other resume surfaces.
- Serializer always sends sessionCwd (null when the session has no cwd), so
absence now only means an older-serializer payload.
- The repin rebuild accepts a null cwd (the builders already omit the cd
prefix), matching the sidebar Resume/Copy paths which repin regardless of cwd.
- An unrepinnable payload (absent sessionCwd) now fails loudly with guidance
instead of silently resuming under the wrong account's home.
* fix(codex): keep a host account switch inside the host lane
markLiveCodexSessionsForRestart walked every tab's PTYs and carded any pane
whose foreground looked like Codex. There was no lane check anywhere in that
path, so a host account switch raised a restart notice on live SSH/relay panes
— and a notice mutes the pane, so the user's remote terminal went deaf.
The notice was provably spurious: a remote spawn carries a connectionId, so
isDaemonHostSpawn is false and no CODEX_HOME is ever injected. The remote Codex
uses the remote machine's own credentials; a local selection cannot reach it.
Scope marking by lane instead. A pane's lane is (machine, runtime): `host`,
`wsl:<distro>`, `env:<id>` for a relay environment, or an SSH connection that
no managed selection can name. A switch made while a runtime environment is
active still cards that environment's panes, which is the case that made the
old "mark everything" behaviour look right.
WSL was the same defect, not a separate one. A Windows run saw a WSL pane
correctly escape a host switch, but only because its foreground read `wsl.exe`,
which fails the Codex-foreground test — the Win32 process table cannot see into
a WSL2 VM. That is incidental: `codex`, `node` and `python3` foregrounds are all
eligible today, so a WSL pane that surfaces one (WSL1 pico-processes are in
Win32_Process) would be carded by a host switch. The lane is now what decides.
Also stop queueing remote and SSH panes into the bind-driven stale sweep at all.
recordCodexPaneAccountForSpawn bails on anything that is not a daemon host
spawn, so listStalePanes can never report one stale, yet each pane still spent
every rung on a 15s-timeout remote RPC — ~75s per pane since the ladder widened
to five rungs.
The lane vocabulary moves to shared/ so the renderer keys panes exactly as a
launch does rather than growing a third copy of the rules.
Refs #10757
* fix(codex): key a WSL pane by the distro its launch actually used
The lane guard derived a pane's WSL distro from the workspace UNC path alone.
A launch does not: pty.ts hands getCodexSelectionTargetForPty a third argument,
the resolved runtime's distro, so a wsl.exe pane on an ordinary Windows-path
worktree launches under `wsl:Ubuntu`. The renderer keyed that same pane
`wsl:__default__`, so the Ubuntu switch never reached it — the pane kept the old
account with no notice, which is #10757 returning by a new route on the exact
platform the issue was reported from.
Resolve the distro the way the spawn does: the project execution runtime first,
then terminalWindowsWslDistro. Both are already in renderer state.
Also match a distro-less WSL switch against the whole `wsl:` family. Two
mutations reach the renderer as `{runtime:'wsl', wslDistro:null}` while writing
concrete distro slots: selecting the system default clears EVERY wsl slot
(setSelectedCodexAccountIdForTarget), and `add` stores the distro it discovered
from the machine. Keying those to `__default__` missed the very panes they
re-pointed. The residual cost is over-marking a sibling distro after an add,
bounded to this machine's WSL panes and far cheaper than a stranded pane.
An owner-less remote pane colliding with the host lane was untested — that
collision is what would mute a working remote terminal, so pin the disjointness
rather than the literal key.
Refs #10757
* fix(codex): resolve a pane's lane the way its launch resolved it
Three more places where the renderer's lane and the launch's lane disagreed.
Each disagreement is silent: too narrow and a stranded pane never gets its
notice (#10757 returns), too wide and a healthy pane is muted, because a notice
makes onData drop every keystroke.
Shell: main runs the request through resolveLocalWindowsTerminalRuntimeOptions,
so an unset shellOverride still lands on WSL when that is the Windows default.
Reading tab.shellOverride alone called such a pane `host` — a host switch would
have muted a working WSL terminal. Gate on the renderer platform, as pty.ts
gates on process.platform.
Cwd: a terminal's startup cwd is deliberately not constrained to the worktree
(resolveTerminalStartupCwd, #7685), and main keys the lane off that cwd. Follow
it through the same shared call instead of reading the workspace root, so a pane
split after `cd \\wsl.localhost\...` is keyed where it actually runs. The comment
claiming a pane can never start outside its workspace was simply wrong.
Family match: narrow the previous commit. setSelectedCodexAccountIdForTarget
only nulls every WSL slot when the account is null AND no distro is named; any
other write lands in one slot. So claim the family only when the change actually
cleared them all, and let `add` pass the created account's concrete target
rather than the row's "WSL default". Both call sites already knew which case
they were in.
Refs #10757
* fix(codex): derive the pane's project runtime the way main does
The previous commit reached for getLocalProjectExecutionRuntimeContext as a
stand-in for main's resolveLocalProjectRuntimeForWorktreeId. They are not the
same function, and the differences both produce wrong lanes:
- It falls back to `state.activeRepoId` when the worktree is not a git worktree,
so a folder-workspace pane inherited whichever repo happened to be selected.
That is not a property of the pane at all — the lane moved when the sidebar
selection moved. On a WSL project it both muted a healthy host pane and hid
the notice a host switch owed it.
- It synthesizes a runtime from `inherit-global` where main returns undefined,
and its host branch rewrites an explicit `wsl.exe` to powershell.exe, keying a
live WSL pane `host`.
Walk repo -> project directly instead, which is what resolveLocalProjectRuntimeForRepo
does, and use it only to supply a distro — never to downgrade a shell. That also
drops the throwing call out of this path entirely; the lane runs outside
scanCodexPanes' inspection guard, so a throw there would have lost the notice
for every pane in the batch, not just one.
Also find the added account by diffing the roster. Reading it back through the
row's active id returns null once two distro slots are filled, which sent the
notice to `wsl:__default__` while `add` had written a concrete distro.
Refs #10757
* fix(codex): key the lane off the runtime the renderer actually shipped
Reverses the project-runtime half of the previous commit. That commit assumed
main resolved the project runtime itself, so it re-derived one by hand. It does
not: for a local pane the RENDERER computes it with
getLocalProjectExecutionRuntimeContext and ships it with the spawn
(pty-connection.ts), and pty.ts feeds that straight to getCodexSelectionTargetForPty.
So the helper is not an approximation to be improved on — it is the launch.
The hand walk dropped the global Windows runtime default, which is what turns an
`inherit-global` project preference into WSL. A user who set their runtime
default to WSL but left terminalWindowsShell alone would have had every live WSL
pane keyed `host`: muted by a host switch, and missed by their own. It also
disagreed on folder workspaces, where the launch really does resolve through the
active repo.
Keep the repair-required early return: that call throws, and it sits outside the
scan's per-pane failure guard, so a throw would lose the notice for every pane in
the batch rather than one.
Separately, floating terminals have no workspace root, so their startup cwd is
used verbatim (resolveTerminalStartupCwdForWorkspace). Resolving one against a
root that does not exist yielded no cwd at all, keying a floating Codex pane on
a WSL filesystem as `host`. Read its cwd directly.
Require exactly one new account before trusting the roster diff — an unloaded
prior roster makes every account look new, and Add Account is not gated on it.
Refs #10757
* fix(codex): stop claiming a floating-terminal cwd the tab never has
The floating-terminal branch read tab.startupCwd, which no floating creation
path ever sets (FloatingTerminalPanel, FloatingTerminalWindowControls,
floating-workspace-tab-creation all pass none). Its cwd is resolved over IPC
from settings.floatingTerminalCwd and handed to the transport as a prop, so it
never reaches the store at all. The branch was inert and its comment described
main's handling of args.cwd rather than what the code read.
Say what is actually true: a floating pane is keyed by its shell, and the
configured-WSL-cwd-under-a-host-shell case is a known gap. Guessing from the
unresolved setting would risk the mute direction, which is the expensive one.
Also pin the repair-required early return. resolveLocalWindowsTerminalRuntimeOptions
throws there, and the lane runs outside scanCodexPanes' per-pane failure guard,
so without it Promise.all rejects and every pane in the batch loses its notice.
That guard had no coverage; removing it now fails with the spawn error.
Refs #10757
* fix(codex): trust the lane main recorded at spawn over a re-derived one
The switch path re-derived each pane's Codex lane from current state while
main had already written the resolved shell, cwd and distro at spawn. Four
review rounds each found another divergence between the two, and the
derivation still answers for a launch that never happened once the user
edits a runtime preference.
Prefer the recorded lane where one exists; keep the derivation for the panes
main never records — pre-feature panes, LocalPtyProvider spawns and remote
ids — and log when the two disagree.
* refactor(codex): drop a redundant guard around the recorded-lane lookup
Closes#10793.
When Orca could not verify the originating Codex session file it either threw — a
red per-pane toast and a failed spawn, reported as constant spam on #10757 — or
returned null. Returning null did NOT start a fresh session: the renderer had
already baked ['codex','resume',<id>] into the command and pty.ts never rewrote
it, so CODEX_HOME simply fell through to whichever account was selected.
The resume argv is now dropped so a plain `codex` launches, with a banner telling
the user. The invariant — never run `codex resume <id>` under an account that does
not own that rollout — is now satisfied by construction rather than by refusing to
spawn. A verified resume is unchanged and still pins CODEX_HOME to the
originating home.
Reviewed over two adversarial rounds; seven defects found and fixed, including a
HIGH where local-provider (non-daemon) spawns still carried
ORCA_SEQUENCED_STARTUP_COMMAND with `resume <id>` — the wrong account behind a
banner claiming it started fresh. `env` is now declared after the strip so no
point in the handler can reach the pre-strip value.
Live-validated in a real Orca dev build: all five cases proven on the SPAWNED
PROCESS, including a real rollout under an untrusted home (the only shape that
discriminates) and the local-provider path forced by stopping the daemon.
An earlier CI failure on multi-client-navigation-isolation.integration.test.ts was
investigated and is a PRE-EXISTING flake — a ~4ms race in the session-tabs notify
coalescer that fails 5-8/24 on clean main, more often than on this branch. Fixed
separately in #11022.
Not verified: no Windows execution — its POSIX-only tests skip there and the
#10757 reporter is on Windows. SSH is partial: no spurious banner or drop observed
against a real target, but headless spawn does not deliver startup commands so the
remote argv could not be read. The relay/mobile notice channel deliberately has no
banner; the argv drop does happen there, so the invariant holds.
Fixes#10757. Switching Codex accounts broke three ways, all rooted in the
self-contained per-account CODEX_HOME from #9501.
HISTORY DISAPPEARED. Codex's own /resume picker only lists rollouts under the
launch CODEX_HOME, and nothing bridged history into a per-account home — only
the AI Vault's discovery scan knew about the other homes. Every other
Orca-visible home's rollouts are now hardlinked in, on selection and again at
launch, so one physical log is listed everywhere.
THE RESTART PANEL STUCK. A queued restart was only drained by a mounted
TerminalPane, but the prompt covered every stale pane in the worktree including
parked and cold-deferred tabs. Requesting a restart now answers the prompt
immediately while the pane keeps its pending restart, and a pane drains it when
its reconnected PTY binds.
PANES STAYED ON THE OLD ACCOUNT. CODEX_HOME is fixed in a shell's environment at
spawn and the daemon keeps those shells alive across app restarts, while the
restart notices are renderer state and are discarded. Each PTY's launch account
is now recorded on disk and compared against the current selection at startup.
Also merged in: #10802 (a dismissed notice no longer kills the pane's keyboard),
#10803 (the sweep arms on real PTY binds, and launcher Codex panes are no longer
filtered out by Windows deepest-process reporting), #10804 (a resume-pinned pane
now says which account it is on), #10870 (the restart card no longer parks focus
on its destructive Restart button), #10853 (the retry ladder is widened past the
Windows worst case).
Six independent reviews found real defects in every original PR, several of them
dead-keyboard bugs and three introduced by the fix for another defect in the same
loop. Live QA on macOS covered every PR; Windows was validated three times.
WINDOWS: pass 1 found two defects that made the stale-account fix a no-op there
(the sweep fired before any PTY was bound and never retried; launcher panes were
filtered out). Pass 3 at the merged head: the prompt appears on its own after a
restart — warm ~3.7-4.2s, cold ~21s needing rung 4, so #10853's widening was
load-bearing rather than precautionary; an ordinary sentence typed into a healthy
pane while another pane's card is up reaches that pane and kills nothing; a pane
running vim after exiting Codex gets no card, still none 45s later. auth.json
byte-identical across every pass.
KNOWN GAPS, stated rather than implied: #10804 is unverified on Windows
(auto-resume could not be manufactured there); cross-volume Windows is untested
and expected to yield no bridged history (EXDEV, and Codex ignores symlinked
rollouts); a cold-parked pane never binds so the sweep never covers it; the
subagent-deepest launcher shape could not be reproduced on Windows, so that
branch is fixture-verified only; WSL passed isolation but the resume mechanism is
host-lane only. A host-account switch also marks and mutes live SSH remote panes
— confirmed pre-existing on main by two independent QA runs — tracked separately
in #10992. Related pre-existing defect filed as #10863.
Reviewed with an independent reproduction on macOS and Ubuntu 20.04. Fixed five test files that asserted the old literal strings, and switched the reader to `command -p cat` so it also survives hosts without /bin/cat.
The legacy Codex session-id rescan (used when a persisted record has no
transcriptPath) returned the FIRST trusted home holding a rollout with that id.
That home becomes the pane's CODEX_HOME — i.e. it picks the ACCOUNT — and the
list ended in per-account homes ordered by settings INSERTION order, so the
account was decided by whichever one the user happened to add first.
Ranks instead: selected account -> real system home -> shared runtime mirror ->
everything else by normalized path. Both ranking inputs are required (an
optional one would silently degrade to pure path order), and the selection
arrives as a thunk so the common provenance-present resume never stats the
ownership marker for a ranking it never runs.
The mirror needs its own tier: prepareLegacySharedCodexSessionResume's guard
only fires when the resolved home IS the mirror, and that guard is what
migrates the rollout into ~/.codex. Without it a system-default selection
silently resumed under an arbitrary account and stayed pinned there permanently
once the hook stamped a transcript path.
Reviewed over two independent rounds; every tier individually mutation-proved.
Live-validated in a real Orca dev build by reading the spawned PTY's actual
CODEX_HOME across three builds (head, base, and head-minus-the-mirror-tier).
Note: fixes none of #10757's user-visible symptoms on its own — it is a
correctness precondition. Verified on macOS only; Windows coverage is
fixture-only.
When a pane is mislabeled agent:codex but still holds a Claude
transcriptPath, the Codex resume guard threw and blocked relaunch.
Only hard-fail when the path claims Codex's dated rollout layout
(sessions/YYYY/MM/DD/rollout-*.jsonl), under any home and without
requiring the file to exist. Paths that never claimed Codex provenance
return null so the pane can relaunch.
Keying on rollout shape rather than trusted-home membership matters:
returning null only declines to override CODEX_HOME, and the renderer
has already baked 'codex resume <id>' into the command. A real rollout
under an untrusted home would otherwise resume under whichever account
is selected — and once the session bridge hardlinks rollouts across
homes, codex would find that id and resume silently under the wrong
account. Trust is decided upstream by findTrustedCodexSessionResume.
Co-authored-by: Wooseong Kim <innocarpe@users.noreply.github.com>
Closes#10517
* feat(codex): surface a stalled config sync instead of failing silently
Why: the mirror keeps serving the last synced settings when ~/.codex/config.toml
is missing, blank, or unreadable. That is the right call for data safety, but it
is invisible — a downed WSL distro or an unhydrated cloud-synced home leaves
"Orca ignores my config edits" with no log line and no UI to diagnose.
Status is derived on demand from the same predicates the mirror uses, so the two
cannot disagree. The stall is logged once per episode rather than on every launch
and quota poll, and the Codex account section names the file and what to do.
* fix(codex): latch an unreadable source and stop over-claiming recovery
An unreadable source throws out of the mirror, so reporting only on the success
path left that stall latch-less: it logged the raw failure on every launch and
quota poll while its reason never reached the surfaced status. Report from the
catch path too.
The clear message also claimed the source was "readable again", which is false
when the stall ended because the runtime config was removed rather than because
the source came back.
Restoring console.warn now happens in afterEach — an inline mockRestore is
skipped by a failing assertion, and the leaked spy made every later case in the
block fail spuriously.
* fix(codex): latch the stall promotion hits first, and scope it to the host
Review round 1 findings:
- The unreadable-source latch still never fired in the steady state. Once a
baseline exists, promotion reads the source before the mirror does, so it
throws first and `!promotionPlan` returned before any reporting — logging a
reasonless failure every launch and quota poll, which is exactly what the
previous commit claimed to fix. Report from that branch too. The test only
passed because its fixture had no baseline; it now seeds one first and fails
without the fix.
- The banner named the host's ~/.codex while a WSL or per-account runtime was
selected, whose real source is a different file entirely. Gate it to the host
scope, matching how the sign-in warning is already gated.
- Three new translate keys were missing from the locale catalogs, failing the
localization gate in `pnpm lint`.
- The registrar mock was never asserted, so deleting the registration left the
suite green.
- `codexConfigSyncStatus` hung off the `agentHooks` namespace despite having
nothing to do with agent hooks; moved to its own `codexConfigSync.status`
while it is still a four-file change.
* fix(codex): report sync health for the home the selection actually mirrors
Review round 2:
- The status resolved the shared runtime home, but the system default now runs
Codex directly against ~/.codex and managed accounts get their own home. So a
stalled per-account mirror showed no banner at all, while a stale shared home
could warn about a config the active lane never reads. Resolve the mirrored
home from the current selection, and report synced when the lane has no mirror
to fall behind.
- The round-1 report on the promotion failure path could clear the latch on a
pass where no mirror ran, claiming a recovery that never happened and
silencing every later pass. Only ever latch a stall there; leave clearing to
the path that actually mirrored.
* fix(codex): refetch sync status when the active Codex account changes
Review round 3:
- Resolving the status per selection made the fetch account-dependent, but the
effect was not keyed on the active account. Switching accounts left the banner
describing the previous one — and switching INTO a stalled account showed
nothing at all, which is the silence this change exists to remove.
- Pin the home resolution itself: it had no direct test, and its shared-home
path was a hand-copied literal that could drift from the real helper and
silence the banner with every other test still green.
- Narrow the handler's dependency to the one method it calls, which also drops
an `as unknown as` cast from its test.
- Skip the chmod-based test on Windows, where a read-only directory does not
block writes so the scenario cannot be constructed; matches the convention
already used in config-settings-promotion.test.ts.
* chore(codex): restore the handler docstring and isolate the resolver suite
Round 4 returned clean; these are its two non-blocking nits.
Narrowing the handler param left its JSDoc stranded above the new type, so the
function had no hover doc. The resolver suite also read the developer's real
CODEX_HOME and shell rc, so anyone exporting one would see it fail locally.
* fix(codex): preserve runtime config without system source
* fix(codex): retain baseline when mirror is skipped
* refactor(codex): extract deprecated hook-flag normalization
Why: codex-config-mirror.ts sat at the 300-line cap, so the missing-source
guard could not land without a max-lines disable.
* fix(codex): bootstrap a baseline when the mirror is skipped
Why: a runtime home seeded outside the mirror (WSL, per-account) never got a
baseline while the source was missing, so promotion stayed inert and silently
reverted the in-Codex change once the source returned.
* fix(codex): stop a synthesized source config from wiping runtime settings
Two routes still reached the #9073 data loss after the missing-source guard:
- Promotion runs before the guard and, with no ~/.codex/config.toml, created
one holding only the promoted keys. The next mirror treated that skeleton as
authoritative and deleted every other runtime setting. It needs no missing
file: `codex mcp add` inside an Orca-launched Codex plus /model was enough to
drop the MCP server for good. Promotion now seeds a brand-new system config
from the runtime's ordinary settings, so the mirror round-trips them.
- A 0-byte source (half-written, or an unhydrated cloud-synced home) still read
as an authoritative empty config and advanced the baseline, making the loss
unrecoverable. A blank source is now treated like a missing one.
Moves the TOML section model out of codex-config-mirror.ts so promotion can
share it without a cycle.
---------
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
* fix(codex): promote [tui] settings so they survive the managed-home remirror
Codex TUI preferences (/statusline, theme, terminal title) are written into
the [tui] table of the managed runtime config.toml, but the write-back
promotion allowlist only covered four top-level scalars — so the next mirror
pass rewrote the runtime config from ~/.codex and silently discarded them.
Extend promotion to the [tui] keys the Codex TUI persists (status_line,
status_line_use_colors, terminal_title, theme), keyed as structured tui.*
paths so the same three-way merge (runtime vs baseline vs ~/.codex) applies:
in-Codex changes promote into ~/.codex before the mirror, and outside edits
to ~/.codex still win over stale runtime values.
The byte-preserving upsert moves to codex-config-settings-upsert.ts (max-lines)
and learns [tui] placement: replace an existing bare or dotted key in place,
insert into the first [tui] body, insert dotted beside existing dotted tui.*
keys, or create one [tui] table at EOF — never defining tui twice, including
when the system config holds an inline tui = {...} table.
* Add codex-config-settings-upsert to the CLI tsconfig file list
* fix(codex): keep tui upserts out of array tables
* fix(codex): handle quoted tui config paths during promotion
* fix(codex): harden tui promotion writes
* fix(codex): count per-account homes in usage and state removal blast radius
- usage scanner now includes codex-accounts/*/home/sessions so multi-account
usage is no longer silently undercounted (audit F2)
- account-removal dialog copy now states that session history and MCP logins
are permanently deleted with the managed home (audit F1 mitigation)
* fix(codex): harden account usage discovery