fix(codex): fold a codex session into its own rollout row

Review fixes for #386.

Duplicate rows. A codex conversation showed twice, once live and once as a
past rollout row, because nothing aliased a codex session to its thread id.
That is worse than cosmetic: the stale row still resumes, so clicking it
starts a second `codex resume` on a thread already open in another pane.

  - A RESUMED session knows its thread id up front, so it folds from its own
    side: add `codexConfig.resumeSessionId` to the `claudeSessionId` chain.
    Not only in the constructor — `start()` recomputes that id at two further
    points (the mux branch, and the unconditional "third reset point" whose
    own comment already warned that omitting omp's fallback there stomps the
    mux branch's resolved alias). Both listed Claude's and omp's ids only, so
    for codex every mux reattach and boot recovery reset the alias back to
    the Codeman id and the duplicate returned.
  - A FRESH session has no thread id until codex writes the rollout, so it is
    folded from the other side. The scanner now reports
    `session_meta.originator`, which is `codeman_<sessionId>` for every pane
    Codeman spawns, and `gatherUnifiedInputs()` stamps the matching live and
    persisted rows, newest rollout winning (`/new` inside the TUI leaves
    several rollouts sharing one originator).
  - Persisted rows read `codexConfig.resumeSessionId` too. A resumed session
    demoted to a persisted-only record would otherwise lose its alias, and
    the originator fallback cannot rescue that one: a resumed rollout keeps
    its ORIGINAL session_meta, so it still names the pane that created the
    thread rather than the pane that resumed it.

Identity cache. It was written as soon as the thread id was known, but codex
writes the first user message only when the user submits, so any scan in that
window pinned `firstPrompt: undefined` for the life of the process — and the
home screen, the command palette and the search-index refresh all scan.
`shouldCacheIdentity()` now keeps an identity only once the prompt is known or
the head read filled its whole window.

Also from review: both caps count emitted rows rather than file index, so a
store of sub-agent threads no longer spends the `lastPrompt` budget before the
first row that needed it; the cache is an `LRUMap` sized like the one beside
it; the unreachable filename fallback is gone; a rollout recording no cwd is
dropped rather than emitted with `workingDir: ''`; and the unified-session
module header names all three transcript stores.

Tests. The resume wiring now has cases for a row with a thread id, a row
without one, and a `resumeId` on a non-codex row; the "no continuation is
wired" case narrows to gemini/antigravity, which is no longer true of codex.
`codex-resume-alias-survives-start.test.ts` drives a real Session through
`start()` rather than asserting on pre-stamped inputs — that gap is why the
reset points went unnoticed. Plus the maintainer's own cache repro, the
tail-budget case, a no-cwd case, and merge cases for both folds.
This commit is contained in:
Michael Grundberg
2026-09-06 21:53:38 +02:00
parent 8285fff91c
commit 327e440607
9 changed files with 442 additions and 58 deletions
+7 -2
View File
@@ -2990,11 +2990,16 @@ Object.assign(CodemanApp.prototype, {
// as a duplicate — click it 3 times, see the same name 3 times. Claude
// rows are left alone: `sessionId` there is a claudeSessionId, which
// usually has no live/persisted Codeman session of its own to delete.
// Gated on `continuesSomething`: for codex/gemini/antigravity (no
// continuation wired above), this is really a FRESH session with no
// Gated on `continuesSomething`: for gemini/antigravity, and for a codex
// row carrying no `resumeId`, this is really a FRESH session with no
// relation to the old row's conversation, so retiring it would discard
// the old conversation with no recovery — worse than the duplicate row
// this guard exists to prevent for the modes that DO continue.
//
// A codex row that DOES continue passes this gate, but the DELETE is a
// no-op for it: `sessionId` there is codex's thread id and no Codeman
// session carries that id. Its duplicate is cleared from the other side
// instead, by the alias fold in gatherUnifiedInputs()/Session.
if (effectiveMode !== 'claude' && continuesSomething && sessionId !== newSessionId) {
fetch(`/api/sessions/${sessionId}?killMux=true`, { method: 'DELETE' }).catch(() => {});
}
+31 -3
View File
@@ -145,7 +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 { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js';
import {
getLastTranscriptResponse,
isExternalCliTranscriptMode,
@@ -4062,6 +4062,13 @@ export function registerSessionRoutes(
// Persisted sessions (state.json). resumeSessionId is the Claude
// conversation UUID a resumed session continues — feed it to the merge's
// alias map so its transcript row folds into this session.
//
// codex keeps its thread id in `codexConfig` instead, and state.json stores
// that, so read it here as well. Without it a resumed codex session that has
// been demoted to a persisted-only record loses its alias and duplicates: the
// originator fallback below cannot rescue that one, because a RESUMED rollout
// keeps the original session_meta (see findActiveCodexFile) and so still
// names whichever pane first created the thread.
const persisted: PersistedSessionInput[] = Object.values(ctx.store.getState().sessions).map((p) => ({
id: p.id,
name: p.name,
@@ -4070,7 +4077,7 @@ export function registerSessionRoutes(
workingDir: p.workingDir,
createdAt: p.createdAt,
lastActivityAt: p.lastActivityAt,
claudeSessionId: p.resumeSessionId,
claudeSessionId: p.resumeSessionId || p.codexConfig?.resumeSessionId,
pinned: p.pinned,
pinnedAt: p.pinnedAt,
}));
@@ -4144,7 +4151,8 @@ export function registerSessionRoutes(
// 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()) {
const codexRows = await scanCodexSessionsHistory();
for (const h of codexRows) {
history.push({
sessionId: h.sessionId,
workingDir: h.workingDir,
@@ -4156,6 +4164,26 @@ export function registerSessionRoutes(
resumeId: h.sessionId,
});
}
// Fold a FRESH codex pane into its own rollout row. A resumed one already
// folds, because Session sets `claudeSessionId` from the resume id it was
// given; a fresh one has no thread id until codex writes the rollout, so
// the link has to come from the other side. Codeman spawns every codex pane
// with CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_<sessionId>, and codex
// stamps that into session_meta.originator, so the rollout names the pane.
//
// Newest rollout wins: `/new` typed inside the TUI leaves several rollouts
// carrying the same originator, and the pane is on the most recent one.
// Rows arrive newest-first, so the first match is it.
//
// Never overwrites an id a session already knows — that one came from the
// resume path and is authoritative.
const codexThreads = codexThreadBySessionId(codexRows);
for (const row of [...live, ...persisted]) {
if (row.claudeSessionId && row.claudeSessionId !== row.id) continue;
const threadId = codexThreads.get(row.id);
if (threadId) row.claudeSessionId = threadId;
}
} catch {
// Best-effort, same as the two scans above.
}