Files
orca/src/main/codex/codex-shared-server-probe.ts
T
Jinwoo HongandClaude Opus 5.5 eefc49f7e3 feat(terminal): warn when a typed Codex joins Codex's shared server (STA-9051) (#24217)
* feat(terminal): warn when a typed Codex joins Codex's shared server

Orca adds --no-daemon to the Codex it launches and to a codex typed in
shells whose wrapper it controls, but a codex typed another way (fish,
cmd.exe, a path-named binary) still joins Codex's shared server, which
mixes up agent status across tabs.

When a local pane's Codex is on that server, show a banner at the top of
the pane with the command that turns auto-start off, a Copy button,
"Don't show again" (a new setting next to the Codex server setting) and
a per-pane dismiss. The banner takes layout space; the terminal refits
below it.

Main answers pty:isCodexOnSharedServer from the pane's outermost Codex
command line (flags and subcommands that keep Codex embedded rule it
out), the CODEX_HOME the pane launched with, and whether that home's
server is live: a socket connect on macOS/Linux, the server's pid record
plus creation time on Windows. The renderer asks only while the pane
already shows Codex, on a short bounded ladder.

Refs STA-9051

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: restore the Claude WSL trust-file fix (#23973) dropped by the banner commit

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(terminal): redesign the Codex shared-server banner and fix dialog

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(terminal): let the Codex shared-server fix run its commands

The fix dialog now runs each step with the shared server's own Codex on the
pane's CODEX_HOME, verifies the result (feature read back, server probed),
and falls back to a copyable command on failure. Stopping asks first.

Also: an apostrophe in a prompt no longer hides an opt-out flag, restored
panes fall back to the saved pty id, and the IPC guards have a table test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(terminal): give each fix step its own card and label the command it runs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor(terminal): simplify the Codex shared-server banner after review

- Read a subcommand only from Codex's first positional, so prompt words
  like "a", "update" or "review" no longer hide the banner.
- Probe the server fresh on every ask; drop the probe cache.
- Make the pty preload methods required and stub them on the web client,
  replacing the optional-method and paired-client checks.
- Render the banner from the existing Codex pane portal loop.
- Treat a non-zero or timed-out Codex command as failed; skip the
  read-back when the disable write failed.
- Reserve the banner's space with a CSS :has() selector instead of a
  data attribute.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(terminal): make the Codex shared-server fix persist on Orca's mirror home

- Step 1 now writes daemon_auto_start = false to the user's own Codex home
  when the pane runs on Orca's shared mirror home (as Windows panes do), then
  to the mirror home too; the mirror is rebuilt from the user's home on every
  launch, so a mirror-only write was lost.
- The server probe is three-state (live / absent / unknown); stop reports
  success only once the server is proven gone.
- The banner retires the one-time "runs Codex without its shared server"
  toast it contradicts.
- A command line with no Codex program never counts as joining the server.
- The fallback local PTY provider reports each pane's root pid.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(codex): keep Turn off in Orca's Codex home when ~/.codex has no config

A pane on Orca's mirror home wrote the setting to ~/.codex first. With no
~/.codex the spawn failed on its cwd and Codex rejects a missing CODEX_HOME;
and creating a config holding only this setting would make the next mirror
replace every setting made in Orca's Codex. The mirror skips a missing or
blank ~/.codex/config.toml, so write only the mirror home then.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(codex): promote [features].daemon_auto_start from Orca's Codex home

Promotion now carries one [features] key alongside the [tui] keys, so a
shared-server Turn off written in Orca's mirror home reaches
~/.codex/config.toml instead of being reverted by the next mirror. Table
keys share one <table>.<key> scan for read, removal and upsert. Orca's own
daemon socket override is never read as a user value, and a blank source
config is seeded from the runtime like a missing one.

* refactor(codex): run Turn off once, in the pane's own Codex home

Settings promotion now carries the setting to ~/.codex, so the separate
settings-home resolution and the two-home loop are gone.

* fix(terminal): offer Stop server only after sharing is turned off

Stopping while sharing is still on closes every sharing session, and the
next Codex starts a new shared server.

* fix(terminal): skip legacy mirror panes off Windows and quoted dotted keys

A retained shared-home pane on macOS/Linux points at a mirror that is no
longer promoted, so Turn off there would be reverted; name no home for it.
A quoted top-level key such as "tui.theme" is one key, not [tui].theme.

* fix(terminal): drop the Turn off note that promised the setting reaches Codex outside Orca

Orca's tabs are what this fix is for; carrying the setting to ~/.codex is best-effort.

* fix(terminal): keep the Turn off note that the setting also applies outside Orca

It holds for nearly everyone; the rare Windows upgrade gaps don't justify hiding it.

* fix(codex): promote Turn off to ~/.codex under an older Orca's baseline

A pane on Orca's Windows mirror home writes daemon_auto_start = false into
the mirror. A promotion baseline from an Orca that predates this key has no
entry for it, so the next mirror pass kept the write as a conflict and then
recorded it, and ~/.codex never got the setting.

Turn off on a mirror-home pane now runs the same mirror pass a terminal
launch runs before and after the write: the first records the key in the
baseline, the second promotes the write to ~/.codex. The passes are
synchronous, so they cannot interleave with a launch's pass. A failed pass is
logged and does not fail Turn off, since the write still fixes Orca's tabs.
Real-home panes are unchanged.

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 18:50:43 -04:00

100 lines
3.5 KiB
TypeScript

import { readFile } from 'node:fs/promises'
import { createConnection } from 'node:net'
import { join } from 'node:path'
import { codexDaemonSocketPath } from './codex-daemon-socket-path-guard'
import { readWindowsProcessCreationTime } from '../windows/windows-process-table'
/** `absent` only on proof; anything the probe cannot settle is `unknown`. */
export type CodexSharedServerState = 'live' | 'absent' | 'unknown'
const CONNECT_TIMEOUT_MS = 1_000
// Codex's FILETIME epoch (1601) sits this far before the Unix epoch.
const FILETIME_UNIX_EPOCH_OFFSET_MS = 11_644_473_600_000n
function errorCode(error: unknown): unknown {
return typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined
}
// Why connect, not stat: a server that crashed leaves its socket file behind.
function probeSocket(socketPath: string): Promise<CodexSharedServerState> {
return new Promise((resolve) => {
const socket = createConnection({ path: socketPath })
const settle = (state: CodexSharedServerState): void => {
socket.destroy()
resolve(state)
}
socket.setTimeout(CONNECT_TIMEOUT_MS, () => settle('unknown'))
socket.once('connect', () => settle('live'))
socket.once('error', (error) => {
const code = errorCode(error)
settle(code === 'ECONNREFUSED' || code === 'ENOENT' ? 'absent' : 'unknown')
})
})
}
async function probeWindowsRecord(path: string): Promise<CodexSharedServerState> {
let text: string
try {
text = await readFile(path, 'utf8')
} catch (error) {
return errorCode(error) === 'ENOENT' ? 'absent' : 'unknown'
}
let record: unknown
try {
record = JSON.parse(text)
} catch {
return 'unknown'
}
if (
typeof record !== 'object' ||
record === null ||
!('pid' in record) ||
typeof record.pid !== 'number' ||
!Number.isSafeInteger(record.pid) ||
record.pid <= 0
) {
return 'unknown'
}
try {
process.kill(record.pid, 0)
} catch (error) {
// Why: only ESRCH proves the process is gone; EPERM means it exists.
return errorCode(error) === 'ESRCH' ? 'absent' : 'unknown'
}
const createdAtMs = readWindowsProcessCreationTime(record.pid)
if (
createdAtMs === null ||
!('processStartTime' in record) ||
typeof record.processStartTime !== 'string' ||
!/^\d+$/.test(record.processStartTime)
) {
return 'unknown'
}
const startedAtMs = Number(
BigInt(record.processStartTime) / 10_000n - FILETIME_UNIX_EPOCH_OFFSET_MS
)
// Why: a running pid with another creation time was reused after the server exited.
return Math.abs(createdAtMs - startedAtMs) < 1_000 ? 'live' : 'absent'
}
/**
* Node cannot open Codex's AF_UNIX socket on Windows (libuv only speaks named
* pipes), so read the server's pid record instead and require the live process
* with that pid to have the recorded creation time, which rules out pid reuse.
*/
async function probeWindowsRecords(codexHome: string): Promise<CodexSharedServerState> {
const states = await Promise.all(
['daemon.pid', 'app-server.pid'].map((name) =>
probeWindowsRecord(join(codexHome, 'app-server-daemon', name))
)
)
return states.includes('live') ? 'live' : states.includes('unknown') ? 'unknown' : 'absent'
}
/** Whether Codex's shared server for this CODEX_HOME is accepting clients right now. */
export function probeCodexSharedServer(codexHome: string): Promise<CodexSharedServerState> {
return process.platform === 'win32'
? probeWindowsRecords(codexHome)
: probeSocket(codexDaemonSocketPath(codexHome))
}