mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -95,6 +95,7 @@ function pickExistingSessionMetadata(metadata: Metadata | null | undefined): Par
|
||||
if (metadata.summary !== undefined) preserved.summary = metadata.summary
|
||||
if (metadata.claudeSessionId !== undefined) preserved.claudeSessionId = metadata.claudeSessionId
|
||||
if (metadata.codexSessionId !== undefined) preserved.codexSessionId = metadata.codexSessionId
|
||||
if (metadata.codexSourceSessionId !== undefined) preserved.codexSourceSessionId = metadata.codexSourceSessionId
|
||||
if (metadata.geminiSessionId !== undefined) preserved.geminiSessionId = metadata.geminiSessionId
|
||||
if (metadata.opencodeSessionId !== undefined) preserved.opencodeSessionId = metadata.opencodeSessionId
|
||||
if (metadata.grokSessionId !== undefined) preserved.grokSessionId = metadata.grokSessionId
|
||||
@@ -102,6 +103,7 @@ function pickExistingSessionMetadata(metadata: Metadata | null | undefined): Par
|
||||
if (metadata.cursorSessionProtocol !== undefined) preserved.cursorSessionProtocol = metadata.cursorSessionProtocol
|
||||
if (metadata.kimiSessionId !== undefined) preserved.kimiSessionId = metadata.kimiSessionId
|
||||
if (metadata.piSessionId !== undefined) preserved.piSessionId = metadata.piSessionId
|
||||
if (metadata.preferredPermissionMode !== undefined) preserved.preferredPermissionMode = metadata.preferredPermissionMode
|
||||
if (metadata.tools !== undefined) preserved.tools = metadata.tools
|
||||
if (metadata.slashCommands !== undefined) preserved.slashCommands = metadata.slashCommands
|
||||
if (metadata.worktree !== undefined) preserved.worktree = metadata.worktree
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync, mkdirSync, realpathSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, rmSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
@@ -92,6 +92,35 @@ async function callCursorChatStoreStatus(
|
||||
return JSON.parse(raw) as unknown
|
||||
}
|
||||
|
||||
async function callListCodexSessions(client: ApiMachineClient, machineId: string, params: { cwd?: string | null; sessionIds?: string[] }): Promise<unknown> {
|
||||
const manager = (client as unknown as { rpcHandlerManager: { handleRequest: (req: { method: string; params: string }) => Promise<string> } }).rpcHandlerManager
|
||||
const raw = await manager.handleRequest({
|
||||
method: `${machineId}:listCodexSessions`,
|
||||
params: JSON.stringify(params)
|
||||
})
|
||||
return JSON.parse(raw) as unknown
|
||||
}
|
||||
|
||||
async function callArchiveCodexSession(client: ApiMachineClient, machineId: string, sessionId: string): Promise<unknown> {
|
||||
const manager = (client as unknown as { rpcHandlerManager: { handleRequest: (req: { method: string; params: string }) => Promise<string> } }).rpcHandlerManager
|
||||
const raw = await manager.handleRequest({
|
||||
method: `${machineId}:archiveCodexSession`,
|
||||
params: JSON.stringify({ sessionId })
|
||||
})
|
||||
return JSON.parse(raw) as unknown
|
||||
}
|
||||
|
||||
function writeCodexTranscript(codexHome: string, fileName: string, payload: Record<string, unknown>, userText: string): string {
|
||||
const sessionDir = join(codexHome, 'sessions', '2026', '06', '29')
|
||||
mkdirSync(sessionDir, { recursive: true })
|
||||
const file = join(sessionDir, fileName)
|
||||
writeFileSync(file, [
|
||||
JSON.stringify({ type: 'session_meta', payload }),
|
||||
JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: userText }] } })
|
||||
].join('\n'))
|
||||
return file
|
||||
}
|
||||
|
||||
describe('ApiMachineClient cursor-chat-store-status handler', () => {
|
||||
beforeEach(() => {
|
||||
inspectCursorChatStoreMock.mockReset()
|
||||
@@ -300,6 +329,100 @@ describe('ApiMachineClient listGrokModelsForCwd handler', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ApiMachineClient Codex transcript handlers', () => {
|
||||
const originalCodexHome = process.env.CODEX_HOME
|
||||
let workspaceRoot: string
|
||||
let outsideRoot: string
|
||||
let codexHome: string
|
||||
|
||||
beforeEach(() => {
|
||||
ioMock.mockReset()
|
||||
listOpencodeModelsForCwdMock.mockReset()
|
||||
workspaceRoot = mkdtempSync(join(tmpdir(), 'hapi-codex-allowed-'))
|
||||
outsideRoot = mkdtempSync(join(tmpdir(), 'hapi-codex-outside-'))
|
||||
codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-'))
|
||||
process.env.CODEX_HOME = codexHome
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCodexHome === undefined) delete process.env.CODEX_HOME
|
||||
else process.env.CODEX_HOME = originalCodexHome
|
||||
rmSync(workspaceRoot, { recursive: true, force: true })
|
||||
rmSync(outsideRoot, { recursive: true, force: true })
|
||||
rmSync(codexHome, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('filters listed Codex sessions to workspace roots', async () => {
|
||||
writeCodexTranscript(codexHome, 'allowed.jsonl', {
|
||||
id: 'allowed-session-id',
|
||||
cwd: workspaceRoot
|
||||
}, 'allowed prompt')
|
||||
writeCodexTranscript(codexHome, 'outside.jsonl', {
|
||||
id: 'outside-session-id',
|
||||
cwd: outsideRoot
|
||||
}, 'outside prompt')
|
||||
|
||||
const machine = makeMachine('codex-machine-1')
|
||||
const client = new ApiMachineClient('cli-token', machine, [workspaceRoot])
|
||||
|
||||
try {
|
||||
const result = await callListCodexSessions(client, machine.id, {})
|
||||
|
||||
expect(result).toMatchObject({ success: true })
|
||||
const sessions = (result as { sessions: Array<{ id: string }> }).sessions
|
||||
expect(sessions.map((session) => session.id)).toEqual(['allowed-session-id'])
|
||||
} finally {
|
||||
client.shutdown()
|
||||
}
|
||||
})
|
||||
|
||||
it('filters import-by-sessionId Codex sessions to workspace roots before returning message bodies', async () => {
|
||||
writeCodexTranscript(codexHome, 'allowed.jsonl', {
|
||||
id: 'allowed-session-id',
|
||||
cwd: workspaceRoot
|
||||
}, 'allowed prompt')
|
||||
writeCodexTranscript(codexHome, 'outside.jsonl', {
|
||||
id: 'outside-session-id',
|
||||
cwd: outsideRoot
|
||||
}, 'outside prompt')
|
||||
|
||||
const machine = makeMachine('codex-machine-2')
|
||||
const client = new ApiMachineClient('cli-token', machine, [workspaceRoot])
|
||||
|
||||
try {
|
||||
const result = await callListCodexSessions(client, machine.id, {
|
||||
sessionIds: ['allowed-session-id', 'outside-session-id']
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ success: true })
|
||||
const sessions = (result as { sessions: Array<{ id: string; messages?: unknown[] }> }).sessions
|
||||
expect(sessions.map((session) => session.id)).toEqual(['allowed-session-id'])
|
||||
expect(sessions[0]?.messages).toHaveLength(1)
|
||||
} finally {
|
||||
client.shutdown()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects archive for Codex sessions outside workspace roots', async () => {
|
||||
const outsideFile = writeCodexTranscript(codexHome, 'outside.jsonl', {
|
||||
id: 'outside-session-id',
|
||||
cwd: outsideRoot
|
||||
}, 'outside prompt')
|
||||
|
||||
const machine = makeMachine('codex-machine-3')
|
||||
const client = new ApiMachineClient('cli-token', machine, [workspaceRoot])
|
||||
|
||||
try {
|
||||
const result = await callArchiveCodexSession(client, machine.id, 'outside-session-id')
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Codex session is outside workspace roots' })
|
||||
expect(existsSync(outsideFile)).toBe(true)
|
||||
} finally {
|
||||
client.shutdown()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('ApiMachineClient keepAlive lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -9,7 +9,15 @@ import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath }
|
||||
import { logger } from '@/ui/logger'
|
||||
import { configuration } from '@/configuration'
|
||||
import type { ClientToServerEvents, ServerToClientEvents, Update, UpdateMachineBody } from '@hapi/protocol'
|
||||
import type { MachineDirectoryEntry, MachineListDirectoryResponse, PathExistsResponse } from '@hapi/protocol/apiTypes'
|
||||
import {
|
||||
ArchiveCodexSessionRpcRequestSchema,
|
||||
ListCodexSessionsRpcRequestSchema,
|
||||
type ArchiveCodexSessionRpcResponse,
|
||||
type ListCodexSessionsRpcResponse,
|
||||
type MachineDirectoryEntry,
|
||||
type MachineListDirectoryResponse,
|
||||
type PathExistsResponse
|
||||
} from '@hapi/protocol/apiTypes'
|
||||
import { RPC_METHODS } from '@hapi/protocol/rpcMethods'
|
||||
import type { RunnerState, Machine, MachineMetadata } from './types'
|
||||
import { RunnerStateSchema, MachineMetadataSchema } from './types'
|
||||
@@ -29,6 +37,7 @@ import {
|
||||
} from '../modules/common/grokModels'
|
||||
import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes'
|
||||
import { applyVersionedAck } from './versionedUpdate'
|
||||
import { archiveLocalCodexSession, listLocalCodexSessionSummaries, listLocalCodexSessionsWithMessagesByIds } from '../modules/common/codexSessions'
|
||||
import { buildSocketIoExtraHeaderOptions } from './hubExtraHeaders'
|
||||
import { collectMachineHealth } from '@/utils/machineHealth'
|
||||
import { inspectCursorChatStore } from '@/cursor/cursorChatStoreStatus'
|
||||
@@ -257,6 +266,54 @@ export class ApiMachineClient {
|
||||
return await listGrokModelsForCwd(resolvedCwd)
|
||||
}
|
||||
)
|
||||
|
||||
this.rpcHandlerManager.registerHandler<unknown, ListCodexSessionsRpcResponse>(
|
||||
RPC_METHODS.ListCodexSessions,
|
||||
async (params) => {
|
||||
const parsed = ListCodexSessionsRpcRequestSchema.safeParse(params)
|
||||
if (!parsed.success) return { success: false, error: 'Invalid Codex sessions request' }
|
||||
const rawCwd = typeof parsed.data.cwd === 'string' ? parsed.data.cwd.trim() : ''
|
||||
if (rawCwd) {
|
||||
const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd)
|
||||
if (!this.isWithinWorkspaceRoots(resolvedCwd)) {
|
||||
return { success: false, error: 'Path is outside workspace roots' }
|
||||
}
|
||||
}
|
||||
const requestedIds = parsed.data.sessionIds
|
||||
? new Set(parsed.data.sessionIds)
|
||||
: null
|
||||
const allSessions = requestedIds
|
||||
? listLocalCodexSessionsWithMessagesByIds(requestedIds)
|
||||
: listLocalCodexSessionSummaries()
|
||||
const sessions = []
|
||||
for (const session of allSessions) {
|
||||
if (await this.isCodexSessionWithinWorkspaceRoots(session)) {
|
||||
sessions.push(session)
|
||||
}
|
||||
}
|
||||
return { success: true, sessions }
|
||||
}
|
||||
)
|
||||
|
||||
this.rpcHandlerManager.registerHandler<unknown, ArchiveCodexSessionRpcResponse>(
|
||||
RPC_METHODS.ArchiveCodexSession,
|
||||
async (params) => {
|
||||
const parsed = ArchiveCodexSessionRpcRequestSchema.safeParse(params)
|
||||
if (!parsed.success) return { success: false, error: 'Invalid Codex archive request' }
|
||||
const sessionId = parsed.data.sessionId.trim()
|
||||
return await archiveLocalCodexSession(sessionId, {
|
||||
canArchive: (session) => this.isCodexSessionWithinWorkspaceRoots(session)
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private async isCodexSessionWithinWorkspaceRoots(session: { cwd?: string | null }): Promise<boolean> {
|
||||
if (!this.normalizedWorkspaceRoots?.length) return true
|
||||
const cwd = session.cwd?.trim()
|
||||
if (!cwd) return false
|
||||
const resolvedCwd = await this.resolveForWorkspaceCheck(cwd)
|
||||
return this.isWithinWorkspaceRoots(resolvedCwd)
|
||||
}
|
||||
|
||||
private isWithinWorkspaceRoots(absolutePath: string): boolean {
|
||||
@@ -300,7 +357,7 @@ export class ApiMachineClient {
|
||||
|
||||
setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void {
|
||||
this.rpcHandlerManager.registerHandler(RPC_METHODS.SpawnHappySession, async (params: any) => {
|
||||
const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, serviceTier, token, sessionType, worktreeName } = params || {}
|
||||
const { directory, sessionId, existingSessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, serviceTier, token, sessionType, worktreeName } = params || {}
|
||||
|
||||
if (!directory) {
|
||||
throw new Error('Directory is required')
|
||||
@@ -314,6 +371,7 @@ export class ApiMachineClient {
|
||||
const result = await spawnSession({
|
||||
directory,
|
||||
sessionId,
|
||||
existingSessionId,
|
||||
resumeSessionId,
|
||||
machineId,
|
||||
approvedNewDirectoryCreation,
|
||||
|
||||
@@ -127,11 +127,24 @@ export interface ThreadResumeParams {
|
||||
export interface ThreadResumeResponse {
|
||||
thread: {
|
||||
id: string;
|
||||
turns?: Array<{ items?: ResponseItem[] }>;
|
||||
};
|
||||
model: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ThreadForkParams extends Omit<ThreadResumeParams, 'history' | 'path'> {
|
||||
}
|
||||
|
||||
export interface ThreadForkResponse {
|
||||
thread: {
|
||||
id: string;
|
||||
turns?: Array<{ items?: ResponseItem[] }>;
|
||||
};
|
||||
model?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type UserInput =
|
||||
| {
|
||||
type: 'text';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1984,7 +1984,7 @@ describe('codexRemoteLauncher', () => {
|
||||
expect(session.sessionId).toBe('thread-old');
|
||||
expect(sessionEvents).toContainEqual({
|
||||
type: 'message',
|
||||
message: 'Task failed: Codex conversation thread-old could not be resumed; no new conversation was created'
|
||||
message: 'Task failed: Codex conversation thread-old could not be resumed; no new conversation was created. Reason: resume failed'
|
||||
});
|
||||
expect(session.thinking).toBe(false);
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import { buildThreadStartParams, buildTurnStartParams } from './utils/appServerC
|
||||
import type { ThreadGoal, ThreadGoalStatus } from './appServerTypes';
|
||||
import { shouldIgnoreTerminalEvent } from './utils/terminalEventGuard';
|
||||
import { parseCodexSpecialCommand } from './codexSpecialCommands';
|
||||
import { extractErrorInfo } from '@/utils/errorUtils';
|
||||
import {
|
||||
RemoteLauncherBase,
|
||||
type RemoteLauncherDisplayContext,
|
||||
@@ -80,6 +81,17 @@ const CODEX_SPAWN_AGENT_FULL_HISTORY_ARGUMENT_ERROR =
|
||||
'Full-history forked agents inherit the parent agent type, model, and reasoning effort; ' +
|
||||
'omit agent_type, model, and reasoning_effort, or spawn without a full-history fork.';
|
||||
|
||||
function formatCodexResumeError(error: unknown): string {
|
||||
const info = extractErrorInfo(error);
|
||||
const message = info.message && info.message !== 'Unknown error' ? info.message : '';
|
||||
const record = error && typeof error === 'object' ? error as Record<string, unknown> : null;
|
||||
const name = error instanceof Error && error.name && error.name !== 'Error' ? error.name : '';
|
||||
const cause = record?.cause instanceof Error ? record.cause.message : typeof record?.cause === 'string' ? record.cause : '';
|
||||
const code = typeof record?.code === 'string' ? record.code : '';
|
||||
const parts = [name, code, message, cause].filter((part) => part.trim().length > 0);
|
||||
return parts.length > 0 ? Array.from(new Set(parts)).join(': ') : 'unknown resume error';
|
||||
}
|
||||
|
||||
const SAME_THREAD_RETRYABLE_ERROR_PATTERNS = [
|
||||
'selected model is at capacity',
|
||||
'codex thread entered systemerror'
|
||||
@@ -3513,10 +3525,19 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
|
||||
while (!this.shouldExit) {
|
||||
logActiveHandles('loop-top');
|
||||
if (!pending && (recoveryInFlight || (turnInFlight && session.queue.size() === 0))) {
|
||||
if (!pending && recoveryInFlight) {
|
||||
await waitForTurnOrRecovery(this.abortController.signal);
|
||||
if (this.abortController.signal.aborted && !this.shouldExit) {
|
||||
logger.debug('[codex]: Internal wait aborted while turn/recovery was active; continuing');
|
||||
logger.debug('[codex]: Internal wait aborted while recovery was active; continuing');
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!pending && turnInFlight && session.queue.size() === 0) {
|
||||
await waitForTurnOrRecovery(this.abortController.signal);
|
||||
if (this.abortController.signal.aborted && !this.shouldExit) {
|
||||
logger.debug('[codex]: Internal wait aborted while turn was active; continuing');
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
@@ -3579,20 +3600,34 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
|
||||
if (resumeCandidate) {
|
||||
try {
|
||||
const resumeResponse = await appServerClient.resumeThread({
|
||||
threadId: resumeCandidate,
|
||||
...threadParams
|
||||
}, {
|
||||
signal: this.abortController.signal
|
||||
});
|
||||
const resumeRecord = asRecord(resumeResponse);
|
||||
const resumeThread = resumeRecord ? asRecord(resumeRecord.thread) : null;
|
||||
threadId = asString(resumeThread?.id) ?? resumeCandidate;
|
||||
applyResolvedModel(resumeRecord?.model);
|
||||
logger.debug(`[Codex] Resumed app-server thread ${threadId}`);
|
||||
const shouldForkImportedSource = Boolean(
|
||||
session.sourceSessionId
|
||||
&& resumeCandidate === session.sourceSessionId
|
||||
);
|
||||
const response = shouldForkImportedSource
|
||||
? await appServerClient.forkThread({
|
||||
threadId: resumeCandidate,
|
||||
...threadParams
|
||||
}, {
|
||||
signal: this.abortController.signal
|
||||
})
|
||||
: await appServerClient.resumeThread({
|
||||
threadId: resumeCandidate,
|
||||
...threadParams
|
||||
}, {
|
||||
signal: this.abortController.signal
|
||||
});
|
||||
const responseRecord = asRecord(response);
|
||||
const responseThread = responseRecord ? asRecord(responseRecord.thread) : null;
|
||||
threadId = asString(responseThread?.id) ?? resumeCandidate;
|
||||
applyResolvedModel(responseRecord?.model);
|
||||
logger.debug(shouldForkImportedSource
|
||||
? `[Codex] Forked imported app-server thread ${resumeCandidate} -> ${threadId}`
|
||||
: `[Codex] Resumed app-server thread ${threadId}`);
|
||||
} catch (error) {
|
||||
logger.warn(`[Codex] Failed to resume app-server thread ${resumeCandidate}; preserving old conversation boundary`, error);
|
||||
const failureMessage = `Task failed: Codex conversation ${resumeCandidate} could not be resumed; no new conversation was created`;
|
||||
const resumeError = formatCodexResumeError(error);
|
||||
logger.warn(`[Codex] Failed to resume app-server thread ${resumeCandidate}; preserving old conversation boundary: ${resumeError}`, error);
|
||||
const failureMessage = `Task failed: Codex conversation ${resumeCandidate} could not be resumed; no new conversation was created. Reason: ${resumeError}`;
|
||||
messageBuffer.addMessage(failureMessage, 'status');
|
||||
session.sendSessionEvent({ type: 'message', message: failureMessage });
|
||||
pending = null;
|
||||
|
||||
@@ -38,6 +38,7 @@ interface LoopOptions {
|
||||
modelReasoningEffort?: ReasoningEffort;
|
||||
collaborationMode?: CodexCollaborationMode;
|
||||
resumeSessionId?: string;
|
||||
sourceSessionId?: string;
|
||||
replayTranscriptHistoryOnStart?: boolean;
|
||||
onSessionReady?: (session: CodexSession) => void;
|
||||
}
|
||||
@@ -63,6 +64,7 @@ export async function loop(opts: LoopOptions): Promise<void> {
|
||||
model: opts.model,
|
||||
modelReasoningEffort: opts.modelReasoningEffort,
|
||||
collaborationMode: opts.collaborationMode ?? 'default',
|
||||
sourceSessionId: opts.sourceSessionId,
|
||||
replayTranscriptHistoryOnStart: opts.replayTranscriptHistoryOnStart ?? false
|
||||
});
|
||||
|
||||
|
||||
@@ -60,6 +60,9 @@ export async function runCodex(opts: {
|
||||
modelReasoningEffort: opts.modelReasoningEffort
|
||||
});
|
||||
const { api, session, sessionInfo } = bootstrap;
|
||||
const codexSourceSessionId = typeof sessionInfo.metadata?.codexSourceSessionId === 'string'
|
||||
? sessionInfo.metadata.codexSourceSessionId
|
||||
: undefined;
|
||||
|
||||
const startingMode: 'local' | 'remote' = startedBy === 'runner' ? 'remote' : 'local';
|
||||
|
||||
@@ -79,7 +82,10 @@ export async function runCodex(opts: {
|
||||
// 需要在首次附着 transcript 时回放已有历史;恢复已有 Hapi 会话时则保持原来的增量模式,避免重复灌入旧消息。
|
||||
const replayTranscriptHistoryOnStart = useLazyBootstrap || Boolean(opts.resumeSessionId && !opts.existingSessionId);
|
||||
|
||||
let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default';
|
||||
const persistedPermissionMode = sessionInfo.permissionMode ?? sessionInfo.metadata?.preferredPermissionMode;
|
||||
let currentPermissionMode: PermissionMode = opts.permissionMode
|
||||
?? (persistedPermissionMode && isPermissionModeAllowedForFlavor(persistedPermissionMode, 'codex') ? persistedPermissionMode as PermissionMode : undefined)
|
||||
?? 'default';
|
||||
let currentModel = opts.model;
|
||||
let currentModelReasoningEffort: ReasoningEffort | undefined = opts.modelReasoningEffort;
|
||||
let currentCollaborationMode: EnhancedMode['collaborationMode'] = opts.collaborationMode ?? 'default';
|
||||
@@ -401,6 +407,7 @@ export async function runCodex(opts: {
|
||||
modelReasoningEffort: currentModelReasoningEffort,
|
||||
collaborationMode: currentCollaborationMode,
|
||||
resumeSessionId: opts.resumeSessionId,
|
||||
sourceSessionId: codexSourceSessionId,
|
||||
replayTranscriptHistoryOnStart,
|
||||
onModeChange: createModeChangeHandler(session),
|
||||
onSessionReady: (instance) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ export class CodexSession extends AgentSessionBase<EnhancedMode> {
|
||||
readonly startedBy: 'runner' | 'terminal';
|
||||
readonly startingMode: 'local' | 'remote';
|
||||
readonly replayTranscriptHistoryOnStart: boolean;
|
||||
readonly sourceSessionId?: string;
|
||||
localLaunchFailure: LocalLaunchFailure | null = null;
|
||||
|
||||
private transcriptPathCallbacks: Array<(path: string) => void> = [];
|
||||
@@ -40,6 +41,7 @@ export class CodexSession extends AgentSessionBase<EnhancedMode> {
|
||||
modelReasoningEffort?: SessionModelReasoningEffort;
|
||||
collaborationMode?: EnhancedMode['collaborationMode'];
|
||||
replayTranscriptHistoryOnStart?: boolean;
|
||||
sourceSessionId?: string;
|
||||
}) {
|
||||
super({
|
||||
api: opts.api,
|
||||
@@ -67,6 +69,7 @@ export class CodexSession extends AgentSessionBase<EnhancedMode> {
|
||||
this.startedBy = opts.startedBy;
|
||||
this.startingMode = opts.startingMode;
|
||||
this.replayTranscriptHistoryOnStart = opts.replayTranscriptHistoryOnStart ?? false;
|
||||
this.sourceSessionId = opts.sourceSessionId;
|
||||
this.permissionMode = opts.permissionMode;
|
||||
this.model = opts.model;
|
||||
this.modelReasoningEffort = opts.modelReasoningEffort;
|
||||
|
||||
@@ -31,6 +31,7 @@ export const codexCommand: CommandDefinition = {
|
||||
codexArgs?: string[]
|
||||
permissionMode?: CodexPermissionMode
|
||||
resumeSessionId?: string
|
||||
existingSessionId?: string
|
||||
model?: string
|
||||
modelReasoningEffort?: ReasoningEffort
|
||||
serviceTier?: string
|
||||
@@ -51,6 +52,12 @@ export const codexCommand: CommandDefinition = {
|
||||
}
|
||||
if (arg === '--started-by') {
|
||||
options.startedBy = commandArgs[++i] as 'runner' | 'terminal'
|
||||
} else if (arg === '--existing-session-id') {
|
||||
const sessionId = commandArgs[++i]
|
||||
if (!sessionId) {
|
||||
throw new Error('Missing --existing-session-id value')
|
||||
}
|
||||
options.existingSessionId = sessionId
|
||||
} else if (arg === '--permission-mode') {
|
||||
const mode = commandArgs[++i]
|
||||
if (!mode || !(CODEX_PERMISSION_MODES as readonly string[]).includes(mode)) {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { archiveLocalCodexSession, listLocalCodexSessionSummaries, listLocalCodexSessionsWithMessagesByIds } from './codexSessions'
|
||||
|
||||
describe('archiveLocalCodexSession', () => {
|
||||
const originalCodexHome = process.env.CODEX_HOME
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCodexHome === undefined) delete process.env.CODEX_HOME
|
||||
else process.env.CODEX_HOME = originalCodexHome
|
||||
})
|
||||
|
||||
it('moves a local codex transcript into archived_sessions preserving relative path', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'codex-home-'))
|
||||
process.env.CODEX_HOME = root
|
||||
const sessionFile = join(root, 'sessions', '2026', '06', '27', 'rollout-2026-06-27T12-00-00-12345678-1234-1234-1234-123456789abc.jsonl')
|
||||
mkdirSync(join(root, 'sessions', '2026', '06', '27'), { recursive: true })
|
||||
writeFileSync(sessionFile, [
|
||||
JSON.stringify({ type: 'session_meta', payload: { id: '12345678-1234-1234-1234-123456789abc', cwd: '/tmp/project' } }),
|
||||
JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hello' }] } })
|
||||
].join('\n'))
|
||||
|
||||
const sessions = listLocalCodexSessionSummaries()
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]?.id).toBe('12345678-1234-1234-1234-123456789abc')
|
||||
|
||||
const result = await archiveLocalCodexSession('12345678-1234-1234-1234-123456789abc')
|
||||
expect(result.success).toBe(true)
|
||||
if (!result.success) return
|
||||
expect(existsSync(sessionFile)).toBe(false)
|
||||
expect(existsSync(result.archivedPath)).toBe(true)
|
||||
expect(readFileSync(result.archivedPath, 'utf-8')).toContain('session_meta')
|
||||
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('refuses to archive when the caller denies the session', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'codex-home-'))
|
||||
process.env.CODEX_HOME = root
|
||||
const sessionFile = join(root, 'sessions', '2026', '06', '27', 'outside.jsonl')
|
||||
mkdirSync(join(root, 'sessions', '2026', '06', '27'), { recursive: true })
|
||||
writeFileSync(sessionFile, [
|
||||
JSON.stringify({ type: 'session_meta', payload: { id: 'outside-session-id', cwd: '/tmp/outside' } }),
|
||||
JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'outside' }] } })
|
||||
].join('\n'))
|
||||
|
||||
const result = await archiveLocalCodexSession('outside-session-id', { canArchive: () => false })
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Codex session is outside workspace roots' })
|
||||
expect(existsSync(sessionFile)).toBe(true)
|
||||
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('listLocalCodexSessionSummaries', () => {
|
||||
const originalCodexHome = process.env.CODEX_HOME
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCodexHome === undefined) delete process.env.CODEX_HOME
|
||||
else process.env.CODEX_HOME = originalCodexHome
|
||||
})
|
||||
|
||||
it('parses original and fork metadata from session_meta', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'codex-home-'))
|
||||
process.env.CODEX_HOME = root
|
||||
const sessionsDir = join(root, 'sessions', '2026', '06', '27')
|
||||
mkdirSync(sessionsDir, { recursive: true })
|
||||
|
||||
writeFileSync(join(sessionsDir, 'original.jsonl'), [
|
||||
JSON.stringify({
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: 'original-session-id',
|
||||
cwd: '/tmp/project',
|
||||
originator: 'Codex Desktop',
|
||||
cli_version: '0.142.2',
|
||||
source: 'vscode',
|
||||
thread_source: 'user'
|
||||
}
|
||||
}),
|
||||
JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hello' }] } })
|
||||
].join('\n'))
|
||||
|
||||
writeFileSync(join(sessionsDir, 'fork.jsonl'), [
|
||||
JSON.stringify({
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: 'fork-session-id',
|
||||
cwd: '/tmp/project',
|
||||
originator: 'hapi-codex-client',
|
||||
cli_version: '0.142.3',
|
||||
source: 'vscode',
|
||||
forked_from_id: 'original-session-id'
|
||||
}
|
||||
}),
|
||||
JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'fork hello' }] } })
|
||||
].join('\n'))
|
||||
|
||||
const sessions = listLocalCodexSessionSummaries()
|
||||
const original = sessions.find((session) => session.id === 'original-session-id')
|
||||
const fork = sessions.find((session) => session.id === 'fork-session-id')
|
||||
|
||||
expect(original).toMatchObject({
|
||||
source: 'vscode',
|
||||
threadSource: 'user',
|
||||
forkedFromId: null
|
||||
})
|
||||
expect(fork).toMatchObject({
|
||||
source: 'vscode',
|
||||
threadSource: null,
|
||||
forkedFromId: 'original-session-id'
|
||||
})
|
||||
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('uses the latest session_index thread name as the title', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'codex-home-'))
|
||||
process.env.CODEX_HOME = root
|
||||
const sessionsDir = join(root, 'sessions', '2026', '07', '19')
|
||||
mkdirSync(sessionsDir, { recursive: true })
|
||||
|
||||
writeFileSync(join(sessionsDir, 'session.jsonl'), [
|
||||
JSON.stringify({ type: 'session_meta', payload: { id: 'indexed-session-id', cwd: '/tmp/project' } }),
|
||||
JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'fallback title' }] } })
|
||||
].join('\n'))
|
||||
writeFileSync(join(root, 'session_index.jsonl'), [
|
||||
JSON.stringify({ id: 'indexed-session-id', thread_name: 'old title', updated_at: '2026-07-19T01:00:00Z' }),
|
||||
JSON.stringify({ id: 'indexed-session-id', thread_name: 'latest title', updated_at: '2026-07-19T02:00:00Z' })
|
||||
].join('\n'))
|
||||
|
||||
expect(listLocalCodexSessionSummaries()[0]?.title).toBe('latest title')
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('skips subagent transcripts', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'codex-home-'))
|
||||
process.env.CODEX_HOME = root
|
||||
const sessionsDir = join(root, 'sessions', '2026', '06', '27')
|
||||
mkdirSync(sessionsDir, { recursive: true })
|
||||
|
||||
writeFileSync(join(sessionsDir, 'subagent.jsonl'), [
|
||||
JSON.stringify({
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: 'subagent-session-id',
|
||||
cwd: '/tmp/project',
|
||||
source: { subagent: 'worker' }
|
||||
}
|
||||
}),
|
||||
JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hidden' }] } })
|
||||
].join('\n'))
|
||||
|
||||
expect(listLocalCodexSessionSummaries()).toHaveLength(0)
|
||||
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('loads messages only for requested session ids', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'codex-home-'))
|
||||
process.env.CODEX_HOME = root
|
||||
const sessionsDir = join(root, 'sessions', '2026', '06', '27')
|
||||
mkdirSync(sessionsDir, { recursive: true })
|
||||
|
||||
writeFileSync(join(sessionsDir, 'wanted.jsonl'), [
|
||||
JSON.stringify({ type: 'session_meta', payload: { id: 'wanted-session-id', cwd: '/tmp/project' } }),
|
||||
JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'wanted' }] } })
|
||||
].join('\n'))
|
||||
writeFileSync(join(sessionsDir, 'other.jsonl'), [
|
||||
JSON.stringify({ type: 'session_meta', payload: { id: 'other-session-id', cwd: '/tmp/project' } }),
|
||||
JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'other' }] } })
|
||||
].join('\n'))
|
||||
|
||||
const sessions = listLocalCodexSessionsWithMessagesByIds(new Set(['wanted-session-id']))
|
||||
|
||||
expect(sessions.map((session) => session.id)).toEqual(['wanted-session-id'])
|
||||
expect(sessions[0]?.messages).toHaveLength(1)
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,446 @@
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync } from 'node:fs'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { basename, dirname, join, relative } from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol'
|
||||
|
||||
const DEFAULT_CODEX_SESSION_SCAN_LIMIT = 200
|
||||
|
||||
type CodexSessionIndexTitle = {
|
||||
threadName: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
type CodexImportedMessageContent = {
|
||||
role: 'user'
|
||||
content: { type: 'text'; text: string }
|
||||
meta: { sentFrom: 'cli' }
|
||||
} | {
|
||||
role: 'agent'
|
||||
content: { type: typeof AGENT_MESSAGE_PAYLOAD_TYPE; data: unknown }
|
||||
meta: { sentFrom: 'cli' }
|
||||
}
|
||||
|
||||
export type LocalCodexSessionSummary = {
|
||||
id: string
|
||||
title: string
|
||||
lastUserMessage?: string | null
|
||||
cwd?: string | null
|
||||
file: string
|
||||
modifiedAt: number
|
||||
originator?: string | null
|
||||
cliVersion?: string | null
|
||||
source?: string | null
|
||||
threadSource?: string | null
|
||||
forkedFromId?: string | null
|
||||
}
|
||||
|
||||
export type LocalCodexSessionWithMessages = LocalCodexSessionSummary & {
|
||||
messages: CodexImportedMessageContent[]
|
||||
}
|
||||
|
||||
export type ArchiveLocalCodexSessionOptions = {
|
||||
canArchive?: (session: LocalCodexSessionSummary) => boolean | Promise<boolean>
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function extractCodexText(value: unknown): string {
|
||||
if (typeof value === 'string') return value.trim()
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => {
|
||||
const record = asRecord(item)
|
||||
if (record?.type === 'text' && typeof record.text === 'string') return record.text
|
||||
if (record?.type === 'input_text' && typeof record.text === 'string') return record.text
|
||||
if (record?.type === 'output_text' && typeof record.text === 'string') return record.text
|
||||
return null
|
||||
}).filter((part): part is string => Boolean(part)).join(' ').trim()
|
||||
}
|
||||
const record = asRecord(value)
|
||||
if (record?.type === 'text' && typeof record.text === 'string') return record.text.trim()
|
||||
if (record?.type === 'input_text' && typeof record.text === 'string') return record.text.trim()
|
||||
if (record?.type === 'output_text' && typeof record.text === 'string') return record.text.trim()
|
||||
return ''
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxLength: number): string {
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value
|
||||
}
|
||||
|
||||
function shouldIgnoreSyntheticUserMessage(text: string): boolean {
|
||||
const normalized = text.trim()
|
||||
return normalized.startsWith('# AGENTS.md instructions') || normalized.startsWith('<environment_context>')
|
||||
}
|
||||
|
||||
function isSubagentSource(value: unknown): boolean {
|
||||
const record = asRecord(value)
|
||||
return Boolean(record && Object.prototype.hasOwnProperty.call(record, 'subagent'))
|
||||
}
|
||||
|
||||
function inferSessionIdFromFileName(filePath: string): string | null {
|
||||
return /([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/.exec(filePath)?.[1] ?? null
|
||||
}
|
||||
|
||||
function collectJsonlFiles(root: string, files: string[]): void {
|
||||
let entries: import('node:fs').Dirent[]
|
||||
try {
|
||||
entries = readdirSync(root, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(root, entry.name)
|
||||
if (entry.isDirectory()) collectJsonlFiles(fullPath, files)
|
||||
else if (entry.isFile() && fullPath.toLowerCase().endsWith('.jsonl')) files.push(fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
function getCodexHome(): string {
|
||||
return process.env.CODEX_HOME?.trim() || join(homedir(), '.codex')
|
||||
}
|
||||
|
||||
function getCodexSessionRoots(): string[] {
|
||||
const codexHome = process.env.CODEX_HOME?.trim() || join(homedir(), '.codex')
|
||||
return [join(codexHome, 'sessions')]
|
||||
}
|
||||
|
||||
function getCodexSessionIndexPath(): string {
|
||||
return join(getCodexHome(), 'session_index.jsonl')
|
||||
}
|
||||
|
||||
function readCodexSessionIndexTitles(): Map<string, CodexSessionIndexTitle> {
|
||||
let content: string
|
||||
try {
|
||||
content = readFileSync(getCodexSessionIndexPath(), 'utf-8')
|
||||
} catch {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
const titles = new Map<string, CodexSessionIndexTitle>()
|
||||
for (const line of content.split(/\r?\n/).filter(Boolean)) {
|
||||
try {
|
||||
const record = asRecord(JSON.parse(line))
|
||||
const id = typeof record?.id === 'string' ? record.id : null
|
||||
const threadName = typeof record?.thread_name === 'string' && record.thread_name.trim()
|
||||
? record.thread_name.trim()
|
||||
: null
|
||||
const updatedAt = typeof record?.updated_at === 'string' && record.updated_at.trim()
|
||||
? record.updated_at.trim()
|
||||
: null
|
||||
if (!id || !threadName || !updatedAt) continue
|
||||
|
||||
const previous = titles.get(id)
|
||||
if (!previous || previous.updatedAt < updatedAt) {
|
||||
titles.set(id, { threadName, updatedAt })
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return titles
|
||||
}
|
||||
|
||||
function extractCodexChangedTitle(record: Record<string, unknown>): string | null {
|
||||
if (record.type === 'response_item') {
|
||||
const payload = asRecord(record.payload)
|
||||
if (payload?.type === 'function_call' && payload.name === 'change_title' && typeof payload.arguments === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(payload.arguments) as { title?: unknown }
|
||||
return typeof parsed.title === 'string' && parsed.title.trim() ? parsed.title.trim() : null
|
||||
} catch { return null }
|
||||
}
|
||||
}
|
||||
if (record.type === 'event_msg') {
|
||||
const payload = asRecord(record.payload)
|
||||
const invocation = asRecord(payload?.invocation)
|
||||
const args = asRecord(invocation?.arguments)
|
||||
if (payload?.type === 'mcp_tool_call_end' && invocation?.tool === 'change_title' && typeof args?.title === 'string' && args.title.trim()) {
|
||||
return args.title.trim()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getLatestCodexChangedTitle(lines: string[]): string | null {
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
try {
|
||||
const record = asRecord(JSON.parse(lines[index]))
|
||||
if (!record) continue
|
||||
const title = extractCodexChangedTitle(record)
|
||||
if (title) return title
|
||||
} catch { continue }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getLatestCodexUserMessage(lines: string[]): string | null {
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
try {
|
||||
const record = asRecord(JSON.parse(lines[index]))
|
||||
if (!record || record.type !== 'response_item') continue
|
||||
const payload = asRecord(record.payload)
|
||||
if (payload?.type !== 'message' || payload.role !== 'user') continue
|
||||
const text = extractCodexText(payload.content)
|
||||
if (text && !shouldIgnoreSyntheticUserMessage(text)) return truncateText(text, 140)
|
||||
} catch { continue }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getCodexSessionTitle(cwd: string | null | undefined, sessionId: string, sessionIndexTitle: string | null, changedTitle: string | null, firstUserMessage: string | null): string {
|
||||
if (sessionIndexTitle) return truncateText(sessionIndexTitle, 80)
|
||||
if (changedTitle) return changedTitle
|
||||
if (firstUserMessage) return truncateText(firstUserMessage, 80)
|
||||
if (cwd) return basename(cwd) || cwd
|
||||
return sessionId.slice(0, 8)
|
||||
}
|
||||
|
||||
function parseCodexFunctionArguments(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return value
|
||||
try { return JSON.parse(trimmed) } catch { return value }
|
||||
}
|
||||
|
||||
function extractCodexToolCallId(payload: Record<string, unknown>): string | null {
|
||||
for (const key of ['call_id', 'callId', 'tool_call_id', 'toolCallId', 'id']) {
|
||||
const value = payload[key]
|
||||
if (typeof value === 'string' && value.length > 0) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function buildImportedUserMessage(text: string): CodexImportedMessageContent {
|
||||
return { role: 'user', content: { type: 'text', text }, meta: { sentFrom: 'cli' } }
|
||||
}
|
||||
|
||||
function buildImportedAgentMessage(data: unknown): CodexImportedMessageContent {
|
||||
return { role: 'agent', content: { type: AGENT_MESSAGE_PAYLOAD_TYPE, data }, meta: { sentFrom: 'cli' } }
|
||||
}
|
||||
|
||||
function convertCodexRecordToImportedMessage(record: Record<string, unknown>): CodexImportedMessageContent | null {
|
||||
const type = asString(record.type)
|
||||
const payload = asRecord(record.payload)
|
||||
if (!type || !payload) return null
|
||||
if (type === 'event_msg') {
|
||||
const eventType = asString(payload.type)
|
||||
if (eventType === 'user_message') {
|
||||
const text = asString(payload.message) ?? asString(payload.text) ?? asString(payload.content)
|
||||
return text && !shouldIgnoreSyntheticUserMessage(text) ? buildImportedUserMessage(text) : null
|
||||
}
|
||||
if (eventType === 'agent_message') {
|
||||
const message = asString(payload.message)
|
||||
return message ? buildImportedAgentMessage({ type: 'message', message, id: randomUUID() }) : null
|
||||
}
|
||||
if (eventType === 'token_count') {
|
||||
const info = asRecord(payload.info)
|
||||
return info ? buildImportedAgentMessage({ type: 'token_count', info, id: randomUUID() }) : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (type !== 'response_item') return null
|
||||
const itemType = asString(payload.type)
|
||||
if (itemType === 'message') {
|
||||
const role = asString(payload.role)
|
||||
const text = extractCodexText(payload.content)
|
||||
if (!text || shouldIgnoreSyntheticUserMessage(text)) return null
|
||||
if (role === 'user') return buildImportedUserMessage(text)
|
||||
if (role === 'assistant') return buildImportedAgentMessage({ type: 'message', message: text, id: randomUUID() })
|
||||
}
|
||||
if (itemType === 'function_call') {
|
||||
const name = asString(payload.name)
|
||||
const callId = extractCodexToolCallId(payload)
|
||||
return name && callId ? buildImportedAgentMessage({ type: 'tool-call', name, callId, input: parseCodexFunctionArguments(payload.arguments), id: randomUUID() }) : null
|
||||
}
|
||||
if (itemType === 'function_call_output') {
|
||||
const callId = extractCodexToolCallId(payload)
|
||||
return callId ? buildImportedAgentMessage({ type: 'tool-call-result', callId, output: payload.output, id: randomUUID() }) : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function stableSerialize(value: unknown): string {
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
||||
if (Array.isArray(value)) return `[${value.map(stableSerialize).join(',')}]`
|
||||
const record = value as Record<string, unknown>
|
||||
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`).join(',')}}`
|
||||
}
|
||||
|
||||
function normalizeComparableContent(content: unknown): string | null {
|
||||
const record = asRecord(content)
|
||||
if (!record) return null
|
||||
if (record.role === 'user') {
|
||||
const body = asRecord(record.content)
|
||||
return body?.type === 'text' && typeof body.text === 'string'
|
||||
? stableSerialize({ role: 'user', text: body.text.replace(/\s+$/u, '') })
|
||||
: null
|
||||
}
|
||||
if (record.role === 'agent') {
|
||||
const body = asRecord(record.content)
|
||||
const data = asRecord(body?.data)
|
||||
const normalized = data ? { ...data } : body?.data
|
||||
if (data) delete (normalized as Record<string, unknown>).id
|
||||
return body?.type === AGENT_MESSAGE_PAYLOAD_TYPE ? stableSerialize({ role: 'agent', data: normalized }) : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function deduplicateAdjacentImportedMessages(messages: CodexImportedMessageContent[]): CodexImportedMessageContent[] {
|
||||
const deduped: CodexImportedMessageContent[] = []
|
||||
let previousKey: string | null = null
|
||||
for (const message of messages) {
|
||||
const key = normalizeComparableContent(message)
|
||||
if (key && key === previousKey) continue
|
||||
deduped.push(message)
|
||||
previousKey = key
|
||||
}
|
||||
return deduped
|
||||
}
|
||||
|
||||
function parseCodexLocalSession(
|
||||
filePath: string,
|
||||
includeMessages: boolean,
|
||||
sessionIndexTitles = new Map<string, CodexSessionIndexTitle>()
|
||||
): LocalCodexSessionWithMessages | LocalCodexSessionSummary | null {
|
||||
let content: string
|
||||
try { content = readFileSync(filePath, 'utf-8') } catch { return null }
|
||||
const lines = content.split(/\r?\n/).filter(Boolean)
|
||||
const headLines = lines.slice(0, 200)
|
||||
let sessionId: string | null = null
|
||||
let cwd: string | null = null
|
||||
let originator: string | null = null
|
||||
let cliVersion: string | null = null
|
||||
let source: string | null = null
|
||||
let threadSource: string | null = null
|
||||
let forkedFromId: string | null = null
|
||||
let firstUserMessage: string | null = null
|
||||
const messages: CodexImportedMessageContent[] = []
|
||||
|
||||
if (includeMessages) {
|
||||
for (const line of lines) {
|
||||
let record: Record<string, unknown> | null = null
|
||||
try { record = asRecord(JSON.parse(line)) } catch { continue }
|
||||
if (!record) continue
|
||||
const message = convertCodexRecordToImportedMessage(record)
|
||||
if (message) messages.push(message)
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of headLines) {
|
||||
try {
|
||||
const record = asRecord(JSON.parse(line))
|
||||
if (!record) continue
|
||||
if (record.type === 'session_meta') {
|
||||
const payload = asRecord(record.payload)
|
||||
if (isSubagentSource(payload?.source)) return null
|
||||
if (!sessionId && typeof payload?.id === 'string') sessionId = payload.id
|
||||
if (!cwd && typeof payload?.cwd === 'string') cwd = payload.cwd
|
||||
if (!originator && typeof payload?.originator === 'string') originator = payload.originator
|
||||
if (!cliVersion && typeof payload?.cli_version === 'string') cliVersion = payload.cli_version
|
||||
if (!source && typeof payload?.source === 'string') source = payload.source
|
||||
if (!threadSource && typeof payload?.thread_source === 'string') threadSource = payload.thread_source
|
||||
if (!forkedFromId && typeof payload?.forked_from_id === 'string') forkedFromId = payload.forked_from_id
|
||||
}
|
||||
if (!firstUserMessage && record.type === 'response_item') {
|
||||
const payload = asRecord(record.payload)
|
||||
if (payload?.type === 'message' && payload.role === 'user') {
|
||||
const text = extractCodexText(payload.content)
|
||||
if (text && !shouldIgnoreSyntheticUserMessage(text)) firstUserMessage = text
|
||||
}
|
||||
}
|
||||
} catch { continue }
|
||||
}
|
||||
|
||||
sessionId = sessionId ?? inferSessionIdFromFileName(filePath)
|
||||
if (!sessionId) return null
|
||||
const sessionIndexTitle = sessionIndexTitles.get(sessionId)?.threadName ?? null
|
||||
const changedTitle = getLatestCodexChangedTitle(lines)
|
||||
const lastUserMessage = getLatestCodexUserMessage(lines)
|
||||
let modifiedAt = Date.now()
|
||||
try { modifiedAt = statSync(filePath).mtimeMs } catch {}
|
||||
const summary = {
|
||||
id: sessionId,
|
||||
title: getCodexSessionTitle(cwd, sessionId, sessionIndexTitle, changedTitle, firstUserMessage),
|
||||
lastUserMessage,
|
||||
cwd,
|
||||
file: filePath,
|
||||
modifiedAt,
|
||||
originator,
|
||||
cliVersion,
|
||||
source,
|
||||
threadSource,
|
||||
forkedFromId
|
||||
}
|
||||
return includeMessages ? { ...summary, messages: deduplicateAdjacentImportedMessages(messages) } : summary
|
||||
}
|
||||
|
||||
function listLocalCodexSessions(includeMessages: false, limit?: number): LocalCodexSessionSummary[]
|
||||
function listLocalCodexSessions(includeMessages: true, limit?: number): LocalCodexSessionWithMessages[]
|
||||
function listLocalCodexSessions(includeMessages: boolean, limit = DEFAULT_CODEX_SESSION_SCAN_LIMIT): Array<LocalCodexSessionSummary | LocalCodexSessionWithMessages> {
|
||||
const files: string[] = []
|
||||
for (const root of getCodexSessionRoots()) collectJsonlFiles(root, files)
|
||||
const sessionIndexTitles = readCodexSessionIndexTitles()
|
||||
const deduped = new Map<string, LocalCodexSessionSummary | LocalCodexSessionWithMessages>()
|
||||
for (const file of files) {
|
||||
const session = parseCodexLocalSession(file, includeMessages, sessionIndexTitles)
|
||||
if (!session) continue
|
||||
const previous = deduped.get(session.id)
|
||||
if (!previous || previous.modifiedAt < session.modifiedAt) deduped.set(session.id, session)
|
||||
}
|
||||
return Array.from(deduped.values()).sort((a, b) => b.modifiedAt - a.modifiedAt).slice(0, limit)
|
||||
}
|
||||
|
||||
export function listLocalCodexSessionSummaries(limit = DEFAULT_CODEX_SESSION_SCAN_LIMIT): LocalCodexSessionSummary[] {
|
||||
return listLocalCodexSessions(false, limit)
|
||||
}
|
||||
|
||||
export function listLocalCodexSessionsWithMessages(limit = DEFAULT_CODEX_SESSION_SCAN_LIMIT): LocalCodexSessionWithMessages[] {
|
||||
return listLocalCodexSessions(true, limit)
|
||||
}
|
||||
|
||||
export function listLocalCodexSessionsWithMessagesByIds(ids: Set<string>): LocalCodexSessionWithMessages[] {
|
||||
if (ids.size === 0) return []
|
||||
const sessionIndexTitles = readCodexSessionIndexTitles()
|
||||
return listLocalCodexSessionSummaries(Number.MAX_SAFE_INTEGER)
|
||||
.filter((session) => ids.has(session.id))
|
||||
.map((session) => parseCodexLocalSession(session.file, true, sessionIndexTitles))
|
||||
.filter((session): session is LocalCodexSessionWithMessages => Boolean(session))
|
||||
}
|
||||
|
||||
|
||||
export async function archiveLocalCodexSession(sessionId: string, options: ArchiveLocalCodexSessionOptions = {}): Promise<{ success: true; archivedPath: string } | { success: false; error: string }> {
|
||||
const normalizedId = sessionId.trim()
|
||||
if (!normalizedId) return { success: false, error: 'sessionId is required' }
|
||||
|
||||
const sessionsRoot = getCodexSessionRoots()[0]
|
||||
const archivedRoot = join(getCodexHome(), 'archived_sessions')
|
||||
const sessions = listLocalCodexSessionSummaries(DEFAULT_CODEX_SESSION_SCAN_LIMIT * 5)
|
||||
const target = sessions.find((session) => session.id === normalizedId)
|
||||
if (!target) return { success: false, error: 'Codex session not found' }
|
||||
if (options.canArchive && !(await options.canArchive(target))) {
|
||||
return { success: false, error: 'Codex session is outside workspace roots' }
|
||||
}
|
||||
|
||||
const relativePath = relative(sessionsRoot, target.file)
|
||||
if (!relativePath || relativePath.startsWith('..')) {
|
||||
return { success: false, error: 'Codex session file is outside local sessions root' }
|
||||
}
|
||||
|
||||
const archivedPath = join(archivedRoot, relativePath)
|
||||
try {
|
||||
mkdirSync(dirname(archivedPath), { recursive: true })
|
||||
if (existsSync(archivedPath)) {
|
||||
return { success: false, error: 'Archived Codex session already exists' }
|
||||
}
|
||||
renameSync(target.file, archivedPath)
|
||||
return { success: true, archivedPath }
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Failed to archive Codex session' }
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export interface SpawnSessionOptions {
|
||||
machineId?: string
|
||||
directory: string
|
||||
sessionId?: string
|
||||
existingSessionId?: string
|
||||
resumeSessionId?: string
|
||||
approvedNewDirectoryCreation?: boolean
|
||||
agent?: AgentFlavor
|
||||
|
||||
@@ -92,6 +92,45 @@ describe('buildCliArgs', () => {
|
||||
expect(args).not.toContain('--service-tier')
|
||||
})
|
||||
|
||||
it('passes existing Hapi session id separately from Codex resume thread', () => {
|
||||
const args = buildCliArgs('codex', {
|
||||
directory: '/tmp',
|
||||
resumeSessionId: 'codex-thread-1',
|
||||
existingSessionId: 'hapi-session-1',
|
||||
model: 'gpt-5.5',
|
||||
modelReasoningEffort: 'low',
|
||||
})
|
||||
expect(args).toEqual([
|
||||
'codex',
|
||||
'resume',
|
||||
'codex-thread-1',
|
||||
'--hapi-starting-mode',
|
||||
'remote',
|
||||
'--started-by',
|
||||
'runner',
|
||||
'--existing-session-id',
|
||||
'hapi-session-1',
|
||||
'--model',
|
||||
'gpt-5.5',
|
||||
'--model-reasoning-effort',
|
||||
'low',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
|
||||
it('does not pass Codex-only existing session id flag to non-Codex agents', () => {
|
||||
const args = buildCliArgs('claude', {
|
||||
directory: '/tmp',
|
||||
resumeSessionId: 'claude-session-1',
|
||||
existingSessionId: 'hapi-session-1',
|
||||
})
|
||||
expect(args).toContain('--resume')
|
||||
expect(args).toContain('claude-session-1')
|
||||
expect(args).not.toContain('--existing-session-id')
|
||||
expect(args).not.toContain('hapi-session-1')
|
||||
})
|
||||
|
||||
it('validates all known permission modes', () => {
|
||||
for (const mode of ['default', 'acceptEdits', 'auto', 'bypassPermissions', 'plan', 'ask', 'debug', 'autoReview', 'read-only', 'safe-yolo', 'yolo']) {
|
||||
const args = buildCliArgs('claude', {
|
||||
|
||||
@@ -1115,6 +1115,12 @@ export function buildCliArgs(
|
||||
}
|
||||
}
|
||||
args.push('--hapi-starting-mode', 'remote', '--started-by', 'runner');
|
||||
if (agent === 'codex') {
|
||||
const existingSessionId = options.existingSessionId ?? options.sessionId;
|
||||
if (existingSessionId) {
|
||||
args.push('--existing-session-id', existingSessionId);
|
||||
}
|
||||
}
|
||||
if (options.model) {
|
||||
args.push('--model', options.model);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user