Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions cli/src/modules/common/codexSessions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { mkdir, writeFile, utimes } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { listCodexSessions } from './codexSessions'

describe('listCodexSessions', () => {
const originalCodexHome = process.env.CODEX_HOME
let codexHome: string

beforeEach(async () => {
codexHome = join(tmpdir(), `hapi-codex-sessions-${Date.now()}-${Math.random().toString(16).slice(2)}`)
process.env.CODEX_HOME = codexHome
await mkdir(join(codexHome, 'sessions'), { recursive: true })
})

afterEach(() => {
if (originalCodexHome === undefined) {
delete process.env.CODEX_HOME
return
}
process.env.CODEX_HOME = originalCodexHome
})

it('lists codex sessions and hides old entries by default', async () => {
const sessionsDir = join(codexHome, 'sessions')
const recentPath = join(sessionsDir, 'recent.jsonl')
const oldPath = join(sessionsDir, 'old.jsonl')

await writeFile(recentPath, `${JSON.stringify({ type: 'session_meta', payload: { id: 'thread-recent', cwd: '/repo/recent', model: 'gpt-5' } })}\n`)
await writeFile(oldPath, `${JSON.stringify({ type: 'session_meta', payload: { id: 'thread-old', cwd: '/repo/old', model: 'gpt-5' } })}\n`)

const oldDate = new Date(Date.now() - (190 * 24 * 60 * 60 * 1000))
await utimes(oldPath, oldDate, oldDate)

const recentOnly = await listCodexSessions()
expect(recentOnly.sessions.map((entry) => entry.id)).toEqual(['thread-recent'])

const withOld = await listCodexSessions({ includeOld: true })
expect(withOld.sessions.map((entry) => entry.id)).toEqual(['thread-recent', 'thread-old'])
expect(withOld.sessions[1]?.isOld).toBe(true)
})

it('supports cursor pagination', async () => {
const sessionsDir = join(codexHome, 'sessions')
for (let i = 0; i < 3; i++) {
const sessionPath = join(sessionsDir, `s-${i}.jsonl`)
await writeFile(sessionPath, `${JSON.stringify({ type: 'session_meta', payload: { id: `thread-${i}` } })}\n`)
}

const page1 = await listCodexSessions({ includeOld: true, limit: 2 })
expect(page1.sessions.length).toBe(2)
expect(page1.nextCursor).toBe('2')

const page2 = await listCodexSessions({ includeOld: true, limit: 2, cursor: page1.nextCursor ?? undefined })
expect(page2.sessions.length).toBe(1)
expect(page2.nextCursor).toBeNull()
})
})
227 changes: 227 additions & 0 deletions cli/src/modules/common/codexSessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { homedir } from 'node:os';
import { basename, extname, join } from 'node:path';
import { promises as fs } from 'node:fs';

export interface CodexSessionSummary {
id: string;
title: string;
updatedAt: number;
path: string | null;
model: string | null;
isOld: boolean;
}

export interface ListCodexSessionsRequest {
includeOld?: boolean;
olderThanDays?: number;
limit?: number;
cursor?: string;
}

export interface ListCodexSessionsResponse {
success: boolean;
sessions?: CodexSessionSummary[];
nextCursor?: string | null;
error?: string;
}

type RawSession = {
id: string;
title: string;
updatedAt: number;
path: string | null;
model: string | null;
};

function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object') {
return null;
}
return value as Record<string, unknown>;
}

function asNonEmptyString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}

function readCodexHome(): string {
return process.env.CODEX_HOME ?? join(homedir(), '.codex');
}

async function walkJsonlFiles(rootDir: string, maxFiles: number): Promise<string[]> {
const queue: string[] = [rootDir];
const files: string[] = [];

while (queue.length > 0 && files.length < maxFiles) {
const current = queue.shift();
if (!current) {
break;
}

let entries: fs.Dirent[];
try {
entries = await fs.readdir(current, { withFileTypes: true });
} catch {
continue;
}

for (const entry of entries) {
if (entry.name.startsWith('.')) {
continue;
}
const fullPath = join(current, entry.name);
if (entry.isDirectory()) {
queue.push(fullPath);
continue;
}
if (entry.isFile() && extname(entry.name) === '.jsonl') {
files.push(fullPath);
if (files.length >= maxFiles) {
break;
}
}
}
}

return files;
}

function parseSessionLine(line: string): { id?: string; title?: string; path?: string; model?: string } | null {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
return null;
}

const record = asRecord(parsed);
if (!record) {
return null;
}

const type = asNonEmptyString(record.type);
const payload = asRecord(record.payload);

if (type === 'session_meta') {
const id = asNonEmptyString(payload?.id) ?? asNonEmptyString(record.id);
const path = asNonEmptyString(payload?.cwd) ?? asNonEmptyString(payload?.path);
const model = asNonEmptyString(payload?.model);
return { id: id ?? undefined, path: path ?? undefined, model: model ?? undefined };
}

if (type === 'event_msg') {
const messageType = asNonEmptyString(payload?.type);
if (messageType === 'agent_message') {
const text = asNonEmptyString(payload?.message) ?? asNonEmptyString(payload?.text);
if (text) {
return { title: text.slice(0, 80) };
}
}

if (messageType === 'thread_started') {
const id = asNonEmptyString(payload?.thread_id) ?? asNonEmptyString(payload?.threadId) ?? asNonEmptyString(payload?.id);
return id ? { id } : null;
}
}

return null;
}

async function parseSessionFile(filePath: string): Promise<RawSession | null> {
let stat: fs.Stats;
let content: string;
try {
[stat, content] = await Promise.all([
fs.stat(filePath),
fs.readFile(filePath, 'utf8')
]);
} catch {
return null;
}

const lines = content.split('\n').filter((line) => line.trim().length > 0).slice(0, 400);
let id: string | null = null;
let title: string | null = null;
let path: string | null = null;
let model: string | null = null;

for (const line of lines) {
const parsed = parseSessionLine(line);
if (!parsed) {
continue;
}
if (!id && parsed.id) {
id = parsed.id;
}
if (!title && parsed.title) {
title = parsed.title;
}
if (!path && parsed.path) {
path = parsed.path;
}
if (!model && parsed.model) {
model = parsed.model;
}
}

const fallbackId = basename(filePath, extname(filePath));
const resolvedId = id ?? fallbackId;
if (!resolvedId) {
return null;
}

return {
id: resolvedId,
title: title ?? resolvedId,
updatedAt: Math.max(0, Math.floor(stat.mtimeMs)),
path,
model
};
}

export async function listCodexSessions(request: ListCodexSessionsRequest = {}): Promise<{ sessions: CodexSessionSummary[]; nextCursor: string | null }> {
const includeOld = request.includeOld === true;
const olderThanDays = Number.isFinite(request.olderThanDays) && (request.olderThanDays ?? 0) > 0
? Number(request.olderThanDays)
: 180;
const limit = Number.isFinite(request.limit) && (request.limit ?? 0) > 0
? Math.min(100, Math.floor(Number(request.limit)))
: 50;
const offset = Number.isFinite(Number(request.cursor)) && Number(request.cursor) >= 0
? Math.floor(Number(request.cursor))
: 0;

const sessionsDir = join(readCodexHome(), 'sessions');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Scope Codex session discovery to the runner workspace. This walks every Codex transcript under CODEX_HOME/sessions, so a runner configured with --workspace-root can still expose titles and absolute paths for sessions outside that root through the new web endpoint. Existing machine directory/spawn paths enforce the workspace boundary; this new handler should filter the discovered sessions before returning them.

Suggested fix:

export async function listCodexSessions(
    request: ListCodexSessionsRequest = {},
    options: { workspaceRoot?: string } = {}
): Promise<{ sessions: CodexSessionSummary[]; nextCursor: string | null }> {
    const workspaceRoot = options.workspaceRoot ? await fs.realpath(options.workspaceRoot).catch(() => options.workspaceRoot) : null;
    // ...after building sorted:
    const scoped = workspaceRoot
        ? sorted.filter((entry) => entry.path && isPathWithinWorkspace(entry.path, workspaceRoot))
        : sorted;
    const filtered = includeOld ? scoped : scoped.filter((entry) => !entry.isOld);
}

const files = await walkJsonlFiles(sessionsDir, 5000);
const raw = (await Promise.all(files.map((filePath) => parseSessionFile(filePath))))
.filter((entry): entry is RawSession => entry !== null);

const deduped = new Map<string, RawSession>();
for (const entry of raw) {
const existing = deduped.get(entry.id);
if (!existing || existing.updatedAt < entry.updatedAt) {
deduped.set(entry.id, entry);
}
}

const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
const sorted = Array.from(deduped.values())
.sort((a, b) => b.updatedAt - a.updatedAt)
.map((entry) => ({
id: entry.id,
title: entry.title,
updatedAt: entry.updatedAt,
path: entry.path,
model: entry.model,
isOld: entry.updatedAt < cutoff
}));

const filtered = includeOld ? sorted : sorted.filter((entry) => !entry.isOld);
const sliced = filtered.slice(offset, offset + limit);
const nextOffset = offset + sliced.length;

return {
sessions: sliced,
nextCursor: nextOffset < filtered.length ? String(nextOffset) : null
};
}
26 changes: 26 additions & 0 deletions cli/src/modules/common/handlers/codexSessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { logger } from '@/ui/logger';
import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager';
import {
listCodexSessions,
type ListCodexSessionsRequest,
type ListCodexSessionsResponse
} from '../codexSessions';
import { getErrorMessage, rpcError } from '../rpcResponses';

export function registerCodexSessionHandlers(rpcHandlerManager: RpcHandlerManager): void {
rpcHandlerManager.registerHandler<ListCodexSessionsRequest, ListCodexSessionsResponse>('listCodexSessions', async (data) => {
logger.debug('List Codex sessions request');

try {
const result = await listCodexSessions(data ?? {});
return {
success: true,
sessions: result.sessions,
nextCursor: result.nextCursor
};
} catch (error) {
logger.debug('Failed to list Codex sessions:', error);
return rpcError(getErrorMessage(error, 'Failed to list Codex sessions'));
}
});
}
2 changes: 2 additions & 0 deletions cli/src/modules/common/registerCommonHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { RpcHandlerManager } from '@/api/rpc/RpcHandlerManager'
import { registerBashHandlers } from './handlers/bash'
import { registerCodexModelHandlers } from './handlers/codexModels'
import { registerCodexSessionHandlers } from './handlers/codexSessions'
import { registerDirectoryHandlers } from './handlers/directories'
import { registerDifftasticHandlers } from './handlers/difftastic'
import { registerFileHandlers } from './handlers/files'
Expand All @@ -13,6 +14,7 @@ import { registerUploadHandlers } from './handlers/uploads'
export function registerCommonHandlers(rpcHandlerManager: RpcHandlerManager, workingDirectory: string): void {
registerBashHandlers(rpcHandlerManager, workingDirectory)
registerCodexModelHandlers(rpcHandlerManager)
registerCodexSessionHandlers(rpcHandlerManager)
registerFileHandlers(rpcHandlerManager, workingDirectory)
registerDirectoryHandlers(rpcHandlerManager, workingDirectory)
registerRipgrepHandlers(rpcHandlerManager, workingDirectory)
Expand Down
24 changes: 24 additions & 0 deletions hub/src/sync/rpcGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,23 @@ export type RpcListCodexModelsResponse = {
error?: string
}


export type RpcCodexSession = {
id: string
title: string
updatedAt: number
path: string | null
model: string | null
isOld: boolean
}

export type RpcListCodexSessionsResponse = {
success: boolean
sessions?: RpcCodexSession[]
nextCursor?: string | null
error?: string
}

export class RpcGateway {
constructor(
private readonly io: Server,
Expand Down Expand Up @@ -262,6 +279,13 @@ export class RpcGateway {
return await this.machineRpc(machineId, 'listCodexModels', {}) as RpcListCodexModelsResponse
}

async listCodexSessionsForMachine(
machineId: string,
options?: { includeOld?: boolean; olderThanDays?: number; limit?: number; cursor?: string }
): Promise<RpcListCodexSessionsResponse> {
return await this.machineRpc(machineId, 'listCodexSessions', options ?? {}) as RpcListCodexSessionsResponse
}

private async sessionRpc(sessionId: string, method: string, params: unknown): Promise<unknown> {
return await this.rpcCall(`${sessionId}:${method}`, params)
}
Expand Down
10 changes: 10 additions & 0 deletions hub/src/sync/syncEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type RpcDeleteUploadResponse,
type RpcListDirectoryResponse,
type RpcListCodexModelsResponse,
type RpcListCodexSessionsResponse,
type RpcPathExistsResponse,
type RpcReadFileResponse,
type RpcUploadFileResponse
Expand Down Expand Up @@ -586,4 +587,13 @@ export class SyncEngine {
async listCodexModelsForMachine(machineId: string): Promise<RpcListCodexModelsResponse> {
return await this.rpcGateway.listCodexModelsForMachine(machineId)
}


async listCodexSessionsForMachine(
machineId: string,
options?: { includeOld?: boolean; olderThanDays?: number; limit?: number; cursor?: string }
): Promise<RpcListCodexSessionsResponse> {
return await this.rpcGateway.listCodexSessionsForMachine(machineId, options)
}

}
Loading
Loading