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
+2
View File
@@ -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
+124 -1
View File
@@ -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()
+60 -2
View File
@@ -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,
+13
View File
@@ -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';
+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,
+1 -1
View File
@@ -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);
});
+50 -15
View File
@@ -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;
+2
View File
@@ -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
});
+8 -1
View File
@@ -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) => {
+3
View File
@@ -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;
+7
View File
@@ -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 })
})
})
+446
View File
@@ -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' }
}
}
+1
View File
@@ -4,6 +4,7 @@ export interface SpawnSessionOptions {
machineId?: string
directory: string
sessionId?: string
existingSessionId?: string
resumeSessionId?: string
approvedNewDirectoryCreation?: boolean
agent?: AgentFlavor
+39
View File
@@ -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', {
+6
View File
@@ -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);
}