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
+19 -4
View File
@@ -39,6 +39,11 @@ export type UnifiedSessionItem = {
/** Main repo root a worktree belongs to (#266). */
worktreeRepo?: string;
remote?: boolean;
/**
* Token this row's CLI resumes by, when that is not `sessionId`. Set only from
* a transcript scanner — see the field of the same name on `HistoryInput`.
*/
resumeId?: string;
/** Pinned to the top of the session manager list (COD-139). */
pinned?: boolean;
/** When the session was pinned (epoch ms) — orders the pinned group desc. */
@@ -100,12 +105,21 @@ export type HistoryInput = {
worktreeName?: string;
worktreeRepo?: string;
/**
* Set only by a non-claude transcript source (currently omp); the Claude
* scanner never stamps this; the meaningfulness floor below still counts a
* row with a `mode` as real, since that also signals "not claude" — see
* where it's read below for the isReal check this touches.
* Set only by a non-claude transcript source (currently omp and codex); the
* Claude scanner never stamps this; the meaningfulness floor below still
* counts a row with a `mode` as real, since that also signals "not claude" —
* see where it's read below for the isReal check this touches.
*/
mode?: string;
/**
* The token this CLI's own resume command expects, when it is NOT the row's
* `sessionId`. Codex names a thread by an id of its own that lives in the
* rollout, and a live codex session's `sessionId` is Codeman's uuid instead —
* so a resume that reused `sessionId` would ask codex for a thread that does
* not exist. Only a transcript scanner sets this, which is what keeps the two
* kinds of row apart.
*/
resumeId?: string;
};
/** Mux process-stat view. */
@@ -186,6 +200,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
// transcript source (currently only omp) does, so a history-only row
// still gets a mode badge instead of reading as claude by default.
overwrite(item, 'mode', h.mode);
overwrite(item, 'resumeId', h.resumeId);
const ms = Date.parse(h.lastModified);
if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms;
}