feat(codex): list codex conversations and resume them

Codex conversations never appeared in the session list, and the resume path
skipped codex, so picking one back up meant finding its thread id by hand and
POSTing codexConfig.resumeSessionId to /api/sessions.

Two gaps caused it:

- The unified list is built from ~/.claude/projects plus omp's own store.
  Codex writes to neither: its rollouts live in ~/.codex/sessions/<y>/<m>/<d>.
- terminal-ui.js sends a continuation only for the CLIs with a
  "continue most recent" flag. Codex has no such flag — it names a thread by an
  exact id — and nothing supplied one.

Add codex-transcript.ts, the codex analog of omp-transcript.ts, and wire it into
gatherUnifiedInputs() beside the omp scan. A rollout row carries `resumeId`, the
thread id `codex resume` takes, and the resume path sends it as
codexConfig.resumeSessionId.

`resumeId` is what keeps the two kinds of row apart: only a transcript scanner
sets it, so a LIVE codex row — whose sessionId is Codeman's own uuid — can never
ask codex for a thread that does not exist.

Three things measured against a real store of 519 rollouts rather than assumed:

- Rollouts are far too large to read whole (median 407 KiB, p90 1.3 MiB, max
  25 MiB, 381 MiB total), so this reads a 128 KiB head for the identity and the
  opening prompt and a bounded tail for the most recent one. session_meta is
  written once and never rewritten, so per-path identity is cached; a warm
  rescan of that store costs ~75ms against ~470ms cold.
- codex 0.152.1 emits no event_msg/user_message rows at all. It writes
  event_msg/item_completed carrying an item.type of UserMessage. Both shapes are
  read, plus response_item as a last resort.
- That last resort sees injected context, and the first such row is the repo's
  AGENTS.md every time, so injections are dropped rather than used as titles.

Sub-agent threads (thread_source: 'subagent') are left out; codex spawns them
for itself and on a real store they outnumber the resumable threads.
This commit is contained in:
Michael Grundberg
2026-09-06 19:45:36 +02:00
parent 6f7add7ce4
commit 8285fff91c
10 changed files with 650 additions and 20 deletions
+7 -1
View File
@@ -389,7 +389,13 @@ Object.assign(CodemanApp.prototype, {
async resumeMobileOverviewSession(sessionId) {
const row = (this._mobileOverviewPastRows || []).find((r) => r.id === sessionId);
if (!row || !row.workingDir) return;
await this.resumeHistorySession(row.claudeSessionId || row.id, row.workingDir, row.name || undefined, row.mode);
await this.resumeHistorySession(
row.claudeSessionId || row.id,
row.workingDir,
row.name || undefined,
row.mode,
row.resumeId
);
},
// ═══════════════════════════════════════════════════════════════
+7 -1
View File
@@ -670,7 +670,13 @@ Object.assign(CodemanApp.prototype, {
} else if (record.workingDir) {
// History rows are keyed by the Claude conversation UUID; resumed
// sessions carry theirs separately as claudeSessionId.
void this.resumeHistorySession(s.claudeSessionId || s.sessionId, record.workingDir, undefined, s.mode);
void this.resumeHistorySession(
s.claudeSessionId || s.sessionId,
record.workingDir,
undefined,
s.mode,
s.resumeId
);
}
},
});
+1 -1
View File
@@ -692,7 +692,7 @@ Object.assign(CodemanApp.prototype, {
btn.append(...parts);
btn.addEventListener('click', (e) => {
e.stopPropagation();
this.resumeHistorySession(s.sessionId, s.workingDir, s.name, s.mode);
this.resumeHistorySession(s.sessionId, s.workingDir, s.name, s.mode, s.resumeId);
});
container.appendChild(btn);
}
+19 -11
View File
@@ -2193,7 +2193,7 @@ Object.assign(CodemanApp.prototype, {
if (isLive && this.sessions.has(s.sessionId)) {
this.selectSession(s.sessionId);
} else {
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode);
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode, s.resumeId);
}
})
);
@@ -2436,7 +2436,7 @@ Object.assign(CodemanApp.prototype, {
} else {
// Resume by the Claude conversation UUID when present (resumed sessions
// carry theirs separately from their Codeman id).
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode);
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode, s.resumeId);
}
this.closeSessionManager?.();
closeMenu();
@@ -2904,7 +2904,7 @@ Object.assign(CodemanApp.prototype, {
return `w${startNumber}-${dirName}`;
},
async resumeHistorySession(sessionId, workingDir, existingName, mode) {
async resumeHistorySession(sessionId, workingDir, existingName, mode, resumeId) {
// Close the run mode menu if open
document.getElementById('runModeMenu')?.classList.remove('active');
// Close folder history modal if open
@@ -2942,19 +2942,27 @@ Object.assign(CodemanApp.prototype, {
grok: 'grokConfig',
omp: 'ompConfig',
}[effectiveMode];
// codex/gemini/antigravity have no wired continuation here yet (their
// configs use an exact conversation id, not a "continue most recent"
// flag, and the row's own `sessionId` is not verified to carry that
// id for these three modes) — `continuesSomething` below is what keeps
// their row from being retired for a resume that didn't actually
// continue anything.
// codex names a thread by an id of its own, not by Codeman's session id,
// so it continues only when the row carried that id: `resumeId` is set by
// the rollout scanner (codex-transcript.ts) and by nothing else, which is
// what stops a LIVE codex row — whose sessionId is Codeman's uuid — from
// asking codex for a thread that does not exist.
//
// gemini/antigravity still have no wired continuation here (same reason
// codex used to have none: an exact conversation id nothing supplies) —
// `continuesSomething` below is what keeps their row from being retired
// for a resume that didn't actually continue anything.
const codexResumeId = effectiveMode === 'codex' ? resumeId : undefined;
const modeConfig =
modeConfigKey
? { [modeConfigKey]: { continueSession: true } }
: effectiveMode === 'deepseek'
? { deepSeekConfig: { resumeSession: true } }
: {};
const continuesSomething = Boolean(modeConfigKey) || effectiveMode === 'deepseek';
: codexResumeId
? { codexConfig: { resumeSessionId: codexResumeId } }
: {};
const continuesSomething =
Boolean(modeConfigKey) || effectiveMode === 'deepseek' || Boolean(codexResumeId);
const createRes = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
+23
View File
@@ -145,6 +145,7 @@ import {
import { LRUMap } from '../../utils/lru-map.js';
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
import { scanCodexSessionsHistory } from '../../codex-transcript.js';
import {
getLastTranscriptResponse,
isExternalCliTranscriptMode,
@@ -4137,6 +4138,28 @@ export function registerSessionRoutes(
// Best-effort, same as the claude scan above.
}
// Codex's own rollout store (~/.codex/sessions) — the same treatment omp
// gets above, and for the same reason: codex writes no Claude transcript, so
// without this a codex conversation disappears from the list as soon as its
// session record does. `resumeId` is the rollout's own thread id, which is
// what `codex resume` takes; see codex-transcript.ts.
try {
for (const h of await scanCodexSessionsHistory()) {
history.push({
sessionId: h.sessionId,
workingDir: h.workingDir,
sizeBytes: h.sizeBytes,
lastModified: h.lastModified,
firstPrompt: h.firstPrompt,
lastPrompt: h.lastPrompt,
mode: 'codex',
resumeId: h.sessionId,
});
}
} catch {
// Best-effort, same as the two scans above.
}
// Mux process stats (best-effort; guard against mocks lacking the method).
let mux: MuxStatInput[] = [];
try {