feat(codex): import and resume sessions from runners (#1088)

* fix codex import resume flow

* fix hub restart session active state

* fix codex transcript workspace scoping

* Address Codex import review findings

* Fix Codex import machine selection

* Update Codex sessions error test

* Address Codex import review findings

* Preserve forked Codex session id on sync

* Make Codex duplicate cleanup source-aware

* Handle Codex archive failures

* Limit existing session flag to Codex

* Preserve Codex import machine binding

* fix: rebase runner Codex import onto current main

* fix: preserve runner-scoped Codex import behavior

---------

Co-authored-by: syy <815728149@qq.com>
This commit is contained in:
NightWatcher314
2026-07-19 14:14:42 +08:00
committed by GitHub
co-authored by syy
parent 651c83a1d9
commit 64834467e3
42 changed files with 2536 additions and 133 deletions
+93 -2
View File
@@ -1,4 +1,5 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { execFileSync, spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { existsSync } from 'node:fs';
import { logger } from '@/ui/logger';
import { JsonLineParser } from '@/utils/jsonLineParser';
import { killProcessByChildProcess } from '@/utils/process';
@@ -12,6 +13,8 @@ import type {
ThreadStartResponse,
ThreadResumeParams,
ThreadResumeResponse,
ThreadForkParams,
ThreadForkResponse,
TurnStartParams,
TurnStartResponse,
TurnInterruptParams,
@@ -72,6 +75,84 @@ function createAbortError(): Error {
return error;
}
type CodexCommandCandidate = {
command: string;
source: 'desktop' | 'path';
version: number[] | null;
};
function parseCodexVersion(output: string): number[] | null {
const match = /(\d+)\.(\d+)\.(\d+)(?:[-+][^\s]+)?/u.exec(output);
if (!match) return null;
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
function getCodexVersion(command: string): number[] | null {
try {
const output = execFileSync(command, ['--version'], {
encoding: 'utf8',
timeout: 3_000,
stdio: ['ignore', 'pipe', 'ignore']
});
return parseCodexVersion(output);
} catch {
return null;
}
}
function compareVersion(a: number[] | null, b: number[] | null): number {
if (!a && !b) return 0;
if (a && !b) return 1;
if (!a && b) return -1;
for (let index = 0; index < 3; index += 1) {
const diff = (a?.[index] ?? 0) - (b?.[index] ?? 0);
if (diff !== 0) return diff;
}
return 0;
}
function resolveCodexAppServerCommand(): string {
if (process.env.HAPI_CODEX_APP_SERVER_BIN) {
return process.env.HAPI_CODEX_APP_SERVER_BIN;
}
const candidates: CodexCommandCandidate[] = [{
command: 'codex',
source: 'path',
version: getCodexVersion('codex')
}];
if (process.platform === 'darwin') {
const desktopCodex = '/Applications/Codex.app/Contents/Resources/codex';
if (existsSync(desktopCodex)) {
candidates.push({
command: desktopCodex,
source: 'desktop',
version: getCodexVersion(desktopCodex)
});
}
}
// 中文注释:Codex Desktop 与 npm CLI 都可能写 thread-store;恢复时选择版本更新的 app-server
// 避免旧 CLI 读取新 rollout 格式失败。版本相同优先 Desktop,和用户看到的 Codex.app 保持一致。
const best = candidates.sort((left, right) => {
const versionDiff = compareVersion(right.version, left.version);
if (versionDiff !== 0) return versionDiff;
if (left.source === right.source) return 0;
return left.source === 'desktop' ? -1 : 1;
})[0];
logger.debug('[CodexAppServer] Resolved codex command', {
selected: best.command,
candidates: candidates.map((candidate) => ({
command: candidate.command,
source: candidate.source,
version: candidate.version?.join('.') ?? null
}))
});
return best.command;
}
export class CodexAppServerClient extends JsonLineParser {
private process: ChildProcessWithoutNullStreams | null = null;
private connected = false;
@@ -93,7 +174,9 @@ export class CodexAppServerClient extends JsonLineParser {
return;
}
this.process = spawn('codex', ['app-server'], {
const codexCommand = resolveCodexAppServerCommand();
logger.debug(`[CodexAppServer] Starting ${codexCommand} app-server`);
this.process = spawn(codexCommand, ['app-server'], {
env: Object.keys(process.env).reduce((acc, key) => {
const value = process.env[key];
if (typeof value === 'string') acc[key] = value;
@@ -194,6 +277,14 @@ export class CodexAppServerClient extends JsonLineParser {
return response as ThreadResumeResponse;
}
async forkThread(params: ThreadForkParams, options?: { signal?: AbortSignal }): Promise<ThreadForkResponse> {
const response = await this.sendRequest('thread/fork', params, {
signal: options?.signal,
timeoutMs: CodexAppServerClient.DEFAULT_TIMEOUT_MS
});
return response as ThreadForkResponse;
}
async startTurn(params: TurnStartParams, options?: { signal?: AbortSignal }): Promise<TurnStartResponse> {
const response = await this.sendRequest('turn/start', params, {
signal: options?.signal,