mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 00:02:39 +00:00
* 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>
165 lines
6.0 KiB
TypeScript
165 lines
6.0 KiB
TypeScript
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { createServer, type Server } from 'node:net'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const { readWindowsProcessCreationTime } = vi.hoisted(() => ({
|
|
readWindowsProcessCreationTime: vi.fn<(pid: number) => number | null>()
|
|
}))
|
|
vi.mock('../windows/windows-process-table', () => ({ readWindowsProcessCreationTime }))
|
|
|
|
import { runProcess } from '../../shared/child-process/run-process'
|
|
import { probeCodexSharedServer } from './codex-shared-server-probe'
|
|
|
|
const originalPlatform = process.platform
|
|
let home: string
|
|
let server: Server | null = null
|
|
|
|
function setPlatform(platform: NodeJS.Platform): void {
|
|
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
|
|
}
|
|
|
|
function listen(): Promise<void> {
|
|
mkdirSync(join(home, 'app-server-control'), { recursive: true })
|
|
const listening = createServer((socket) => socket.destroy())
|
|
server = listening
|
|
return new Promise((resolve) =>
|
|
listening.listen(join(home, 'app-server-control', 'app-server-control.sock'), resolve)
|
|
)
|
|
}
|
|
|
|
function close(): Promise<void> {
|
|
const listening = server
|
|
server = null
|
|
return new Promise((resolve) => (listening ? listening.close(() => resolve()) : resolve()))
|
|
}
|
|
|
|
beforeEach(() => {
|
|
// Why /tmp: a unix socket path must fit sun_path, which a macOS $TMPDIR can exceed.
|
|
home = mkdtempSync(join(process.platform === 'win32' ? tmpdir() : '/tmp', 'cxh-'))
|
|
readWindowsProcessCreationTime.mockReset()
|
|
})
|
|
|
|
function errnoError(code: string): NodeJS.ErrnoException {
|
|
return Object.assign(new Error(code), { code })
|
|
}
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks()
|
|
setPlatform(originalPlatform)
|
|
await close()
|
|
rmSync(home, { recursive: true, force: true })
|
|
})
|
|
|
|
describe.skipIf(process.platform === 'win32')('probeCodexSharedServer on POSIX', () => {
|
|
beforeEach(() => setPlatform('darwin'))
|
|
|
|
it('is live while the control socket accepts connections', async () => {
|
|
await listen()
|
|
await expect(probeCodexSharedServer(home)).resolves.toBe('live')
|
|
})
|
|
|
|
it('is absent when no socket exists', async () => {
|
|
await expect(probeCodexSharedServer(home)).resolves.toBe('absent')
|
|
})
|
|
|
|
it('is absent when a crashed server left its socket file behind', async () => {
|
|
const socketPath = join(home, 'app-server-control', 'app-server-control.sock')
|
|
mkdirSync(join(home, 'app-server-control'), { recursive: true })
|
|
// Why a killed child: only a crash leaves the socket inode with nobody listening.
|
|
await runProcess({
|
|
program: process.execPath,
|
|
args: [
|
|
'-e',
|
|
`require('node:net').createServer().listen(${JSON.stringify(socketPath)}, () => process.kill(process.pid, 'SIGKILL'))`
|
|
],
|
|
timeoutMs: 10_000
|
|
})
|
|
expect(existsSync(socketPath)).toBe(true)
|
|
await expect(probeCodexSharedServer(home)).resolves.toBe('absent')
|
|
})
|
|
|
|
it('is unknown when the socket path cannot be connected to for another reason', async () => {
|
|
// Why a file where the directory goes: connect fails with ENOTDIR, which proves nothing.
|
|
writeFileSync(join(home, 'app-server-control'), '')
|
|
await expect(probeCodexSharedServer(home)).resolves.toBe('unknown')
|
|
})
|
|
})
|
|
|
|
describe('probeCodexSharedServer on Windows', () => {
|
|
const START_FILETIME = '134352704749372843'
|
|
const START_UNIX_MS = 1_790_796_874_937
|
|
|
|
beforeEach(() => {
|
|
setPlatform('win32')
|
|
vi.spyOn(process, 'kill').mockReturnValue(true)
|
|
})
|
|
|
|
function writeRecord(name: string, record: unknown): void {
|
|
mkdirSync(join(home, 'app-server-daemon'), { recursive: true })
|
|
writeFileSync(
|
|
join(home, 'app-server-daemon', name),
|
|
typeof record === 'string' ? record : JSON.stringify(record)
|
|
)
|
|
}
|
|
|
|
it('is live when the recorded pid still has the recorded creation time', async () => {
|
|
writeRecord('daemon.pid', { pid: 27368, processStartTime: START_FILETIME })
|
|
readWindowsProcessCreationTime.mockReturnValue(START_UNIX_MS)
|
|
await expect(probeCodexSharedServer(home)).resolves.toBe('live')
|
|
expect(readWindowsProcessCreationTime).toHaveBeenCalledWith(27368)
|
|
})
|
|
|
|
it('reads the legacy record name', async () => {
|
|
writeRecord('app-server.pid', { pid: 27368, processStartTime: START_FILETIME })
|
|
readWindowsProcessCreationTime.mockReturnValue(START_UNIX_MS)
|
|
await expect(probeCodexSharedServer(home)).resolves.toBe('live')
|
|
})
|
|
|
|
it('is absent when no record exists', async () => {
|
|
await expect(probeCodexSharedServer(home)).resolves.toBe('absent')
|
|
})
|
|
|
|
it('is absent when the recorded pid is not running', async () => {
|
|
writeRecord('daemon.pid', { pid: 27368, processStartTime: START_FILETIME })
|
|
vi.mocked(process.kill).mockImplementation(() => {
|
|
throw errnoError('ESRCH')
|
|
})
|
|
await expect(probeCodexSharedServer(home)).resolves.toBe('absent')
|
|
expect(readWindowsProcessCreationTime).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('is absent when the pid was reused by a later process', async () => {
|
|
writeRecord('daemon.pid', { pid: 27368, processStartTime: START_FILETIME })
|
|
readWindowsProcessCreationTime.mockReturnValue(START_UNIX_MS + 60_000)
|
|
await expect(probeCodexSharedServer(home)).resolves.toBe('absent')
|
|
})
|
|
|
|
it.each([
|
|
[
|
|
'access to the pid is denied',
|
|
() =>
|
|
vi.mocked(process.kill).mockImplementation(() => {
|
|
throw errnoError('EPERM')
|
|
})
|
|
],
|
|
[
|
|
'the creation time cannot be read',
|
|
() => readWindowsProcessCreationTime.mockReturnValue(null)
|
|
],
|
|
[
|
|
'the record holds no parseable start time',
|
|
() => {
|
|
readWindowsProcessCreationTime.mockReturnValue(START_UNIX_MS)
|
|
writeRecord('daemon.pid', { pid: 27368, processStartTime: 'Wed Sep 30 15:33:16 2026' })
|
|
}
|
|
],
|
|
['the record is not JSON', () => writeRecord('daemon.pid', '{')]
|
|
])('is unknown when %s', async (_label, arrange) => {
|
|
writeRecord('daemon.pid', { pid: 27368, processStartTime: START_FILETIME })
|
|
arrange()
|
|
await expect(probeCodexSharedServer(home)).resolves.toBe('unknown')
|
|
})
|
|
})
|