Merge remote-tracking branch 'origin/brennanb2025/codex-structured-core' into brennanb2025/structured-remove-chat-button

This commit is contained in:
Brennan Benson
2026-08-23 14:10:34 -07:00
6 changed files with 48 additions and 38 deletions
@@ -1,7 +1,7 @@
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import type { spawn } from 'node:child_process'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { spawnProcess } from '../../shared/child-process/run-process'
import {
isCodexAppServerRequestError,
openCodexAppServerConnection,
@@ -76,7 +76,7 @@ type StubChild = EventEmitter & {
/** Full control over framing and death, which a real child cannot give. */
function stubChild(options: { exitOnStdinEnd?: boolean } = {}): {
child: StubChild
spawnImpl: typeof spawn
spawnImpl: typeof spawnProcess
written: Record<string, unknown>[]
} {
const child = new EventEmitter() as StubChild
@@ -96,7 +96,7 @@ function stubChild(options: { exitOnStdinEnd?: boolean } = {}): {
if (options.exitOnStdinEnd !== false) {
child.stdin.on('finish', () => child.emit('exit', 0, null))
}
return { child, spawnImpl: (() => child) as unknown as typeof spawn, written }
return { child, spawnImpl: (() => child) as unknown as typeof spawnProcess, written }
}
/** Answers the handshake so `openCodexAppServerConnection` can resolve. */
@@ -1,4 +1,4 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { spawnProcess } from '../../shared/child-process/run-process'
import { buildCodexAppServerExitError } from './codex-app-server-exit-error'
import { isAppServerRecord, parseCodexAppServerJsonLine } from './codex-app-server-jsonl'
import { waitForProcessExitUntil } from './codex-process-exit-deadline'
@@ -70,17 +70,18 @@ type PendingRequest = {
export async function openCodexAppServerConnection(
launch: CodexAppServerLaunch,
handlers: CodexAppServerConnectionHandlers = {},
spawnImpl: typeof spawn = spawn
spawnImpl: typeof spawnProcess = spawnProcess
): Promise<CodexAppServerConnection> {
const childEnv: NodeJS.ProcessEnv = { ...process.env, ...launch.env }
for (const key of launch.envToDelete ?? []) {
delete childEnv[key]
}
const child = spawnImpl(launch.command, launch.args, {
const child = spawnImpl({
program: launch.command,
args: launch.args,
env: childEnv,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
}) as ChildProcessWithoutNullStreams
cwd: process.cwd()
})
const pending = new Map<number, PendingRequest>()
let stderrTail = ''
@@ -6,12 +6,13 @@
// workspace that is not a repository at all. Keying by id rather than by path
// makes a worktree, a folder workspace, a WSL distro, and an SSH host identical.
//
// `app` is imported lazily so the store stays usable from tests and from the
// headless/SSH runtime, which have no Electron app object.
// The host environment port supplies the root so desktop Electron and the
// headless/SSH runtime resolve their own durable state directories.
import { createHash } from 'node:crypto'
import { join } from 'node:path'
import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types'
import { getAppEnvironment } from '../../../shared/app-environment'
const JOURNAL_DIR_NAME = 'agent-session-journal'
@@ -35,9 +36,7 @@ export function journalDirectoryFor(
)
}
/** Default host state root. Electron is required lazily so importing this
* module from a non-Electron runtime does not pull the whole app in. */
export async function defaultJournalRoot(): Promise<string> {
const { app } = await import('electron')
return app.getPath('userData')
/** Default host state root. */
export function defaultJournalRoot(): Promise<string> {
return Promise.resolve(getAppEnvironment().getPath('userData'))
}
@@ -8,16 +8,13 @@
* closed to manual recovery rather than minting a second writer.
*/
import { execFile } from 'node:child_process'
import { readFile } from 'node:fs/promises'
import { promisify } from 'node:util'
import type {
AgentSessionIdentityMatchField,
AgentSessionOwnerProbe
} from '../../shared/agent-session-lease-adjudication'
import type { AgentSessionProcessIdentity } from '../../shared/agent-session-record'
const execFileAsync = promisify(execFile)
import { runProcess } from '../../shared/child-process/run-process'
/** Start times drift by scheduler granularity and clock reads; compare with a tolerance. */
export const PROCESS_START_TIME_TOLERANCE_MS = 2_000
@@ -64,10 +61,15 @@ async function readLinuxProcessStartTimeMs(pid: number): Promise<number | null>
async function readDarwinProcessStartTimeMs(pid: number): Promise<number | null> {
try {
const { stdout } = await execFileAsync('ps', ['-o', 'lstart=', '-p', String(pid)], {
timeout: PROCESS_START_TIME_TIMEOUT_MS
const result = await runProcess({
program: 'ps',
args: ['-o', 'lstart=', '-p', String(pid)],
timeoutMs: PROCESS_START_TIME_TIMEOUT_MS
})
const parsed = Date.parse(stdout.trim())
if (result.timedOut || result.code !== 0) {
return null
}
const parsed = Date.parse(result.stdout.trim())
return Number.isFinite(parsed) ? parsed : null
} catch {
return null
@@ -79,12 +81,15 @@ async function readWindowsProcessStartTimeMs(pid: number): Promise<number | null
const script =
`$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; ` +
'if ($p) { $p.CreationDate.ToUniversalTime().ToString("o") }'
const { stdout } = await execFileAsync(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', script],
{ timeout: PROCESS_START_TIME_TIMEOUT_MS, windowsHide: true }
)
const parsed = Date.parse(stdout.trim())
const result = await runProcess({
program: 'powershell.exe',
args: ['-NoProfile', '-NonInteractive', '-Command', script],
timeoutMs: PROCESS_START_TIME_TIMEOUT_MS
})
if (result.timedOut || result.code !== 0) {
return null
}
const parsed = Date.parse(result.stdout.trim())
return Number.isFinite(parsed) ? parsed : null
} catch {
return null
+7 -7
View File
@@ -1,5 +1,5 @@
import { spawn } from 'node:child_process'
import { win32 as pathWin32 } from 'node:path'
import { spawnProcess } from '../../shared/child-process/run-process'
import { resolveWindowsShellStartupFamily } from '../../shared/windows-terminal-shell'
import {
resolveProfileLoadingFallbackShell,
@@ -86,12 +86,7 @@ function spawnShellAndReadEnvironment(shell: string): Promise<NodeJS.ProcessEnv
return new Promise((resolve) => {
let settled = false
const chunks: Buffer[] = []
const child = spawn(shell, args, {
env: process.env,
stdio: ['ignore', 'pipe', 'ignore'],
detached: false,
windowsHide: true
})
const child = spawnProcess({ program: shell, args, env: process.env })
const finish = (value: NodeJS.ProcessEnv | null): void => {
if (settled) {
return
@@ -108,6 +103,11 @@ function spawnShellAndReadEnvironment(shell: string): Promise<NodeJS.ProcessEnv
}
finish(null)
}, SPAWN_TIMEOUT_MS)
child.stdin.on('error', () => {})
child.stdout.on('error', () => finish(null))
child.stderr.on('error', () => {})
child.stdin.end()
child.stderr.resume()
child.stdout.on('data', (chunk: Buffer) => chunks.push(chunk))
child.on('error', () => finish(null))
child.on('close', () => {
+7 -2
View File
@@ -2,6 +2,7 @@ import {
spawn as nodeSpawn,
spawnSync as nodeSpawnSync,
type ChildProcess,
type ChildProcessWithoutNullStreams,
type SpawnOptions as NodeSpawnOptions
} from 'node:child_process'
import { buildWindowsCmdShimCommandLine, isCmdInterpretedProgram } from './windows-command-line'
@@ -116,9 +117,13 @@ export function resolveSpawn(spec: ProcessSpec, platform: NodeJS.Platform): Reso
* `runProcess` handles that for you; here it cannot, because a blanket handler
* would also defeat callers that track and remove their own listeners.
*/
export function spawnProcess(spec: ProcessSpec): ChildProcess {
export function spawnProcess(spec: ProcessSpec): ChildProcessWithoutNullStreams {
const resolved = resolveSpawn(spec, process.platform)
return nodeSpawn(resolved.file, [...resolved.args], resolved.options)
return nodeSpawn(
resolved.file,
[...resolved.args],
resolved.options
) as ChildProcessWithoutNullStreams
}
/**